From d4f10ab574bd8e23381c06011be89bff0324f69a Mon Sep 17 00:00:00 2001 From: tamakyi Date: Wed, 9 Sep 2026 12:38:18 +0800 Subject: [PATCH] =?UTF-8?q?git-sync-web=20=E8=87=AA=E5=8A=A8=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=202026-09-09=2012:38:18?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- engine.go | 236 ++++++++++++++++++++++++++++++++++++++++++++++ server.go | 78 +++++++++++++++ static/app.js | 114 ++++++++++++++++++++++ static/index.html | 34 +++++++ static/style.css | 32 +++++++ 5 files changed, 494 insertions(+) diff --git a/engine.go b/engine.go index ab10d0e..70da0f3 100644 --- a/engine.go +++ b/engine.go @@ -1209,4 +1209,240 @@ func (e *Engine) ReadCommitDiff(name, hash string) (string, error) { out = out[:131072] + "\n…(内容过长已截断)" } return out, nil +} + +// ---------- 差异与文件编辑(网页端) ---------- + +const fileEditMaxBytes = 1 << 20 // 1MB,网页编辑单文件上限 + +// safeRepoPath 将仓库内相对路径解析为绝对路径,并严格校验不越界、不触及 .git +func (e *Engine) safeRepoPath(s *repoState, rel string) (string, error) { + rel = filepath.ToSlash(strings.TrimSpace(rel)) + rel = strings.TrimPrefix(rel, "/") + if rel == "" || rel == "." { + return "", fmt.Errorf("无效路径") + } + for _, seg := range strings.Split(rel, "/") { + if seg == ".." { + return "", fmt.Errorf("路径不允许包含 ..") + } + } + root := filepath.Clean(s.cfg.RepoPath) + full := filepath.Clean(filepath.Join(root, filepath.FromSlash(rel))) + if full != root && !strings.HasPrefix(full, root+string(os.PathSeparator)) { + return "", fmt.Errorf("路径越界: %s", rel) + } + relClean := strings.TrimPrefix(full, root+string(os.PathSeparator)) + if relClean == ".git" || strings.HasPrefix(relClean, ".git"+string(os.PathSeparator)) { + return "", fmt.Errorf("禁止访问 .git 内部") + } + return full, nil +} + +// FileNode 文件树节点(网页端) +type FileNode struct { + Path string `json:"path"` +} + +// FileTree 列出仓库内可编辑文件(已跟踪 + 未跟踪且未被忽略),天然避开 .git 内部 +func (e *Engine) FileTree(name string) []FileNode { + s := e.get(name) + if s == nil { + return []FileNode{} + } + set := map[string]struct{}{} + collect := func(args ...string) { + if out, err := gitRun(s.cfg.RepoPath, 15*time.Second, args...); err == nil { + for _, ln := range strings.Split(strings.TrimSpace(out), "\n") { + ln = strings.TrimSpace(ln) + if ln != "" { + set[ln] = struct{}{} + } + } + } + } + collect("ls-files") + collect("ls-files", "--others", "--exclude-standard") + paths := make([]string, 0, len(set)) + for p := range set { + paths = append(paths, p) + } + sort.Strings(paths) + nodes := make([]FileNode, 0, len(paths)) + for _, p := range paths { + nodes = append(nodes, FileNode{Path: p}) + } + return nodes +} + +// FileContent 文件内容读取结果 +type FileContent struct { + Path string `json:"path"` + Content string `json:"content"` + Binary bool `json:"binary"` + Size int `json:"size"` +} + +// ReadFileContent 读取仓库内文件内容(带大小上限与二进制检测) +func (e *Engine) ReadFileContent(name, rel string) (*FileContent, error) { + s := e.get(name) + if s == nil { + return nil, fmt.Errorf("配置不存在: %s", name) + } + full, err := e.safeRepoPath(s, rel) + if err != nil { + return nil, err + } + info, err := os.Stat(full) + if err != nil { + return nil, err + } + if info.IsDir() { + return nil, fmt.Errorf("这是一个目录: %s", rel) + } + if info.Size() > fileEditMaxBytes { + return nil, fmt.Errorf("文件过大(>%d 字节),禁止在网页编辑", fileEditMaxBytes) + } + data, err := os.ReadFile(full) + if err != nil { + return nil, err + } + if bytes.Contains(data, []byte{0}) { + return &FileContent{Path: rel, Binary: true, Size: int(info.Size())}, nil + } + return &FileContent{Path: rel, Content: string(data), Size: int(info.Size())}, nil +} + +// WriteFileContent 将内容写回仓库内文件(路径校验 + 大小上限 + 二进制拒绝) +func (e *Engine) WriteFileContent(name, rel, content string) error { + s := e.get(name) + if s == nil { + return fmt.Errorf("配置不存在: %s", name) + } + full, err := e.safeRepoPath(s, rel) + if err != nil { + return err + } + if len(content) > fileEditMaxBytes { + return fmt.Errorf("内容过大(>%d 字节)", fileEditMaxBytes) + } + if strings.Contains(content, "\x00") { + return fmt.Errorf("内容含空字符,疑似二进制,禁止写入") + } + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return err + } + return os.WriteFile(full, []byte(content), 0o644) +} + +// CommitFile 仅提交单个被网页端编辑的文件 +func (e *Engine) CommitFile(name, rel, message string) error { + s := e.get(name) + if s == nil { + return fmt.Errorf("配置不存在: %s", name) + } + if _, err := e.safeRepoPath(s, rel); err != nil { + return err + } + if strings.TrimSpace(message) == "" { + message = "网页端编辑: " + rel + } + if _, err := gitRun(s.cfg.RepoPath, 30*time.Second, "add", "--", rel); err != nil { + return fmt.Errorf("git add 失败: %w", err) + } + if _, err := gitRun(s.cfg.RepoPath, 60*time.Second, "commit", "-m", message, "--", rel); err != nil { + return fmt.Errorf("git commit 失败: %w", err) + } + e.logf(s, "info", "网页端提交 %s 成功", rel) + return nil +} + +// DiffFile 单文件增删统计 +type DiffFile struct { + Path string `json:"path"` + Add int `json:"add"` + Del int `json:"del"` +} + +// DiffResult 差异结果(网页端「差异」tab) +type DiffResult struct { + Scope string `json:"scope"` + Stat string `json:"stat"` + Text string `json:"text"` + Files []DiffFile `json:"files"` + VsRemote bool `json:"vs_remote"` +} + +// WorkingDiff 返回工作区/暂存/全部/与远程的差异 +func (e *Engine) WorkingDiff(name, scope string) (*DiffResult, error) { + s := e.get(name) + if s == nil { + return nil, fmt.Errorf("配置不存在: %s", name) + } + res := &DiffResult{Scope: scope} + var args []string + remote := "origin" + switch scope { + case "staged": + args = []string{"diff", "--cached", "--no-color"} + case "all": + args = []string{"diff", "HEAD", "--no-color"} + case "remote": + ahead, _, _ := aheadBehind(s.cfg.RepoPath, remote, s.cfg.Branch) + if ahead <= 0 { + res.Text = "本地没有领先远程的提交,无需对比。" + return res, nil + } + args = []string{"diff", "--no-color", remote + "/" + s.cfg.Branch, "HEAD"} + res.VsRemote = true + default: // working(未暂存) + args = []string{"diff", "--no-color"} + } + statArgs := append([]string{}, args...) + if scope == "remote" { + statArgs = []string{"diff", "--no-color", "--stat", remote + "/" + s.cfg.Branch, "HEAD"} + } else { + statArgs = append(statArgs, "--stat") + } + if statOut, serr := gitRun(s.cfg.RepoPath, 30*time.Second, statArgs...); serr == nil { + res.Stat = statOut + res.Files = parseStatFiles(statOut) + } + if textOut, terr := gitRun(s.cfg.RepoPath, 60*time.Second, args...); terr == nil { + res.Text = textOut + } else { + res.Text = terr.Error() + } + if len(res.Text) > 262144 { + res.Text = res.Text[:262144] + "\n…(diff 过长已截断)" + } + return res, nil +} + +// parseStatFiles 解析 `git diff --stat` 输出为单文件增删统计 +func parseStatFiles(stat string) []DiffFile { + var files []DiffFile + for _, line := range strings.Split(stat, "\n") { + if strings.Contains(line, "files changed") || strings.Contains(line, "file changed") { + continue + } + idx := strings.Index(line, " | ") + if idx < 0 { + continue + } + path := strings.TrimSpace(line[:idx]) + rest := line[idx+3:] + add, del := 0, 0 + for _, r := range rest { + if r == '+' { + add++ + } else if r == '-' { + del++ + } + } + if path != "" { + files = append(files, DiffFile{Path: path, Add: add, Del: del}) + } + } + return files } \ No newline at end of file diff --git a/server.go b/server.go index 26b7e4b..b161014 100644 --- a/server.go +++ b/server.go @@ -50,6 +50,11 @@ func NewServer(e *Engine, static fs.FS, password string) *Server { mux.HandleFunc("GET /api/repos/{name}/stats", s.wrapRepo(s.statsRepo)) mux.HandleFunc("GET /api/repos/{name}/commits", s.wrapRepo(s.commitsRepo)) mux.HandleFunc("GET /api/repos/{name}/commits/{hash}", s.wrapRepo(s.commitDiffRepo)) + 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}/commit", s.wrapRepo(s.commitFileRepo)) + mux.HandleFunc("GET /api/repos/{name}/diff", s.wrapRepo(s.diffRepo)) mux.Handle("/", http.FileServer(http.FS(static))) return s } @@ -329,4 +334,77 @@ func (s *Server) abortPushRepo(w http.ResponseWriter, r *http.Request, name stri return } writeJSON(w, 200, map[string]any{"ok": true}) +} + +// ---------- 差异与文件编辑(网页端) ---------- + +func (s *Server) treeRepo(w http.ResponseWriter, r *http.Request, name string) { + writeJSON(w, 200, map[string]any{"files": s.engine.FileTree(name)}) +} + +func (s *Server) fileRepo(w http.ResponseWriter, r *http.Request, name string) { + rel := r.URL.Query().Get("path") + if rel == "" { + writeErr(w, 400, "缺少 path 参数") + return + } + fc, err := s.engine.ReadFileContent(name, rel) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + writeJSON(w, 200, fc) +} + +func (s *Server) fileWriteRepo(w http.ResponseWriter, r *http.Request, name string) { + var req struct { + Path string `json:"path"` + Content string `json:"content"` + } + 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.WriteFileContent(name, req.Path, req.Content); 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"` + Message string `json:"message"` + } + 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.CommitFile(name, req.Path, req.Message); err != nil { + writeErr(w, 400, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"ok": true}) +} + +func (s *Server) diffRepo(w http.ResponseWriter, r *http.Request, name string) { + scope := r.URL.Query().Get("scope") + if scope == "" { + scope = "working" + } + res, err := s.engine.WorkingDiff(name, scope) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + writeJSON(w, 200, res) } \ No newline at end of file diff --git a/static/app.js b/static/app.js index 0a15b15..ba78400 100644 --- a/static/app.js +++ b/static/app.js @@ -108,6 +108,7 @@ function connect() { 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.msg}`); @@ -206,6 +207,8 @@ function showTab(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); if (t === 'conflict' && current) { loadConflicts(current); updateConflictActions(); } if (t === 'stats' && current) loadStats(current); } @@ -367,6 +370,117 @@ function updateConflictActions() { } } +// ---------- 差异显示(网页端) ---------- +function loadDiff(name) { + const scope = $('#f-diff-scope').value; + const box = $('#diffbox'); + const stat = $('#diff-stat'); + box.textContent = '加载中…'; + stat.innerHTML = ''; + api(`/api/repos/${name}/diff?scope=${encodeURIComponent(scope)}`).then(d => { + renderDiff(box, d.text || '(无差异)'); + 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 renderDiff(box, text) { + box.innerHTML = ''; + const lines = String(text).split('\n'); + 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); + }); + box.appendChild(frag); +} + +// ---------- 文件编辑(网页端) ---------- +let fileCurrent = null; +function loadTree(name) { + const list = $('#file-list'); + list.textContent = '加载中…'; + api(`/api/repos/${name}/tree`).then(d => { + const files = d.files || []; + if (!files.length) { list.textContent = '(无文件)'; return; } + list.innerHTML = files.map(f => + `
${esc(f.path)}
` + ).join(''); + }).catch(err => { list.textContent = '文件列表加载失败:' + err.message; }); +} + +async function loadFile(name, path) { + const editor = $('#file-editor'); + editor.value = '加载中…'; + editor.disabled = false; + $('#file-current').textContent = path; + fileCurrent = path; + try { + const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`); + if (d.binary) { + editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`; + editor.disabled = true; + } else { + editor.value = d.content || ''; + editor.disabled = false; + } + } catch (err) { + editor.value = '读取失败:' + err.message; + editor.disabled = true; + } +} + +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; + try { + // 先写盘 + await api(`/api/repos/${name}/file`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, content }) + }); + if (commit) { + await api(`/api/repos/${name}/commit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, message: $('#file-msg').value }) + }); + alert('已保存并提交'); + } else { + // 保存后自动切到差异预览 + $('#f-diff-scope').value = 'working'; + showTab('diff'); + alert('已保存到磁盘(未提交)。可在「差异」tab 查看改动,或稍后由自动同步提交。'); + } + } catch (err) { alert('保存失败:' + err.message); } +} + +$('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); }); +$('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(current); }); +$('#btn-file-refresh').addEventListener('click', () => { if (current) loadTree(current); }); +$('#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)); + // ---------- 统计 ---------- function dateStr(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); diff --git a/static/index.html b/static/index.html index adba04b..f9126c6 100644 --- a/static/index.html +++ b/static/index.html @@ -45,6 +45,8 @@
+ + @@ -56,6 +58,38 @@ + +