From f161424dcd1b623eab5798479ba5d8b6880d40fb Mon Sep 17 00:00:00 2001 From: tamakyi Date: Wed, 9 Sep 2026 13:58:08 +0800 Subject: [PATCH] =?UTF-8?q?git-sync-web=20=E8=87=AA=E5=8A=A8=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=202026-09-09=2013:58:07?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _smoke/base/configs/demo.conf | 14 +++ _smoke/base/logs/demo.log | 5 + _smoke/repo | 1 + _smoke/server.log | 1 + _smoke2/base/configs/demo.conf | 14 +++ _smoke2/base/logs/demo.log | 1 + _smoke2/repo | 1 + _smoke2/server.log | 1 + git.go | 16 ++- static/app.js | 173 +++++++++++++++++++++++++++++++-- 10 files changed, 214 insertions(+), 13 deletions(-) create mode 100644 _smoke/base/configs/demo.conf create mode 100644 _smoke/base/logs/demo.log create mode 160000 _smoke/repo create mode 100644 _smoke/server.log create mode 100644 _smoke2/base/configs/demo.conf create mode 100644 _smoke2/base/logs/demo.log create mode 160000 _smoke2/repo create mode 100644 _smoke2/server.log diff --git a/_smoke/base/configs/demo.conf b/_smoke/base/configs/demo.conf new file mode 100644 index 0000000..0ecf185 --- /dev/null +++ b/_smoke/base/configs/demo.conf @@ -0,0 +1,14 @@ +# Git 自动同步配置 - demo +REPO_PATH="C:/OtherFiles/Github/git-auto-sync/git-sync-web/_smoke/repo" +BRANCH="main" +REMOTES="origin=https://example.invalid/x.git" +MODE="both" +INTERVAL="60" +LOG_FILE="" +AUTH_METHOD="https" +RETRY_COUNT="0" +RETRY_INTERVAL="0" +CONFLICT_STRATEGY="merge" +PUSH_STRATEGY="manual" +AUTO_COMMIT="0" +CRON="" diff --git a/_smoke/base/logs/demo.log b/_smoke/base/logs/demo.log new file mode 100644 index 0000000..f0e389e --- /dev/null +++ b/_smoke/base/logs/demo.log @@ -0,0 +1,5 @@ +[2026-09-09 13:51:38] [info] 配置已创建(路径 C:/OtherFiles/Github/git-auto-sync/git-sync-web/_smoke/repo) +[2026-09-09 13:51:43] [info] 监控已启动 +[2026-09-09 13:51:43] [info] 已添加远程 origin → https://example.invalid/x.git +[2026-09-09 13:51:44] [info] === 开始同步(启动检查,模式 both)=== +[2026-09-09 13:51:45] [error] fetch 失败: fatal: unable to access 'https://example.invalid/x.git/': CONNECT tunnel failed, response 502 diff --git a/_smoke/repo b/_smoke/repo new file mode 160000 index 0000000..39a01d8 --- /dev/null +++ b/_smoke/repo @@ -0,0 +1 @@ +Subproject commit 39a01d89da349508c5f3fdf60ae480745f41efa0 diff --git a/_smoke/server.log b/_smoke/server.log new file mode 100644 index 0000000..5085d0b --- /dev/null +++ b/_smoke/server.log @@ -0,0 +1 @@ +2026/09/09 13:50:57 Git Sync Web 已启动: http://127.0.0.1:5831(鉴权:false) diff --git a/_smoke2/base/configs/demo.conf b/_smoke2/base/configs/demo.conf new file mode 100644 index 0000000..330b0cd --- /dev/null +++ b/_smoke2/base/configs/demo.conf @@ -0,0 +1,14 @@ +# Git 自动同步配置 - demo +REPO_PATH="C:/OtherFiles/Github/git-auto-sync/git-sync-web/_smoke2/repo" +BRANCH="main" +REMOTES="origin=https://example.invalid/x.git" +MODE="both" +INTERVAL="60" +LOG_FILE="" +AUTH_METHOD="https" +RETRY_COUNT="0" +RETRY_INTERVAL="0" +CONFLICT_STRATEGY="merge" +PUSH_STRATEGY="manual" +AUTO_COMMIT="0" +CRON="" diff --git a/_smoke2/base/logs/demo.log b/_smoke2/base/logs/demo.log new file mode 100644 index 0000000..bdeb821 --- /dev/null +++ b/_smoke2/base/logs/demo.log @@ -0,0 +1 @@ +[2026-09-09 13:55:33] [info] 配置已创建(路径 C:/OtherFiles/Github/git-auto-sync/git-sync-web/_smoke2/repo) diff --git a/_smoke2/repo b/_smoke2/repo new file mode 160000 index 0000000..e7bbd5c --- /dev/null +++ b/_smoke2/repo @@ -0,0 +1 @@ +Subproject commit e7bbd5cce2e657817cedfa2bbf0eecd81ed8b450 diff --git a/_smoke2/server.log b/_smoke2/server.log new file mode 100644 index 0000000..01375f7 --- /dev/null +++ b/_smoke2/server.log @@ -0,0 +1 @@ +2026/09/09 13:54:59 Git Sync Web 已启动: http://127.0.0.1:5832(鉴权:false) diff --git a/git.go b/git.go index 4d41869..9fb8dc0 100644 --- a/git.go +++ b/git.go @@ -30,16 +30,22 @@ func gitRun(repoPath string, timeout time.Duration, args ...string) (string, err "GIT_SSH_COMMAND=ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new", ) cmd.WaitDelay = 10 * time.Second - var buf bytes.Buffer - cmd.Stdout = &buf - cmd.Stderr = &buf + // stdout 与 stderr 分开收集:成功时只返回干净的 stdout 供解析, + // 避免 git 的环境告警(如 autocrlf、进度信息)污染解析结果; + // 失败时优先用 stderr 作为错误信息,保证诊断信息不丢失。 + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf err := cmd.Run() - out := strings.TrimSpace(buf.String()) + out := strings.TrimSpace(outBuf.String()) if err != nil { if ctx.Err() == context.DeadlineExceeded { return out, fmt.Errorf("命令超时: git %s", strings.Join(args, " ")) } - msg := out + msg := strings.TrimSpace(errBuf.String()) + if msg == "" { + msg = out + } if msg == "" { msg = err.Error() } diff --git a/static/app.js b/static/app.js index 431f490..fc9e534 100644 --- a/static/app.js +++ b/static/app.js @@ -434,14 +434,38 @@ function updateConflictActions() { } // ---------- 差异显示(网页端) ---------- +// diffPath 非空时只显示单个文件的差异(编辑器「对比此文件」进入) +let diffPath = ''; +// diffText 保存当前差异原文,供「复制 / 下载 .patch」使用 +let diffText = ''; + +function updateDiffFilterNote() { + const el = $('#diff-filter'); + if (!el) return; + if (!diffPath) { el.hidden = true; el.textContent = ''; return; } + el.hidden = false; + el.innerHTML = `仅显示:${esc(diffPath)} ✕ 显示全部`; + const a = $('#diff-filter-clear'); + if (a) a.addEventListener('click', (e) => { + e.preventDefault(); + diffPath = ''; + updateDiffFilterNote(); + if (current) loadDiff(current); + }); +} + function loadDiff(name) { const scope = $('#f-diff-scope').value; const box = $('#diffbox'); const stat = $('#diff-stat'); box.textContent = '加载中…'; stat.innerHTML = ''; - api(`/api/repos/${name}/diff?scope=${encodeURIComponent(scope)}`).then(d => { - renderDiff(box, d.text || '(无差异)'); + updateDiffFilterNote(); + let url = `/api/repos/${name}/diff?scope=${encodeURIComponent(scope)}`; + if (diffPath) url += `&path=${encodeURIComponent(diffPath)}`; + api(url).then(d => { + diffText = d.text || ''; + renderDiff(box, diffText || '(无差异)'); if (d.files && d.files.length) { stat.innerHTML = d.files.map(f => `${esc(f.path)} +${f.add} -${f.del}` @@ -470,33 +494,81 @@ function renderDiff(box, text) { // ---------- 文件编辑(网页端) ---------- let fileCurrent = null; +const STATUS_LABEL = { M: '已修改', A: '已新增', D: '已删除', '?': '未跟踪' }; function loadTree(name) { const list = $('#file-list'); list.textContent = '加载中…'; api(`/api/repos/${name}/tree`).then(d => { const files = d.files || []; if (!files.length) { list.textContent = '(无文件)'; return; } - list.innerHTML = files.map(f => - `
${esc(f.path)}
` - ).join(''); + list.innerHTML = files.map(f => { + const st = f.status || ''; + const cls = st === '?' ? 'Q' : st; + const badge = st + ? `${esc(st)}` + : ''; + return `
${esc(f.path)}${badge}
`; + }).join(''); }).catch(err => { list.textContent = '文件列表加载失败:' + err.message; }); } +// ---------- 草稿缓存(localStorage,防刷新丢稿) ---------- +const draftKey = (name, path) => `gitautosync:draft:${name}:${path}`; +let draftServer = ''; // 当前文件在磁盘上的原文,用于「放弃草稿」回滚 +let draftTimer = null; + +function showDraftNote(on) { + const el = $('#file-draft'); + if (!el) return; + if (!on) { el.hidden = true; el.innerHTML = ''; return; } + el.hidden = false; + el.innerHTML = '已恢复未保存的草稿 放弃草稿'; + const a = $('#draft-discard'); + if (a) a.addEventListener('click', (e) => { + e.preventDefault(); + localStorage.removeItem(draftKey(current, fileCurrent)); + $('#file-editor').value = draftServer; + showDraftNote(false); + }); +} + +// 编辑时防抖缓存草稿;内容与磁盘一致时自动清除 +function scheduleDraft() { + if (!current || !fileCurrent) return; + if (draftTimer) clearTimeout(draftTimer); + draftTimer = setTimeout(() => { + const editor = $('#file-editor'); + if (!editor || editor.disabled) return; + const v = editor.value; + if (v === draftServer) localStorage.removeItem(draftKey(current, fileCurrent)); + else localStorage.setItem(draftKey(current, fileCurrent), v); + }, 600); +} + async function loadFile(name, path) { const editor = $('#file-editor'); editor.value = '加载中…'; editor.disabled = false; $('#file-current').textContent = path; fileCurrent = path; + showDraftNote(false); try { const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`); if (d.binary) { editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`; editor.disabled = true; - } else { - editor.value = d.content || ''; - editor.disabled = false; + draftServer = ''; + return; } + draftServer = d.content || ''; + const saved = localStorage.getItem(draftKey(name, path)); + if (saved !== null && saved !== draftServer) { + editor.value = saved; + showDraftNote(true); + } else { + editor.value = draftServer; + } + editor.disabled = false; } catch (err) { editor.value = '读取失败:' + err.message; editor.disabled = true; @@ -517,6 +589,10 @@ async function saveFile(commit) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, content }) }); + // 写盘成功 → 草稿作废 + localStorage.removeItem(draftKey(name, path)); + showDraftNote(false); + draftServer = content; if (commit) { await api(`/api/repos/${name}/commit`, { method: 'POST', @@ -535,7 +611,40 @@ async function saveFile(commit) { $('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); }); $('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(current); }); + +// 差异:复制 / 下载 .patch +$('#btn-diff-copy').addEventListener('click', async () => { + if (!diffText) { alert('当前没有可复制的差异内容'); return; } + try { + await navigator.clipboard.writeText(diffText); + alert('差异内容已复制到剪贴板'); + } catch (err) { + alert('复制失败(浏览器可能限制了剪贴板权限):' + err.message); + } +}); +$('#btn-diff-download').addEventListener('click', () => { + if (!diffText) { alert('当前没有可下载的差异内容'); return; } + const scope = $('#f-diff-scope').value; + const blob = new Blob([diffText], { type: 'text/plain;charset=utf-8' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${current || 'repo'}-${scope}.patch`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(a.href); +}); + $('#btn-file-refresh').addEventListener('click', () => { if (current) loadTree(current); }); + +// 编辑器「对比此文件」→ 跳到差异 tab 并只看该文件 +$('#btn-file-diff').addEventListener('click', () => { + if (!current || !fileCurrent) { alert('请先在左侧选择一个文件'); return; } + diffPath = fileCurrent; + $('#f-diff-scope').value = 'working'; + showTab('diff'); +}); + $('#file-list').addEventListener('click', (e) => { const item = e.target.closest('.file-item'); if (!item || !current) return; @@ -544,6 +653,15 @@ $('#file-list').addEventListener('click', (e) => { $('#btn-file-save').addEventListener('click', () => saveFile(false)); $('#btn-file-commit').addEventListener('click', () => saveFile(true)); +// 编辑器:输入时缓存草稿,Ctrl/Cmd + S 直接保存 +$('#file-editor').addEventListener('input', scheduleDraft); +$('#file-editor').addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { + e.preventDefault(); + saveFile(false); + } +}); + // ---------- 统计 ---------- function dateStr(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); @@ -607,6 +725,7 @@ function fillForm(r) { $('#f-autocommit').checked = r ? !!r.auto_commit : false; $('#f-cron').value = r ? (r.cron || '') : ''; $('#f-log').value = r ? (r.log_file || '') : ''; + previewCron(); } function readForm() { @@ -674,6 +793,43 @@ $('#btn-sync-all').addEventListener('click', () => { repos.filter(r => r.running).forEach(r => post(`/api/repos/${r.name}/sync`).catch(() => {})); }); +// ---------- 暗色模式 ---------- +const THEME_KEY = 'gitautosync:theme'; +function applyTheme(dark) { + document.documentElement.classList.toggle('dark', !!dark); + const btn = $('#btn-theme'); + if (btn) btn.textContent = dark ? '☀️' : '🌙'; +} +function initTheme() { + applyTheme(localStorage.getItem(THEME_KEY) === 'dark'); +} +$('#btn-theme').addEventListener('click', () => { + const dark = !document.documentElement.classList.contains('dark'); + localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light'); + applyTheme(dark); +}); + +// ---------- Cron 下次执行预览 ---------- +let cronTimer = null; +async function previewCron() { + const el = $('#cron-next'); + if (!el) return; + const expr = $('#f-cron').value.trim(); + if (!expr) { el.textContent = ''; el.className = 'cron-next'; return; } + try { + const d = await api(`/api/cron-next?expr=${encodeURIComponent(expr)}`); + el.textContent = '下次执行:' + d.next; + el.className = 'cron-next good'; + } catch (err) { + el.textContent = '表达式无效:' + err.message; + el.className = 'cron-next bad'; + } +} +$('#f-cron').addEventListener('input', () => { + if (cronTimer) clearTimeout(cronTimer); + cronTimer = setTimeout(previewCron, 400); +}); + // ---------- 初始化 ---------- async function refresh() { try { @@ -684,6 +840,7 @@ async function refresh() { } catch (e) { /* 忽略,SSE 会重试 */ } } +initTheme(); refresh(); connect(); setInterval(refresh, 20000);