diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..64fab42 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +BINARY := git-auto.exe + +# 关闭 VCS 标记:绕过本地 .git 对象库不持久导致的 build 报错 +.PHONY: build run +build: + go build -buildvcs=false -o $(BINARY) . + +run: build + ./$(BINARY) -dir .. -addr 127.0.0.1:5757 diff --git a/README.md b/README.md new file mode 100644 index 0000000..19b36c7 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# Git 自动同步 · 网页控制台(git-sync-web) + +将 `git-auto-sync`(原 Bash 脚本版定时同步工具)的能力搬上网页:用浏览器即可监控、手动触发、查看差异、在线编辑文件、就地解决冲突,无需登录服务器敲命令行。 + +单二进制、纯标准库实现(Go 1.25+),前端资源通过 `go:embed` 内嵌,**零外部依赖、零运行时下载**,拷贝一个 exe 即可运行。 + +--- + +## 核心功能 + +### 1. 仓库总览(Dashboard) +- 每个被监控仓库一张卡片:同步状态灯、领先/落后提交数、本地改动文件数。 +- 运行中的仓库实时显示;关闭「自动同步」的仓库标注「手动模式」徽标,仅响应手动触发。 +- 顶部按钮「全部启动 / 全部暂停」一键管控。 + +### 2. 实时活动(SSE 日志) +- 右侧实时日志面板通过 Server-Sent Events 推送每个仓库的同步过程、提交、冲突等事件,并带错误/警告高亮。 +- 支持按关键字过滤、一键清空。 + +### 3. 手动同步 +- 每个仓库提供「获取 / 拉取 / 推送」按钮,随时手动触发,不必等待定时调度。 + +### 4. 差异查看(Diff) +- 支持「工作区 / 已暂存 / 某次提交」三种范围;统一视图与**并排(split)视图**切换。 +- 可选的**词级差异**(`git --word-diff=plain`),改动以行内增删高亮呈现。 +- 一键**复制**差异内容、下载 `.patch` 文件;编辑器内「对比此文件」可只看当前文件的改动。 + +### 5. 网页端文件编辑 +- 文件树列出仓库内可跟踪 / 未跟踪文件,并标注改动状态(M/A/D/?)。 +- 点击文件即可**在线读取与编辑**,保存可「仅写盘」或「保存并提交」。 +- 内置 **CodeMirror** 语法高亮;编辑过程中自动缓存草稿到浏览器 `localStorage`,刷新不丢稿,可「放弃草稿」回滚。 +- 二进制文件自动识别并只读提示。 + +### 6. 冲突解决 +- **拉取冲突**:选择「放弃合并」或「我已在本地解决,继续」。 +- **推送冲突**(non-fast-forward):提供「强制推送(--force-with-lease)」或「放弃」。 +- **冲突文件内联编辑**:直接在网页展开冲突文件、编辑解决、保存并标记已解决;所有冲突处理完毕时自动结束合并/变基。 + +### 7. 自动同步 / 自动提交 / Cron +- 每个仓库可设固定间隔(秒)或 **Cron 表达式**自动拉取推送(Cron 非空时优先于固定间隔)。 +- 「自动提交」开启后,检测到工作区脏文件会自动 `add -A` + `commit` 再推送。 +- 「自动同步」开关可单独关闭——关闭后该仓库不再自动运行,仅响应手动同步。 + +### 8. 提交历史 +- 浏览仓库提交记录,点击展开单条提交的详细 diff。提交哈希经正则白名单校验,防止命令注入。 + +### 9. 统计 +- 每次同步写入 CSV 统计(领先/落后/改动等),网页端以图表呈现近 30 天趋势。 + +### 10. 访问密码(可选) +- 启动时加 `-password` 参数启用鉴权:生成 30 天会话 cookie,除静态资源外的 API 均受保护。 + +--- + +## 编译与运行 + +```bash +# 编译(会重新内嵌 static 目录下的前端资源,改前端后必须重新 build) +go build -o git-auto.exe . + +# Windows下可能需要执行如下命令编译: +go build -buildvcs=false -o git-auto.exe . + +# 运行 +./git-auto.exe -dir "C:/OtherFiles/Github/git-auto-sync" -addr 127.0.0.1:5757 +``` + +| 参数 | 说明 | 默认值 | +| --- | --- | --- | +| `-dir` | 项目根目录,需包含 `configs/`、`logs/`、`stats/` | `.` | +| `-addr` | 监听地址 | `127.0.0.1:5757` | +| `-password` | 访问密码(为空则不启用鉴权) | 空 | + +启动后浏览器打开 `http://127.0.0.1:5757` 即可。 + +--- + +## 配置文件格式 + +监控目标以 `.conf` 文件存放在 `-dir` 指定的 `configs/` 目录下,兼容原 Bash 脚本的配置字段: + +```ini +# Git 自动同步配置 - 示例 +REPO_PATH="/c/OtherFiles/Github/your-repo" # 支持 Git Bash 风格 /c/... 路径,会自动转为 Windows 路径 +REMOTE_NAME="origin" +REMOTE_URL="https://example.com/you/repo.git" +# 多远程写法:REMOTES="origin=https://a.git,fork=https://b.git" +BRANCH="main" +MODE="both" # both / push / pull +INTERVAL="60" # 固定间隔(秒) +CRON="" # 可选,cron 表达式优先于 INTERVAL +LOG_FILE="/c/.../sync.log" +AUTH_METHOD="ssh" # ssh / https +AUTO_COMMIT="0" # 1 开启自动提交 +AUTO_SYNC="1" # 0 关闭自动同步(仅手动) +CONFLICT_STRATEGY="merge" # merge / rebase +PUSH_STRATEGY="manual" # manual / auto +``` + +> 说明:路径既支持 `C:/...` 也支持 Git Bash 的 `/c/...`,后者会在加载时自动规范化;`.git` 内部目录被禁止通过网页访问。 + +--- + +## 目录结构 + +``` +git-sync-web/ +├── main.go # 入口:-dir / -addr / -password,内嵌 static +├── config.go # .conf 解析与写回、Windows 路径规范化 +├── git.go # git 执行器、领先/落后、冲突检测 +├── engine.go # 每仓库调度循环、SSE 事件总线、文件树/读写 +├── cron.go # 纯标准库 5 段 cron 解析(含单测) +├── server.go # REST API 与静态资源服务 +└── static/ # 前端:index.html / app.js / style.css / cm-boot.js / vendor/cm/ +``` + +--- + +## 设计要点 + +- **单二进制 + 内嵌前端**:部署只需一个可执行文件,不依赖任何外部 CDN 或运行时。 +- **安全边界**:仓库内路径经 `safeRepoPath` 严格校验,禁止 `..` 越界与访问 `.git`;提交哈希白名单防注入;网页编辑单文件 ≤1MB 且拒绝二进制写入。 +- **优雅降级**:CodeMirror 资源缺失或初始化失败时,编辑器自动回退原生 ` -
- - - -
-
「仅保存」只写回磁盘;「保存并标记已解决」会暂存该文件,并在已无其它冲突文件时结束合并/变基。
- -
${esc(f.diff) || '(无差异内容)'}
- `).join(''); - }).catch(err => { $('#conflict-list').innerHTML = `

${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`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action }) - }); - toast(okMsg); - loadConflicts(current); - } catch (err) { toast(err.message, 'error'); } -} -$('#btn-abort').addEventListener('click', () => { - if (confirm('确定放弃本次合并/变基?仓库将回到冲突前状态。')) doAction('abort', '已放弃本次合并'); -}); -$('#btn-resolved').addEventListener('click', () => doAction('resolve', '冲突处理流程完成')); - -// 手动拉取 / 推送 / 获取(B1:后端 ManualGit 已支持) -$('#btn-fetch').addEventListener('click', () => doAction('fetch', '获取完成')); -$('#btn-pull').addEventListener('click', () => doAction('pull', '拉取完成')); -$('#btn-push').addEventListener('click', () => doAction('push', '推送完成')); - -// 推送冲突(non-fast-forward)的解决入口(B3) -$('#btn-force-push').addEventListener('click', async () => { - if (!current) return; - try { - await post(`/api/repos/${current}/force-push`); - toast('已发起强制推送(--force-with-lease)'); - updateConflictActions(); - } catch (err) { toast(err.message, 'error'); } -}); -$('#btn-abort-push').addEventListener('click', async () => { - if (!current) return; - try { - await post(`/api/repos/${current}/abort-push`); - toast('已放弃强制推送'); - updateConflictActions(); - } catch (err) { toast(err.message, 'error'); } -}); - -// 根据当前仓库快照切换推送冲突操作区显隐(B3) -function updateConflictActions() { - const r = repos.find(x => x.name === current); - const box = $('#push-conflict-actions'); - const hint = $('#push-conflict-hint'); - if (!box) return; - if (r && r.push_conflict) { - box.hidden = false; - if (hint) hint.textContent = `远程 ${r.push_conflict_remote || ''} 分支 ${r.push_conflict_branch || ''} 有新提交,推送被拒绝`; - } else { - box.hidden = true; - } -} - -// ---------- 差异显示(网页端) ---------- -// 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 = ''; - 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}` - ).join(''); - } else if (d.stat) { - stat.textContent = d.stat.trim(); - } - }).catch(err => { box.textContent = '差异加载失败:' + err.message; }); -} - -// ---------- 差异渲染:统一 / 并排 / 词级 ---------- -// 把 unified diff 解析成「文件 → 行记录」,统一与并排两种视图共用同一份结构 -function parseUnifiedDiff(text) { - const lines = String(text).split('\n'); - const files = []; - let cur = null; - let oldNo = 0, newNo = 0; - let inHunk = false; // 词级模式(--word-diff=plain)下 git 的上下文行不带前导空格,需据此兜底 - const push = (header) => { cur = { header, rows: [] }; files.push(cur); return cur; }; - for (const line of lines) { - if (line.startsWith('diff --git') || line.startsWith('Index: ')) { push(line); continue; } - if (!cur && line === '') continue; // 开头空行忽略 - if (!cur) push(''); - 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; - } - if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('index ') || - line.startsWith('new file') || line.startsWith('deleted file') || line.startsWith('similarity ') || - line.startsWith('rename ') || line.startsWith('old mode') || line.startsWith('new mode')) { - cur.rows.push({ type: 'meta', text: line }); - continue; - } - 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++, 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; -} - -// 词级:把 [-删除-] / {+新增+} 渲染成高亮片段(textContent 拼接,天然防 XSS) -function fillWord(el, text) { - const re = /\[-([\s\S]*?)-\]|\{\+([\s\S]*?)\+\}/g; - let last = 0, m; - el.textContent = ''; - while ((m = re.exec(text)) !== null) { - if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index))); - const span = document.createElement('span'); - const isDel = m[1] !== undefined; - span.className = isDel ? 'wd-del' : 'wd-add'; - span.textContent = isDel ? m[1] : m[2]; - el.appendChild(span); - last = re.lastIndex; - } - if (last < text.length) el.appendChild(document.createTextNode(text.slice(last))); - if (!el.childNodes.length) el.appendChild(document.createTextNode(text)); -} - -// 并排:把连续的 del/add 段配对成行,多余的一侧留空 -function buildSplitRows(rows) { - const out = []; - let i = 0; - 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: { 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++]); - const n = Math.max(dels.length, adds.length); - for (let k = 0; k < n; k++) { - const l = dels[k] || null, a = adds[k] || null; - out.push({ kind: l && a ? 'chg' : (l ? 'del' : 'add'), left: l, right: a }); - } - } - return out; -} - -function fileHeaderOf(h) { - const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(h); - return m ? m[2] : h; -} - -function sideCell(cls, r, word) { - const c = document.createElement('div'); - c.className = 'scol ' + cls; - const ln = document.createElement('span'); - ln.className = 'ln'; - ln.textContent = r ? (r.no == null ? '' : r.no) : ''; - const t = document.createElement('span'); - t.className = 'stxt'; - if (r) { if (word) fillWord(t, r.text); else t.textContent = r.text; } - c.appendChild(ln); - c.appendChild(t); - return c; -} - -function buildSplit(f, word) { - const frag = document.createDocumentFragment(); - buildSplitRows(f.rows).forEach(r => { - if (r.kind === 'hunk' || r.kind === 'meta') { - const d = document.createElement('div'); - d.className = 'diff-line diff-hunk'; - d.textContent = r.text === '' ? ' ' : r.text; - frag.appendChild(d); - return; - } - const row = document.createElement('div'); - row.className = 'srow s-' + r.kind; - row.appendChild(sideCell('sl', r.left, word)); - row.appendChild(sideCell('sr', r.right, word)); - frag.appendChild(row); - }); - return frag; -} - -function buildUnified(f, word) { - const frag = document.createDocumentFragment(); - f.rows.forEach(r => { - const d = document.createElement('div'); - d.className = 'diff-line'; - if (r.type === 'add') d.classList.add('diff-add'); - else if (r.type === 'del') d.classList.add('diff-del'); - else if (r.type === 'hunk') d.classList.add('diff-hunk'); - const prefix = r.type === 'add' ? '+' : r.type === 'del' ? '-' : r.type === 'ctx' ? ' ' : ''; - const txt = prefix + r.text; - if (word) fillWord(d, txt); else d.textContent = txt === '' ? ' ' : txt; - frag.appendChild(d); - }); - return frag; -} - -function renderDiff(box, text) { - box.innerHTML = ''; - const viewEl = $('#f-diff-view'); - const wordEl = $('#f-diff-word'); - const split = !!viewEl && viewEl.value === 'split'; - const word = !!wordEl && wordEl.checked; - const files = parseUnifiedDiff(text); - const frag = document.createDocumentFragment(); - if (!files.length) { - const d = document.createElement('div'); - d.className = 'diff-line'; - d.textContent = String(text) || '(无差异)'; - frag.appendChild(d); - box.appendChild(frag); - return; - } - files.forEach(f => { - if (f.header) { - const h = document.createElement('div'); - h.className = 'diff-file-head'; - h.textContent = fileHeaderOf(f.header); - h.title = f.header; - frag.appendChild(h); - } - frag.appendChild(split ? buildSplit(f, word) : buildUnified(f, word)); - }); - box.appendChild(frag); -} - -// ---------- 文件编辑(网页端) ---------- -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 => { - 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(() => { - if (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; - 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; - } -} - -async function saveFile(commit) { - const name = current; - const path = fileCurrent; - if (!name || !path) { toast('请先在左侧选择一个文件'); return; } - if (Editor.disabled) { toast('当前文件不可编辑(二进制或读取失败)'); return; } - const content = Editor.value; - try { - // 先写盘 - await api(`/api/repos/${name}/file`, { - method: 'PUT', - 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', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path, message: $('#file-msg').value }) - }); - toast('已保存并提交'); - } else { - // 保存后自动切到差异预览 - $('#f-diff-scope').value = 'working'; - showTab('diff'); - toast('已保存到磁盘(未提交)。可在「差异」tab 查看改动,或稍后由自动同步提交。'); - } - } catch (err) { toast('保存失败:' + err.message); } -} - -$('#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 -$('#btn-diff-copy').addEventListener('click', async () => { - if (!diffText) { toast('当前没有可复制的差异内容'); return; } - try { - await navigator.clipboard.writeText(diffText); - toast('差异内容已复制到剪贴板'); - } catch (err) { - toast('复制失败(浏览器可能限制了剪贴板权限):' + err.message); - } -}); -$('#btn-diff-download').addEventListener('click', () => { - if (!diffText) { toast('当前没有可下载的差异内容'); 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) { toast('请先在左侧选择一个文件'); 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; - loadFile(current, item.dataset.path); -}); -$('#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'); -} - -function loadStats(name) { - api(`/api/repos/${name}/stats`).then(rows => { - const days = []; - for (let i = 29; i >= 0; i--) { - const d = new Date(); - d.setDate(d.getDate() - i); - days.push(dateStr(d)); - } - // 后端写入的 result 词汇是 success/failed/conflict,这里归一化到 ok/error/conflict 再统计 - const normResult = r => r.result === 'success' ? 'ok' : r.result === 'failed' ? 'error' : r.result; - const mk = (key) => days.map(d => rows.filter(r => normResult(r) === key && r.time.slice(0, 10) === d).length); - const total = { ok: 0, error: 0, conflict: 0 }; - rows.forEach(r => { const k = normResult(r); if (total[k] !== undefined) total[k]++; }); - $('#stat-summary').innerHTML = - `近 30 天共 ${rows.length} 次同步记录` + - `成功 ${total.ok}` + - `失败 ${total.error}` + - `冲突 ${total.conflict}`; - if (chart) { chart.destroy(); chart = null; } - if (!window.Chart) return; - chart = new Chart($('#stat-chart'), { - type: 'bar', - data: { - labels: days.map(d => d.slice(5)), - datasets: [ - { label: '成功', data: mk('ok'), backgroundColor: '#97c459' }, - { label: '失败', data: mk('error'), backgroundColor: '#f09595' }, - { label: '冲突', data: mk('conflict'), backgroundColor: '#ef9f27' }, - ] - }, - options: { - responsive: true, - scales: { - x: { stacked: true }, - y: { stacked: true, ticks: { precision: 0 } } - }, - plugins: { legend: { position: 'bottom' } } - } - }); - }).catch(err => { $('#stat-summary').textContent = '统计加载失败:' + err.message; }); -} - -// ---------- 配置表单 ---------- -function fillForm(r) { - $('#f-name').value = r ? r.name : ''; - $('#f-path').value = r ? r.repo_path : ''; - $('#f-branch').value = r ? r.branch : 'main'; - $('#f-mode').value = r ? r.mode : 'both'; - $('#f-remotes').value = r && r.remotes ? r.remotes.map(x => x.name + '=' + x.url).join('\n') : ''; - $('#f-interval').value = r ? r.interval : 60; - $('#f-auth').value = r ? r.auth_method : 'ssh'; - $('#f-conflict').value = r ? r.conflict_strategy : 'merge'; - $('#f-push').value = r ? r.push_strategy : 'manual'; - $('#f-retry').value = r ? r.retry_count : 3; - $('#f-retry-interval').value = r ? r.retry_interval : 5; - $('#f-autocommit').checked = r ? !!r.auto_commit : false; - $('#f-autosync').checked = r ? (r.auto_sync !== false) : true; - $('#f-cron').value = r ? (r.cron || '') : ''; - $('#f-log').value = r ? (r.log_file || '') : ''; - previewCron(); -} - -function readForm() { - const remotes = []; - $('#f-remotes').value.split('\n').forEach(line => { - line = line.trim(); - if (!line) return; - const i = line.indexOf('='); - if (i > 0) remotes.push({ name: line.slice(0, i).trim(), url: line.slice(i + 1).trim() }); - }); - return { - name: $('#f-name').value.trim(), - repo_path: $('#f-path').value.trim(), - branch: $('#f-branch').value.trim() || 'main', - remotes, - mode: $('#f-mode').value, - interval: parseInt($('#f-interval').value, 10) || 60, - auth_method: $('#f-auth').value, - retry_count: parseInt($('#f-retry').value, 10) || 0, - retry_interval: parseInt($('#f-retry-interval').value, 10) || 0, - conflict_strategy: $('#f-conflict').value, - push_strategy: $('#f-push').value, - auto_commit: $('#f-autocommit').checked, - auto_sync: $('#f-autosync').checked, - cron: $('#f-cron').value.trim(), - log_file: $('#f-log').value.trim(), - }; -} - -$('#cfg-form').addEventListener('submit', async (e) => { - e.preventDefault(); - const cfg = readForm(); - try { - if (creating) { - await api('/api/repos', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(cfg) - }); - } else { - await api(`/api/repos/${cfg.name}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(cfg) - }); - } - closeModal(); - refresh(); - } catch (err) { toast('保存失败:' + err.message); } -}); - -$('#btn-delete').addEventListener('click', async () => { - if (!current) return; - if (!confirm(`确定删除配置 "${current}" ?(不影响日志和统计文件)`)) return; - try { - await api(`/api/repos/${current}`, { method: 'DELETE' }); - closeModal(); - refresh(); - } 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'); -} -$('#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 { - const d = await api('/api/status'); - repos = d.repos || []; - render(); - seedLive(); - } catch (e) { /* 忽略,SSE 会重试 */ } -} - -initTheme(); -refresh(); -connect(); -setInterval(refresh, 20000); +const $ = (sel) => document.querySelector(sel); + +const STATUS = { + ok: { text: '正常', cls: 'st-ok' }, + error: { text: '异常', cls: 'st-error' }, + conflict: { text: '冲突', cls: 'st-conflict' }, + running: { text: '同步中', cls: 'st-running' }, + idle: { text: '未启动', cls: 'st-idle' }, +}; +const RESULT_TEXT = { ok: '成功', error: '失败', conflict: '冲突' }; + +const STALE_HOURS = 24; // 超过该小时数且仍在运行 → 标记「久未同步」 +function parseTs(s) { + if (!s) return null; + const t = new Date(String(s).replace(' ', 'T')); + return isNaN(t.getTime()) ? null : t; +} +function relTime(s) { + const t = parseTs(s); + if (!t) return String(s || ''); + const min = Math.floor((Date.now() - t.getTime()) / 60000); + if (min < 1) return '刚刚'; + if (min < 60) return min + ' 分钟前'; + const hr = Math.floor(min / 60); + if (hr < 24) return hr + ' 小时前'; + const day = Math.floor(hr / 24); + if (day < 30) return day + ' 天前'; + return String(s).slice(0, 10); +} +function isStale(s) { + const t = parseTs(s); + return !!t && (Date.now() - t.getTime()) > STALE_HOURS * 3600 * 1000; +} + +let repos = []; +let current = null; +let creating = false; +let chart = null; + +function esc(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +async function api(url, opts) { + const res = await fetch(url, opts); + if (res.status === 401) { + showLogin(); + throw new Error('需要登录'); + } + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || res.statusText); + return data; +} +const post = (url) => api(url, { method: 'POST' }); + +function toast(msg, type) { + const box = $('#toasts'); + if (!box) { console.warn('toast 容器缺失:', msg); return; } + const el = document.createElement('div'); + el.className = 'toast t-' + (type || 'info'); + el.textContent = msg; + el.title = '点击关闭'; + el.addEventListener('click', () => el.remove()); + box.appendChild(el); + while (box.childElementCount > 5) box.removeChild(box.firstChild); // 最多堆叠 5 条 + const life = type === 'error' ? 6000 : 3200; + setTimeout(() => { + el.classList.add('out'); + setTimeout(() => el.remove(), 320); + }, life); +} + +const Editor = { + cm: null, + get el() { return $('#file-editor'); }, + get value() { + if (this.cm) return this.cm.getValue(); + return this.el ? this.el.value : ''; + }, + set value(v) { + const s = v == null ? '' : String(v); + if (this.cm) this.cm.setValue(s); + else if (this.el) this.el.value = s; + }, + get disabled() { + if (this.cm) return this.cm.getOption('readOnly') === 'nocursor'; + return !!(this.el && this.el.disabled); + }, + set disabled(b) { + if (this.cm) this.cm.setOption('readOnly', b ? 'nocursor' : false); + if (this.el) this.el.disabled = !!b; + }, + setMode(path) { + if (!this.cm) return; + const ext = String(path || '').split('.').pop().toLowerCase(); + const map = { + js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', + json: 'application/json', ts: 'text/typescript', tsx: 'text/typescript', + html: 'htmlmixed', htm: 'htmlmixed', vue: 'htmlmixed', xml: 'xml', svg: 'xml', + css: 'css', scss: 'css', less: 'css', + go: 'go', py: 'python', sh: 'shell', bash: 'shell', zsh: 'shell', + c: 'clike', h: 'clike', cpp: 'clike', cc: 'clike', hpp: 'clike', + java: 'clike', kt: 'clike', cs: 'clike', scala: 'clike', + yml: 'yaml', yaml: 'yaml', sql: 'sql', + md: 'markdown', markdown: 'markdown', + ini: 'properties', properties: 'properties', env: 'properties', + cfg: 'properties', conf: 'properties', toml: 'properties', + diff: 'diff', patch: 'diff', + }; + this.cm.setOption('mode', map[ext] || 'text/plain'); + }, + setTheme(dark) { + if (this.cm) this.cm.setOption('theme', dark ? 'material-darker' : 'eclipse'); + }, + refresh() { if (this.cm) setTimeout(() => this.cm.refresh(), 30); }, + onSave: null, // 由 cm-boot.js 的快捷键触发 + onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存) +}; +window.Editor = Editor; + +function render() { + const grid = $('#grid'); + grid.innerHTML = ''; + repos.forEach(r => grid.appendChild(card(r))); + $('#empty').hidden = repos.length > 0; +} + +function card(r) { + const st = STATUS[r.status] || STATUS.idle; + const el = document.createElement('div'); + el.className = 'card ' + st.cls; + el.dataset.name = r.name; + const last = r.last_sync + ? `${relTime(r.last_sync.time)} · ${RESULT_TEXT[r.last_sync.result] || r.last_sync.result}` + : '从未同步'; + el.innerHTML = ` +
+ +

${esc(r.name)}

+ ${esc(r.mode)} +
+
${esc(r.repo_path)}
+
+ ${esc(r.branch)} + 领先 ↑${r.ahead} + 落后 ↓${r.behind} + ${r.dirty ? `改动 ${r.dirty}` : ''} + ${r.auto_commit ? '自动提交' : ''} + ${r.cron ? `cron` : ''} + ${r.conflicts && r.conflicts.length ? `冲突 ${r.conflicts.length}` : ''} + ${r.push_conflict ? `推送冲突` : ''} + ${r.running && r.auto_sync === false ? `手动模式` : ''} + ${r.running && r.last_sync && isStale(r.last_sync.time) ? '久未同步' : ''} +
+
上次同步:${esc(last)}
+ ${r.last_sync && r.last_sync.detail ? `
${esc(r.last_sync.detail)}
` : ''} + ${r.status === 'error' && r.last_error ? `
${esc(r.last_error)}
` : ''} +
+ + + +
`; + return el; +} + +$('#grid').addEventListener('click', async (e) => { + const btn = e.target.closest('button[data-act]'); + if (!btn) return; + const name = btn.closest('.card').dataset.name; + const act = btn.dataset.act; + try { + if (act === 'sync') { + await post(`/api/repos/${name}/sync`); + } else if (act === 'toggle') { + const r = repos.find(x => x.name === name); + await post(`/api/repos/${name}/${r.running ? 'stop' : 'start'}`); + } else if (act === 'detail') { + openDetail(name); + } + } catch (err) { toast(err.message, 'error'); } +}); + +function connect() { + const es = new EventSource('/api/events'); + es.onopen = () => setConn(true); + es.onerror = () => setConn(false); // EventSource 会自动重连 + es.onmessage = (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch { return; } + if (msg.type === 'snapshot') { + repos = msg.data || []; + render(); + } else if (msg.type === 'status') { + const i = repos.findIndex(x => x.name === msg.repo); + if (i >= 0) repos[i] = msg.data; else repos.push(msg.data); + render(); + updateConflictActions(); + if (current === msg.repo && !$('#tab-diff').hidden) loadDiff(msg.repo); + } else if (msg.type === 'log') { + appendLiveLine(msg); + if (current === msg.repo && !$('#tab-log').hidden) appendLogLine(`[${msg.time}] [${msg.level || 'info'}] ${msg.msg}`); + } + }; +} + +function setConn(ok) { + const el = $('#conn'); + el.textContent = ok ? '● 已连接' : '● 未连接'; + el.className = 'conn ' + (ok ? 'on' : 'off'); + const live = $('#live-conn'); + if (live) { + live.textContent = ok ? '● 已连接' : '● 未连接'; + live.className = 'conn ' + (ok ? 'on' : 'off'); + } +} + +const LIVE_MAX = 400; +function appendLiveLine(msg) { + const box = $('#live-log'); + if (!box) return; + let level = 'info'; + if (msg.level === 'error') level = 'err'; + else if (msg.level === 'warn') level = 'warn'; + const line = document.createElement('div'); + line.className = 'live-line lv-' + level; + line.innerHTML = `${esc(msg.time)}${esc(msg.repo)}${esc(msg.msg)}`; + box.appendChild(line); + while (box.childElementCount > LIVE_MAX) box.removeChild(box.firstChild); + box.scrollTop = box.scrollHeight; +} + +function clearLive() { + const box = $('#live-log'); + if (box) box.innerHTML = ''; +} + +let liveSeeded = false; +async function seedLive() { + if (liveSeeded) return; + liveSeeded = true; + const names = repos.map(r => r.name); + await Promise.all(names.map(async (name) => { + try { + const d = await api(`/api/repos/${name}/log?lines=8`); + if (d.lines && d.lines.length) { + d.lines.forEach(t => { + const m = t.match(/^\[(.+?)\]\s*(?:\[(info|warn|error)\]\s*)?(.*)$/i); + appendLiveLine({ + time: m ? m[1] : '', + repo: name, + msg: m ? m[3] : t, + level: m && m[2] ? m[2].toLowerCase() : 'info', + seed: true, + }); + }); + } + } catch (_) { /* 忽略 */ } + })); +} + +$('#btn-live-clear').addEventListener('click', clearLive); + +function showLogin() { + const el = $('#login'); + if (el.hidden) { + el.hidden = false; + setTimeout(() => $('#login-password').focus(), 50); + } +} + +async function doLogin() { + const errEl = $('#login-err'); + errEl.hidden = true; + try { + await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: $('#login-password').value }) + }).then(async res => { + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d.error || '登录失败'); + } + }); + location.reload(); + } catch (err) { + errEl.textContent = err.message; + errEl.hidden = false; + } +} +$('#btn-login').addEventListener('click', doLogin); +$('#login-password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); + +function showTab(t) { + document.querySelectorAll('#m-tabs .tab').forEach(b => b.classList.toggle('active', b.dataset.tab === t)); + document.querySelectorAll('.tab-pane').forEach(p => { p.hidden = p.id !== 'tab-' + t; }); + if (t === 'log' && current) loadLog(current); + if (t === 'history' && current) loadHistory(current); + if (t === 'diff' && current) loadDiff(current); + if (t === 'file' && current) { loadTree(current); Editor.refresh(); } + if (t === 'conflict' && current) { loadConflicts(current); updateConflictActions(); } + if (t === 'stats' && current) loadStats(current); +} + +$('#m-tabs').addEventListener('click', (e) => { + const b = e.target.closest('.tab'); + if (b) showTab(b.dataset.tab); +}); + +function openDetail(name) { + creating = false; + current = name; + const r = repos.find(x => x.name === name); + $('#m-title').textContent = name; + $('#f-name').disabled = true; + $('#btn-delete').hidden = false; + $('#btn-clone').hidden = false; + fillForm(r); + showTab('log'); + $('#modal').hidden = false; +} + +function openCreate() { + creating = true; + current = null; + $('#m-title').textContent = '新建配置'; + $('#f-name').disabled = false; + $('#btn-delete').hidden = true; + $('#btn-clone').hidden = true; + fillForm(null); + showTab('config'); + $('#modal').hidden = false; +} + +function closeModal() { + $('#modal').hidden = true; + current = null; +} + +$('#m-close').addEventListener('click', closeModal); +$('#modal').addEventListener('click', (e) => { if (e.target === $('#modal')) closeModal(); }); +document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); }); + +let logLines = []; +let logLevel = 'all'; +let logQuery = ''; + +function logLevelOf(line) { + const m = /^\[[^\]]+\]\s*\[(info|warn|error)\]/i.exec(line); + return m ? m[1].toLowerCase() : 'info'; +} +function renderLog() { + const box = $('#logbox'); + const q = logQuery.trim().toLowerCase(); + const out = logLines.filter(l => + (logLevel === 'all' || logLevelOf(l) === logLevel) && + (!q || l.toLowerCase().includes(q)) + ); + if (!logLines.length) box.textContent = '(暂无日志)'; + else if (!out.length) box.textContent = '(当前过滤条件下无匹配行)'; + else box.textContent = out.join('\n'); + box.scrollTop = box.scrollHeight; +} +function loadLog(name) { + $('#logbox').textContent = '加载中…'; + api(`/api/repos/${name}/log?lines=500`).then(d => { + logLines = d.lines && d.lines.length ? d.lines : []; + renderLog(); + }).catch(err => { logLines = []; $('#logbox').textContent = '日志加载失败:' + err.message; }); +} + +function appendLogLine(line) { + logLines.push(line); + if (logLines.length > 2000) logLines.shift(); + renderLog(); +} + +$('#log-level').addEventListener('change', (e) => { logLevel = e.target.value; renderLog(); }); +$('#log-search').addEventListener('input', (e) => { logQuery = e.target.value; renderLog(); }); +$('#btn-log-clear').addEventListener('click', () => { + logLevel = 'all'; + logQuery = ''; + $('#log-level').value = 'all'; + $('#log-search').value = ''; + renderLog(); +}); + +function loadHistory(name) { + const list = $('#commit-list'); + list.textContent = '加载中…'; + api(`/api/repos/${name}/commits?limit=50`).then(commits => { + if (!commits.length) { + list.textContent = '暂无提交记录。'; + return; + } + list.innerHTML = commits.map(c => ` +
+
+ ${esc(c.hash.slice(0, 8))} + ${esc(c.subject)} + ${esc(c.author)} · ${esc(c.date)} +
+ +
`).join(''); + }).catch(err => { list.textContent = '提交历史加载失败:' + err.message; }); +} + +$('#commit-list').addEventListener('click', async (e) => { + const head = e.target.closest('.commit-head'); + if (!head || !current) return; + const item = head.closest('.commit'); + const pre = item.querySelector('.commit-diff'); + if (!pre.hidden) { pre.hidden = true; return; } + pre.hidden = false; + pre.textContent = '加载中…'; + try { + const d = await api(`/api/repos/${current}/commits/${item.dataset.hash}`); + pre.textContent = d.diff || '(无差异内容)'; + } catch (err) { + pre.textContent = '加载失败:' + err.message; + } +}); + +function loadConflicts(name) { + api(`/api/repos/${name}/conflicts`).then(files => { + const list = $('#conflict-list'); + if (!files.length) { + list.innerHTML = '

当前没有冲突。

'; + return; + } + list.innerHTML = files.map(f => ` +
+
+

${esc(f.file)}

+ + +
+ +
${esc(f.diff) || '(无差异内容)'}
+
`).join(''); + }).catch(err => { $('#conflict-list').innerHTML = `

${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; + } + + 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`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }) + }); + toast(okMsg); + loadConflicts(current); + } catch (err) { toast(err.message, 'error'); } +} +$('#btn-abort').addEventListener('click', () => { + if (confirm('确定放弃本次合并/变基?仓库将回到冲突前状态。')) doAction('abort', '已放弃本次合并'); +}); +$('#btn-resolved').addEventListener('click', () => doAction('resolve', '冲突处理流程完成')); + +$('#btn-fetch').addEventListener('click', () => doAction('fetch', '获取完成')); +$('#btn-pull').addEventListener('click', () => doAction('pull', '拉取完成')); +$('#btn-push').addEventListener('click', () => doAction('push', '推送完成')); + +$('#btn-force-push').addEventListener('click', async () => { + if (!current) return; + try { + await post(`/api/repos/${current}/force-push`); + toast('已发起强制推送(--force-with-lease)'); + updateConflictActions(); + } catch (err) { toast(err.message, 'error'); } +}); +$('#btn-abort-push').addEventListener('click', async () => { + if (!current) return; + try { + await post(`/api/repos/${current}/abort-push`); + toast('已放弃强制推送'); + updateConflictActions(); + } catch (err) { toast(err.message, 'error'); } +}); + +function updateConflictActions() { + const r = repos.find(x => x.name === current); + const box = $('#push-conflict-actions'); + const hint = $('#push-conflict-hint'); + if (!box) return; + if (r && r.push_conflict) { + box.hidden = false; + if (hint) hint.textContent = `远程 ${r.push_conflict_remote || ''} 分支 ${r.push_conflict_branch || ''} 有新提交,推送被拒绝`; + } else { + box.hidden = true; + } +} + +let diffPath = ''; +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 = ''; + 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}` + ).join(''); + } else if (d.stat) { + stat.textContent = d.stat.trim(); + } + }).catch(err => { box.textContent = '差异加载失败:' + err.message; }); +} + +function parseUnifiedDiff(text) { + const lines = String(text).split('\n'); + const files = []; + let cur = null; + let oldNo = 0, newNo = 0; + let inHunk = false; // 词级模式(--word-diff=plain)下 git 的上下文行不带前导空格,需据此兜底 + const push = (header) => { cur = { header, rows: [] }; files.push(cur); return cur; }; + for (const line of lines) { + if (line.startsWith('diff --git') || line.startsWith('Index: ')) { push(line); continue; } + if (!cur && line === '') continue; // 开头空行忽略 + if (!cur) push(''); + 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; + } + if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('index ') || + line.startsWith('new file') || line.startsWith('deleted file') || line.startsWith('similarity ') || + line.startsWith('rename ') || line.startsWith('old mode') || line.startsWith('new mode')) { + cur.rows.push({ type: 'meta', text: line }); + continue; + } + 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++, nNo: newNo++ }); continue; } + if (line === '') { cur.rows.push({ type: 'ctx', text: '', no: oldNo++, nNo: newNo++ }); continue; } + // 词级模式(--word-diff=plain)下 git 的上下文行不带前导空格, + 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; +} + +function fillWord(el, text) { + const re = /\[-([\s\S]*?)-\]|\{\+([\s\S]*?)\+\}/g; + let last = 0, m; + el.textContent = ''; + while ((m = re.exec(text)) !== null) { + if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index))); + const span = document.createElement('span'); + const isDel = m[1] !== undefined; + span.className = isDel ? 'wd-del' : 'wd-add'; + span.textContent = isDel ? m[1] : m[2]; + el.appendChild(span); + last = re.lastIndex; + } + if (last < text.length) el.appendChild(document.createTextNode(text.slice(last))); + if (!el.childNodes.length) el.appendChild(document.createTextNode(text)); +} + +function buildSplitRows(rows) { + const out = []; + let i = 0; + 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: { 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++]); + const n = Math.max(dels.length, adds.length); + for (let k = 0; k < n; k++) { + const l = dels[k] || null, a = adds[k] || null; + out.push({ kind: l && a ? 'chg' : (l ? 'del' : 'add'), left: l, right: a }); + } + } + return out; +} + +function fileHeaderOf(h) { + const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(h); + return m ? m[2] : h; +} + +function sideCell(cls, r, word) { + const c = document.createElement('div'); + c.className = 'scol ' + cls; + const ln = document.createElement('span'); + ln.className = 'ln'; + ln.textContent = r ? (r.no == null ? '' : r.no) : ''; + const t = document.createElement('span'); + t.className = 'stxt'; + if (r) { if (word) fillWord(t, r.text); else t.textContent = r.text; } + c.appendChild(ln); + c.appendChild(t); + return c; +} + +function buildSplit(f, word) { + const frag = document.createDocumentFragment(); + buildSplitRows(f.rows).forEach(r => { + if (r.kind === 'hunk' || r.kind === 'meta') { + const d = document.createElement('div'); + d.className = 'diff-line diff-hunk'; + d.textContent = r.text === '' ? ' ' : r.text; + frag.appendChild(d); + return; + } + const row = document.createElement('div'); + row.className = 'srow s-' + r.kind; + row.appendChild(sideCell('sl', r.left, word)); + row.appendChild(sideCell('sr', r.right, word)); + frag.appendChild(row); + }); + return frag; +} + +function buildUnified(f, word) { + const frag = document.createDocumentFragment(); + f.rows.forEach(r => { + const d = document.createElement('div'); + d.className = 'diff-line'; + if (r.type === 'add') d.classList.add('diff-add'); + else if (r.type === 'del') d.classList.add('diff-del'); + else if (r.type === 'hunk') d.classList.add('diff-hunk'); + const prefix = r.type === 'add' ? '+' : r.type === 'del' ? '-' : r.type === 'ctx' ? ' ' : ''; + const txt = prefix + r.text; + if (word) fillWord(d, txt); else d.textContent = txt === '' ? ' ' : txt; + frag.appendChild(d); + }); + return frag; +} + +function renderDiff(box, text) { + box.innerHTML = ''; + const viewEl = $('#f-diff-view'); + const wordEl = $('#f-diff-word'); + const split = !!viewEl && viewEl.value === 'split'; + const word = !!wordEl && wordEl.checked; + const files = parseUnifiedDiff(text); + const frag = document.createDocumentFragment(); + if (!files.length) { + const d = document.createElement('div'); + d.className = 'diff-line'; + d.textContent = String(text) || '(无差异)'; + frag.appendChild(d); + box.appendChild(frag); + return; + } + files.forEach(f => { + if (f.header) { + const h = document.createElement('div'); + h.className = 'diff-file-head'; + h.textContent = fileHeaderOf(f.header); + h.title = f.header; + frag.appendChild(h); + } + frag.appendChild(split ? buildSplit(f, word) : buildUnified(f, word)); + }); + box.appendChild(frag); +} + +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 => { + 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; }); +} + +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)); + Editor.value = draftServer; + showDraftNote(false); + }); +} + +function scheduleDraft() { + if (!current || !fileCurrent) return; + if (draftTimer) clearTimeout(draftTimer); + draftTimer = setTimeout(() => { + if (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) { + Editor._suspend = true; + try { + Editor.value = '加载中…'; + Editor.disabled = false; + Editor.setMode(path); + $('#file-current').textContent = path; + fileCurrent = path; + showDraftNote(false); + const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`); + if (d.binary) { + Editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`; + Editor.disabled = true; + 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; + } finally { + Editor._suspend = false; + } +} + +async function saveFile(commit) { + const name = current; + const path = fileCurrent; + if (!name || !path) { toast('请先在左侧选择一个文件'); return; } + if (Editor.disabled) { toast('当前文件不可编辑(二进制或读取失败)'); return; } + const content = Editor.value; + try { + await api(`/api/repos/${name}/file`, { + method: 'PUT', + 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', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, message: $('#file-msg').value }) + }); + toast('已保存并提交'); + } else { + $('#f-diff-scope').value = 'working'; + showTab('diff'); + toast('已保存到磁盘(未提交)。可在「差异」tab 查看改动,或稍后由自动同步提交。'); + } + } catch (err) { toast('保存失败:' + err.message); } +} + +$('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); }); +$('#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); }); + +$('#btn-diff-copy').addEventListener('click', async () => { + if (!diffText) { toast('当前没有可复制的差异内容'); return; } + try { + await navigator.clipboard.writeText(diffText); + toast('差异内容已复制到剪贴板'); + } catch (err) { + toast('复制失败(浏览器可能限制了剪贴板权限):' + err.message); + } +}); +$('#btn-diff-download').addEventListener('click', () => { + if (!diffText) { toast('当前没有可下载的差异内容'); 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); }); + +$('#btn-file-diff').addEventListener('click', () => { + if (!current || !fileCurrent) { toast('请先在左侧选择一个文件'); 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; + loadFile(current, item.dataset.path); +}); +$('#btn-file-save').addEventListener('click', () => saveFile(false)); +$('#btn-file-commit').addEventListener('click', () => saveFile(true)); + +$('#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); + } +}); +Editor.onChange = scheduleDraft; +Editor.onSave = () => saveFile(false); + +function dateStr(d) { + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); +} + +function loadStats(name) { + api(`/api/repos/${name}/stats`).then(rows => { + const days = []; + for (let i = 29; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + days.push(dateStr(d)); + } + const normResult = r => r.result === 'success' ? 'ok' : r.result === 'failed' ? 'error' : r.result; + const mk = (key) => days.map(d => rows.filter(r => normResult(r) === key && r.time.slice(0, 10) === d).length); + const total = { ok: 0, error: 0, conflict: 0 }; + rows.forEach(r => { const k = normResult(r); if (total[k] !== undefined) total[k]++; }); + $('#stat-summary').innerHTML = + `近 30 天共 ${rows.length} 次同步记录` + + `成功 ${total.ok}` + + `失败 ${total.error}` + + `冲突 ${total.conflict}`; + if (chart) { chart.destroy(); chart = null; } + if (!window.Chart) return; + chart = new Chart($('#stat-chart'), { + type: 'bar', + data: { + labels: days.map(d => d.slice(5)), + datasets: [ + { label: '成功', data: mk('ok'), backgroundColor: '#97c459' }, + { label: '失败', data: mk('error'), backgroundColor: '#f09595' }, + { label: '冲突', data: mk('conflict'), backgroundColor: '#ef9f27' }, + ] + }, + options: { + responsive: true, + scales: { + x: { stacked: true }, + y: { stacked: true, ticks: { precision: 0 } } + }, + plugins: { legend: { position: 'bottom' } } + } + }); + }).catch(err => { $('#stat-summary').textContent = '统计加载失败:' + err.message; }); +} + +function fillForm(r) { + $('#f-name').value = r ? r.name : ''; + $('#f-path').value = r ? r.repo_path : ''; + $('#f-branch').value = r ? r.branch : 'main'; + $('#f-mode').value = r ? r.mode : 'both'; + $('#f-remotes').value = r && r.remotes ? r.remotes.map(x => x.name + '=' + x.url).join('\n') : ''; + $('#f-interval').value = r ? r.interval : 60; + $('#f-auth').value = r ? r.auth_method : 'ssh'; + $('#f-conflict').value = r ? r.conflict_strategy : 'merge'; + $('#f-push').value = r ? r.push_strategy : 'manual'; + $('#f-retry').value = r ? r.retry_count : 3; + $('#f-retry-interval').value = r ? r.retry_interval : 5; + $('#f-autocommit').checked = r ? !!r.auto_commit : false; + $('#f-autosync').checked = r ? (r.auto_sync !== false) : true; + $('#f-cron').value = r ? (r.cron || '') : ''; + $('#f-log').value = r ? (r.log_file || '') : ''; + previewCron(); +} + +function readForm() { + const remotes = []; + $('#f-remotes').value.split('\n').forEach(line => { + line = line.trim(); + if (!line) return; + const i = line.indexOf('='); + if (i > 0) remotes.push({ name: line.slice(0, i).trim(), url: line.slice(i + 1).trim() }); + }); + return { + name: $('#f-name').value.trim(), + repo_path: $('#f-path').value.trim(), + branch: $('#f-branch').value.trim() || 'main', + remotes, + mode: $('#f-mode').value, + interval: parseInt($('#f-interval').value, 10) || 60, + auth_method: $('#f-auth').value, + retry_count: parseInt($('#f-retry').value, 10) || 0, + retry_interval: parseInt($('#f-retry-interval').value, 10) || 0, + conflict_strategy: $('#f-conflict').value, + push_strategy: $('#f-push').value, + auto_commit: $('#f-autocommit').checked, + auto_sync: $('#f-autosync').checked, + cron: $('#f-cron').value.trim(), + log_file: $('#f-log').value.trim(), + }; +} + +$('#cfg-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const cfg = readForm(); + try { + if (creating) { + await api('/api/repos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cfg) + }); + } else { + await api(`/api/repos/${cfg.name}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cfg) + }); + } + closeModal(); + refresh(); + } catch (err) { toast('保存失败:' + err.message); } +}); + +$('#btn-delete').addEventListener('click', async () => { + if (!current) return; + if (!confirm(`确定删除配置 "${current}" ?(不影响日志和统计文件)`)) return; + try { + await api(`/api/repos/${current}`, { method: 'DELETE' }); + closeModal(); + refresh(); + } catch (err) { toast(err.message, 'error'); } +}); + +$('#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'); +} +$('#btn-theme').addEventListener('click', () => { + const dark = !document.documentElement.classList.contains('dark'); + localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light'); + applyTheme(dark); +}); + +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 { + const d = await api('/api/status'); + repos = d.repos || []; + render(); + seedLive(); + } catch (e) { /* 忽略,SSE 会重试 */ } +} + +initTheme(); +refresh(); +connect(); +setInterval(refresh, 20000); diff --git a/static/cm-boot.js b/static/cm-boot.js index b8024ea..8b75155 100644 --- a/static/cm-boot.js +++ b/static/cm-boot.js @@ -1,29 +1,25 @@ -// 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); - } -})(); +(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; + cm.on('change', function () { if (window.Editor._suspend) return; if (window.Editor.onChange) window.Editor.onChange(); }); + var cur = window.Editor.value; + } catch (e) { + if (window.console) console.warn('CodeMirror 初始化失败,已回退原生编辑器:', e); + } +})();