diff --git a/_sm/base/configs/demo.conf b/_sm/base/configs/demo.conf new file mode 100644 index 0000000..75f3cba --- /dev/null +++ b/_sm/base/configs/demo.conf @@ -0,0 +1,14 @@ +# Git 自动同步配置 - demo +REPO_PATH="C:/OtherFiles/Github/git-auto-sync/git-sync-web/_sm/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/_sm/base/logs/demo.log b/_sm/base/logs/demo.log new file mode 100644 index 0000000..70b1006 --- /dev/null +++ b/_sm/base/logs/demo.log @@ -0,0 +1,5 @@ +[2026-09-09 14:59:58] [info] 配置已创建(路径 C:/OtherFiles/Github/git-auto-sync/git-sync-web/_sm/repo) +[2026-09-09 15:00:47] [info] 监控已启动 +[2026-09-09 15:00:48] [info] 已添加远程 origin → https://example.invalid/x.git +[2026-09-09 15:00:48] [info] === 开始同步(启动检查,模式 both)=== +[2026-09-09 15:00:50] [error] fetch 失败: fatal: unable to access 'https://example.invalid/x.git/': CONNECT tunnel failed, response 502 diff --git a/_sm/pid b/_sm/pid new file mode 100644 index 0000000..aa8712d --- /dev/null +++ b/_sm/pid @@ -0,0 +1 @@ +3628 diff --git a/_sm/pid2 b/_sm/pid2 new file mode 100644 index 0000000..4fd6dca --- /dev/null +++ b/_sm/pid2 @@ -0,0 +1 @@ +3728 diff --git a/_sm/repo b/_sm/repo new file mode 160000 index 0000000..f5c7035 --- /dev/null +++ b/_sm/repo @@ -0,0 +1 @@ +Subproject commit f5c70352ee6f38f145e8edb46a2a2a31212e254e diff --git a/_sm/srv.log b/_sm/srv.log new file mode 100644 index 0000000..1b2bdc9 --- /dev/null +++ b/_sm/srv.log @@ -0,0 +1 @@ +2026/09/09 14:58:40 Git Sync Web 已启动: http://127.0.0.1:5851(鉴权:false) diff --git a/engine.go b/engine.go index a095e72..84c54dc 100644 --- a/engine.go +++ b/engine.go @@ -1431,8 +1431,10 @@ func (e *Engine) WorkingDiff(name, scope, rel, mode string) (*DiffResult, error) args = []string{"diff", "--no-color"} } // 词级差异:--word-diff=plain 用 [-x-] / {+x+} 标记行内改动 + // 注意:该模式下 git 输出的上下文行不带前导空格,前端解析需兼容(见 parseUnifiedDiff) if mode == "word" { args = append(args, "--word-diff=plain") + res.Word = true } statArgs := append([]string{}, args...) if scope == "remote" { diff --git a/static/app.js b/static/app.js index cbee64a..7d3b9e0 100644 --- a/static/app.js +++ b/static/app.js @@ -126,6 +126,8 @@ const Editor = { onSave: null, // 由 cm-boot.js 的快捷键触发 onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存) }; +// 顶层 const 不会挂到 window,显式暴露给 cm-boot.js 使用 +window.Editor = Editor; // ---------- 渲染 ---------- function render() { @@ -329,6 +331,7 @@ function openDetail(name) { $('#m-title').textContent = name; $('#f-name').disabled = true; $('#btn-delete').hidden = false; + $('#btn-clone').hidden = false; fillForm(r); showTab('log'); $('#modal').hidden = false; @@ -340,6 +343,7 @@ function openCreate() { $('#m-title').textContent = '新建配置'; $('#f-name').disabled = false; $('#btn-delete').hidden = true; + $('#btn-clone').hidden = true; fillForm(null); showTab('config'); $('#modal').hidden = false; @@ -445,12 +449,86 @@ function loadConflicts(name) { list.innerHTML = '
当前没有冲突。
'; return; } - list.innerHTML = files.map(f => - `${esc(f.diff) || '(无差异内容)'}${esc(f.diff) || '(无差异内容)'}
+ ${esc(err.message)}
`; }); } +// 冲突文件内联编辑:展开编辑区 → 读取原文 → 保存 / 保存并标记已解决 +$('#conflict-list').addEventListener('click', async (e) => { + const btn = e.target.closest('button[data-cf]'); + if (!btn || !current) return; + const item = btn.closest('.conflict-file'); + const path = item.dataset.path; + const act = btn.dataset.cf; + const wrap = item.querySelector('.cf-edit'); + const ta = item.querySelector('.cf-ta'); + const editBtn = item.querySelector('[data-cf="edit"]'); + const closeBtn = item.querySelector('[data-cf="close"]'); + + if (act === 'edit') { + wrap.hidden = false; + editBtn.hidden = true; + closeBtn.hidden = false; + ta.disabled = false; + ta.value = '加载中…'; + try { + const d = await api(`/api/repos/${current}/file?path=${encodeURIComponent(path)}`); + if (d.binary) { + ta.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`; + ta.disabled = true; + } else { + ta.value = d.content || ''; + } + } catch (err) { + ta.value = '读取失败:' + err.message; + ta.disabled = true; + } + return; + } + + if (act === 'close') { + wrap.hidden = true; + editBtn.hidden = false; + closeBtn.hidden = true; + return; + } + + // save / resolve + if (ta.disabled) { toast('该文件当前不可编辑', 'error'); return; } + try { + await api(`/api/repos/${current}/resolve-file`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + path, + content: ta.value, + finish: act === 'resolve', + }) + }); + toast(act === 'resolve' ? '已保存并结束合并/变基' : '已保存到磁盘(尚未标记解决)', 'success'); + loadConflicts(current); + updateConflictActions(); + refresh(); + } catch (err) { toast(err.message, 'error'); } +}); + async function doAction(action, okMsg) { try { await api(`/api/repos/${current}/action`, { @@ -562,6 +640,7 @@ function parseUnifiedDiff(text) { if (line.startsWith('@@')) { const m = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); if (m) { oldNo = parseInt(m[1], 10); newNo = parseInt(m[2], 10); } + inHunk = true; cur.rows.push({ type: 'hunk', text: line }); continue; } @@ -573,9 +652,15 @@ function parseUnifiedDiff(text) { } if (line.startsWith('+')) { cur.rows.push({ type: 'add', text: line.slice(1), no: newNo++ }); continue; } if (line.startsWith('-')) { cur.rows.push({ type: 'del', text: line.slice(1), no: oldNo++ }); continue; } - if (line.startsWith(' ')) { cur.rows.push({ type: 'ctx', text: line.slice(1), no: oldNo++ }); newNo++; continue; } - if (line === '') { cur.rows.push({ type: 'ctx', text: '', no: oldNo++ }); newNo++; continue; } - cur.rows.push({ type: 'meta', text: line }); // \ No newline 等 + if (line.startsWith(' ')) { cur.rows.push({ type: 'ctx', text: line.slice(1), no: oldNo++, nNo: newNo++ }); continue; } + if (line === '') { cur.rows.push({ type: 'ctx', text: '', no: oldNo++, nNo: newNo++ }); continue; } + // 词级模式(--word-diff=plain)下 git 的上下文行不带前导空格, + // 因此 hunk 内未识别的行一律按上下文处理;hunk 外的(如 "\ No newline")按元信息处理。 + if (inHunk && !line.startsWith('\\')) { + cur.rows.push({ type: 'ctx', text: line, no: oldNo++, nNo: newNo++ }); + continue; + } + cur.rows.push({ type: 'meta', text: line }); } return files; } @@ -605,7 +690,12 @@ function buildSplitRows(rows) { while (i < rows.length) { const r = rows[i]; if (r.type === 'hunk' || r.type === 'meta') { out.push({ kind: r.type, text: r.text }); i++; continue; } - if (r.type === 'ctx') { out.push({ kind: 'ctx', left: r, right: r }); i++; continue; } + if (r.type === 'ctx') { + // 左右列行号分别取旧/新行号,故右侧用副本 + out.push({ kind: 'ctx', left: r, right: { text: r.text, no: r.nNo == null ? r.no : r.nNo } }); + i++; + continue; + } const dels = [], adds = []; while (i < rows.length && rows[i].type === 'del') dels.push(rows[i++]); while (i < rows.length && rows[i].type === 'add') adds.push(rows[i++]); @@ -817,6 +907,9 @@ async function saveFile(commit) { } $('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); }); +// 并排/统一纯前端重渲染(同一份 diff 原文);词级需后端重新生成,故重新拉取 +$('#f-diff-view').addEventListener('change', () => { renderDiff($('#diffbox'), diffText || '(无差异)'); }); +$('#f-diff-word').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); }); $('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(current); }); // 差异:复制 / 下载 .patch @@ -993,19 +1086,65 @@ $('#btn-delete').addEventListener('click', async () => { } catch (err) { toast(err.message, 'error'); } }); +// ---------- 配置「克隆」按钮(多仓库同构时省得重填) ---------- +// 复用 readForm 取当前表单值 → 切到新建态:名称自动加 -copy(去重),仓库路径清空 +$('#btn-clone').addEventListener('click', () => { + if (!current) return; + const base = readForm(); + const oldName = base.name || 'repo'; + creating = true; + current = null; + const stem = oldName.replace(/-copy\d*$/i, ''); + let name = stem + '-copy'; + const taken = new Set(repos.map(r => r.name)); + let i = 2; + while (taken.has(name)) name = stem + '-copy' + (i++); + $('#m-title').textContent = `克隆配置:${oldName}`; + $('#f-name').disabled = false; + $('#btn-delete').hidden = true; + $('#btn-clone').hidden = true; + fillForm(base); + $('#f-name').value = name; + $('#f-path').value = ''; // 路径必须换,否则会与源仓库指向同一目录 + showTab('config'); + toast('已载入原配置内容:请修改「配置名称」与「仓库路径」后保存(仓库路径已清空,需指向新目录)', 'info'); + $('#f-name').focus(); +}); + // ---------- 顶部按钮 ---------- $('#btn-new').addEventListener('click', openCreate); $('#btn-empty-new').addEventListener('click', openCreate); $('#btn-sync-all').addEventListener('click', () => { repos.filter(r => r.running).forEach(r => post(`/api/repos/${r.name}/sync`).catch(() => {})); + toast(`已向 ${repos.filter(r => r.running).length} 个运行中的仓库发起同步`); }); +// 批量启停:并发调用,汇总成功/失败 +async function batchToggle(act) { + const isStart = act === 'start'; + const targets = repos.filter(r => isStart ? !r.running : r.running); + if (!targets.length) { + toast(isStart ? '没有处于暂停状态的仓库' : '没有正在运行的仓库'); + return; + } + let ok = 0, fail = 0; + await Promise.all(targets.map(async (r) => { + try { await post(`/api/repos/${r.name}/${act}`); ok++; } + catch (_) { fail++; } + })); + toast(`${isStart ? '启动' : '暂停'}完成:成功 ${ok}${fail ? `,失败 ${fail}` : ''}`, fail ? 'warn' : 'success'); + refresh(); +} +$('#btn-start-all').addEventListener('click', () => batchToggle('start')); +$('#btn-stop-all').addEventListener('click', () => batchToggle('stop')); + // ---------- 暗色模式 ---------- const THEME_KEY = 'gitautosync:theme'; function applyTheme(dark) { document.documentElement.classList.toggle('dark', !!dark); const btn = $('#btn-theme'); if (btn) btn.textContent = dark ? '☀️' : '🌙'; + Editor.setTheme(!!dark); // CodeMirror 主题跟随(未启用 CM 时为空操作) } function initTheme() { applyTheme(localStorage.getItem(THEME_KEY) === 'dark'); diff --git a/static/cm-boot.js b/static/cm-boot.js new file mode 100644 index 0000000..b8024ea --- /dev/null +++ b/static/cm-boot.js @@ -0,0 +1,29 @@ +// CodeMirror 引导:在 codemirror.min.js 及其 mode 加载完成后接管 #file-editor。 +// 若库未加载或初始化失败,window.Editor.cm 保持 null,自动回退原生 textarea,功能不受影响。 +(function () { + if (typeof window.CodeMirror === 'undefined' || typeof window.Editor === 'undefined') return; + var ta = document.getElementById('file-editor'); + if (!ta) return; + try { + var dark = document.documentElement.classList.contains('dark'); + var cm = window.CodeMirror.fromTextArea(ta, { + lineNumbers: true, + lineWrapping: true, + indentUnit: 2, + tabSize: 2, + theme: dark ? 'material-darker' : 'eclipse', + readOnly: ta.disabled ? 'nocursor' : false, + extraKeys: { + 'Ctrl-S': function () { if (window.Editor.onSave) window.Editor.onSave(); }, + 'Cmd-S': function () { if (window.Editor.onSave) window.Editor.onSave(); } + } + }); + window.Editor.cm = cm; + // CodeMirror 的 change 事件桥接到草稿缓存 + cm.on('change', function () { if (window.Editor.onChange) window.Editor.onChange(); }); + // 模式跟随打开文件时的 setMode + var cur = window.Editor.value; + } catch (e) { + if (window.console) console.warn('CodeMirror 初始化失败,已回退原生编辑器:', e); + } +})(); diff --git a/static/style.css b/static/style.css index d2506c2..b0cf122 100644 --- a/static/style.css +++ b/static/style.css @@ -245,3 +245,7 @@ html.dark .file-editor { background: #12151a; } .cf-ta { width: 100%; min-height: 260px; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12.5px/1.55 Consolas, monospace; color: var(--text); background: #fff; white-space: pre; overflow: auto; } html.dark .cf-ta { background: #12151a; } .cf-actions { display: flex; gap: 8px; margin-top: 6px; } +.cf-hint { font-size: 12px; color: var(--muted); margin-top: 6px; line-height: 1.5; } +.cf-edit { margin-bottom: 10px; } +.conflict-file { border: 1px solid var(--border); border-radius: 8px; padding: 10px; margin-bottom: 10px; } +.conflict-file pre { margin: 0; }