还没有任何仓库配置。
- +还没有任何仓库配置。
+diff --git a/engine.go b/engine.go index 0b03e31..a095e72 100644 --- a/engine.go +++ b/engine.go @@ -1361,6 +1361,31 @@ func (e *Engine) CommitFile(name, rel, message string) error { return nil } +// ResolveConflictFile 网页端解决冲突:写入内容 → 暂存 → (可选)结束合并/变基 +// finish=true 且已无其它冲突文件时才真正结束,避免把带冲突标记的内容提交进去 +func (e *Engine) ResolveConflictFile(name, rel, content string, finish bool) error { + s := e.get(name) + if s == nil { + return fmt.Errorf("配置不存在: %s", name) + } + if err := e.WriteFileContent(name, rel, content); err != nil { + return err + } + if _, err := gitRun(s.cfg.RepoPath, 30*time.Second, "add", "--", rel); err != nil { + return fmt.Errorf("git add 失败: %w", err) + } + e.logf(s, "info", "网页端冲突文件 %s 已暂存", rel) + if !finish { + return nil + } + rest, _ := conflictFiles(s.cfg.RepoPath) + if len(rest) > 0 { + e.pushStatus(s) + return fmt.Errorf("已暂存 %s;但仍有未解决的冲突文件:%s", rel, strings.Join(rest, ", ")) + } + return e.ConflictResolve(name) +} + // DiffFile 单文件增删统计 type DiffFile struct { Path string `json:"path"` @@ -1375,10 +1400,13 @@ type DiffResult struct { Text string `json:"text"` Files []DiffFile `json:"files"` VsRemote bool `json:"vs_remote"` + Word bool `json:"word"` } -// WorkingDiff 返回工作区/暂存/全部/与远程的差异;rel 非空时只对比该文件 -func (e *Engine) WorkingDiff(name, scope, rel string) (*DiffResult, error) { +// WorkingDiff 返回工作区/暂存/全部/与远程的差异 +// rel 非空时只对比单个文件(已做路径安全校验) +// mode = "word" 时改用 --word-diff=plain,便于词级定位改动 +func (e *Engine) WorkingDiff(name, scope, rel, mode string) (*DiffResult, error) { s := e.get(name) if s == nil { return nil, fmt.Errorf("配置不存在: %s", name) @@ -1402,6 +1430,10 @@ func (e *Engine) WorkingDiff(name, scope, rel string) (*DiffResult, error) { default: // working(未暂存) args = []string{"diff", "--no-color"} } + // 词级差异:--word-diff=plain 用 [-x-] / {+x+} 标记行内改动 + if mode == "word" { + args = append(args, "--word-diff=plain") + } statArgs := append([]string{}, args...) if scope == "remote" { statArgs = []string{"diff", "--no-color", "--stat", remote + "/" + s.cfg.Branch, "HEAD"} diff --git a/server.go b/server.go index 32b51a9..865907d 100644 --- a/server.go +++ b/server.go @@ -53,6 +53,7 @@ func NewServer(e *Engine, static fs.FS, password string) *Server { mux.HandleFunc("GET /api/repos/{name}/tree", s.wrapRepo(s.treeRepo)) mux.HandleFunc("GET /api/repos/{name}/file", s.wrapRepo(s.fileRepo)) mux.HandleFunc("PUT /api/repos/{name}/file", s.wrapRepo(s.fileWriteRepo)) + mux.HandleFunc("POST /api/repos/{name}/resolve-file", s.wrapRepo(s.resolveFileRepo)) mux.HandleFunc("POST /api/repos/{name}/commit", s.wrapRepo(s.commitFileRepo)) mux.HandleFunc("GET /api/repos/{name}/diff", s.wrapRepo(s.diffRepo)) mux.HandleFunc("GET /api/cron-next", s.handleCronNext) @@ -377,6 +378,28 @@ func (s *Server) fileWriteRepo(w http.ResponseWriter, r *http.Request, name stri writeJSON(w, 200, map[string]any{"ok": true}) } +// resolveFileRepo 网页端解决单个冲突文件:写入 + 暂存,finish=true 时尝试结束合并/变基 +func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) { + var req struct { + Path string `json:"path"` + Content string `json:"content"` + Finish bool `json:"finish"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, 400, "请求体格式错误") + return + } + if req.Path == "" { + writeErr(w, 400, "缺少 path") + return + } + if err := s.engine.ResolveConflictFile(name, req.Path, req.Content, req.Finish); err != nil { + writeErr(w, 400, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"ok": true}) +} + func (s *Server) commitFileRepo(w http.ResponseWriter, r *http.Request, name string) { var req struct { Path string `json:"path"` @@ -403,7 +426,8 @@ func (s *Server) diffRepo(w http.ResponseWriter, r *http.Request, name string) { scope = "working" } // path 可选:传入时只对比该文件(用于编辑器「对比此文件」) - res, err := s.engine.WorkingDiff(name, scope, r.URL.Query().Get("path")) + // mode = word 时返回 --word-diff=plain 结果(词级差异) + res, err := s.engine.WorkingDiff(name, scope, r.URL.Query().Get("path"), r.URL.Query().Get("mode")) if err != nil { writeErr(w, 400, err.Error()) return diff --git a/static/app.js b/static/app.js index fc9e534..cbee64a 100644 --- a/static/app.js +++ b/static/app.js @@ -56,6 +56,77 @@ async function api(url, opts) { } const post = (url) => api(url, { method: 'POST' }); +// ---------- Toast 轻提示(替代原生 alert,避免打断操作) ---------- +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); +} + +// ---------- 编辑器抽象:统一原生 textarea 与 CodeMirror 的读写 ---------- +// CodeMirror 由 cm-boot.js 在脚本加载后接管并写入 Editor.cm; +// 库缺失 / 初始化失败时 Editor.cm 保持 null,自动回退原生 textarea,功能不受影响。 +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'); + }, + // 弹窗 / tab 切换后需要重算尺寸,否则 CodeMirror 可能不显示内容 + refresh() { if (this.cm) setTimeout(() => this.cm.refresh(), 30); }, + onSave: null, // 由 cm-boot.js 的快捷键触发 + onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存) +}; + // ---------- 渲染 ---------- function render() { const grid = $('#grid'); @@ -115,7 +186,7 @@ $('#grid').addEventListener('click', async (e) => { } else if (act === 'detail') { openDetail(name); } - } catch (err) { alert(err.message); } + } catch (err) { toast(err.message, 'error'); } }); // ---------- SSE 实时事件 ---------- @@ -241,7 +312,7 @@ function showTab(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); + if (t === 'file' && current) { loadTree(current); Editor.refresh(); } if (t === 'conflict' && current) { loadConflicts(current); updateConflictActions(); } if (t === 'stats' && current) loadStats(current); } @@ -387,9 +458,9 @@ async function doAction(action, okMsg) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }); - alert(okMsg); + toast(okMsg); loadConflicts(current); - } catch (err) { alert(err.message); } + } catch (err) { toast(err.message, 'error'); } } $('#btn-abort').addEventListener('click', () => { if (confirm('确定放弃本次合并/变基?仓库将回到冲突前状态。')) doAction('abort', '已放弃本次合并'); @@ -406,17 +477,17 @@ $('#btn-force-push').addEventListener('click', async () => { if (!current) return; try { await post(`/api/repos/${current}/force-push`); - alert('已发起强制推送(--force-with-lease)'); + toast('已发起强制推送(--force-with-lease)'); updateConflictActions(); - } catch (err) { alert(err.message); } + } catch (err) { toast(err.message, 'error'); } }); $('#btn-abort-push').addEventListener('click', async () => { if (!current) return; try { await post(`/api/repos/${current}/abort-push`); - alert('已放弃强制推送'); + toast('已放弃强制推送'); updateConflictActions(); - } catch (err) { alert(err.message); } + } catch (err) { toast(err.message, 'error'); } }); // 根据当前仓库快照切换推送冲突操作区显隐(B3) @@ -476,18 +547,156 @@ function loadDiff(name) { }).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; + 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); } + 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++ }); newNo++; continue; } + if (line === '') { cur.rows.push({ type: 'ctx', text: '', no: oldNo++ }); newNo++; continue; } + cur.rows.push({ type: 'meta', text: line }); // \ No newline 等 + } + 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: r }); 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 lines = String(text).split('\n'); + 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(); - lines.forEach(line => { - const pre = document.createElement('div'); - pre.className = 'diff-line'; - if (line.startsWith('+')) pre.classList.add('diff-add'); - else if (line.startsWith('-')) pre.classList.add('diff-del'); - else if (line.startsWith('@@')) pre.classList.add('diff-hunk'); - pre.textContent = line === '' ? ' ' : line; - frag.appendChild(pre); + 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); } @@ -537,9 +746,8 @@ 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 (Editor.disabled) return; + const v = Editor.value; if (v === draftServer) localStorage.removeItem(draftKey(current, fileCurrent)); else localStorage.setItem(draftKey(current, fileCurrent), v); }, 600); @@ -578,10 +786,9 @@ async function loadFile(name, path) { async function saveFile(commit) { const name = current; const path = fileCurrent; - if (!name || !path) { alert('请先在左侧选择一个文件'); return; } - const editor = $('#file-editor'); - if (editor.disabled) { alert('当前文件不可编辑(二进制或读取失败)'); return; } - const content = editor.value; + if (!name || !path) { toast('请先在左侧选择一个文件'); return; } + if (Editor.disabled) { toast('当前文件不可编辑(二进制或读取失败)'); return; } + const content = Editor.value; try { // 先写盘 await api(`/api/repos/${name}/file`, { @@ -599,14 +806,14 @@ async function saveFile(commit) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, message: $('#file-msg').value }) }); - alert('已保存并提交'); + toast('已保存并提交'); } else { // 保存后自动切到差异预览 $('#f-diff-scope').value = 'working'; showTab('diff'); - alert('已保存到磁盘(未提交)。可在「差异」tab 查看改动,或稍后由自动同步提交。'); + toast('已保存到磁盘(未提交)。可在「差异」tab 查看改动,或稍后由自动同步提交。'); } - } catch (err) { alert('保存失败:' + err.message); } + } catch (err) { toast('保存失败:' + err.message); } } $('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); }); @@ -614,16 +821,16 @@ $('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(c // 差异:复制 / 下载 .patch $('#btn-diff-copy').addEventListener('click', async () => { - if (!diffText) { alert('当前没有可复制的差异内容'); return; } + if (!diffText) { toast('当前没有可复制的差异内容'); return; } try { await navigator.clipboard.writeText(diffText); - alert('差异内容已复制到剪贴板'); + toast('差异内容已复制到剪贴板'); } catch (err) { - alert('复制失败(浏览器可能限制了剪贴板权限):' + err.message); + toast('复制失败(浏览器可能限制了剪贴板权限):' + err.message); } }); $('#btn-diff-download').addEventListener('click', () => { - if (!diffText) { alert('当前没有可下载的差异内容'); return; } + 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'); @@ -639,7 +846,7 @@ $('#btn-file-refresh').addEventListener('click', () => { if (current) loadTree(c // 编辑器「对比此文件」→ 跳到差异 tab 并只看该文件 $('#btn-file-diff').addEventListener('click', () => { - if (!current || !fileCurrent) { alert('请先在左侧选择一个文件'); return; } + if (!current || !fileCurrent) { toast('请先在左侧选择一个文件'); return; } diffPath = fileCurrent; $('#f-diff-scope').value = 'working'; showTab('diff'); @@ -773,7 +980,7 @@ $('#cfg-form').addEventListener('submit', async (e) => { } closeModal(); refresh(); - } catch (err) { alert('保存失败:' + err.message); } + } catch (err) { toast('保存失败:' + err.message); } }); $('#btn-delete').addEventListener('click', async () => { @@ -783,7 +990,7 @@ $('#btn-delete').addEventListener('click', async () => { await api(`/api/repos/${current}`, { method: 'DELETE' }); closeModal(); refresh(); - } catch (err) { alert(err.message); } + } catch (err) { toast(err.message, 'error'); } }); // ---------- 顶部按钮 ---------- diff --git a/static/index.html b/static/index.html index e16f8f6..00e772b 100644 --- a/static/index.html +++ b/static/index.html @@ -1,204 +1,214 @@ - -
- - + + + +还没有任何仓库配置。
- +还没有任何仓库配置。
+