diff --git a/engine.go b/engine.go index a1b70e0..9d64e39 100644 --- a/engine.go +++ b/engine.go @@ -131,9 +131,114 @@ func NewEngine(base string) (*Engine, error) { return nil, err } } + _ = e.ensureSummariesDir() return e, nil } +func (e *Engine) summariesDir() string { + return filepath.Join(e.baseDir, "summaries") +} + +// ensureSummariesDir 创建本地变更摘要目录(gitignored,不同步到远程)。 +func (e *Engine) ensureSummariesDir() error { + dir := e.summariesDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + gi := filepath.Join(dir, ".gitignore") + if _, err := os.Stat(gi); err != nil { + _ = os.WriteFile(gi, []byte("*\n"), 0o644) + } + return nil +} + +func (e *Engine) summaryPath(name string) string { + return filepath.Join(e.summariesDir(), name+".md") +} + +func (e *Engine) pendingPath(name string) string { + return filepath.Join(e.summariesDir(), name+".pending") +} + +func (e *Engine) GetPendingSummary(name string) string { + b, err := os.ReadFile(e.pendingPath(name)) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func (e *Engine) SavePendingSummary(name, text string) error { + if err := e.ensureSummariesDir(); err != nil { + return err + } + t := strings.TrimSpace(text) + if t == "" { + return os.Remove(e.pendingPath(name)) + } + return os.WriteFile(e.pendingPath(name), []byte(t+"\n"), 0o644) +} + +// autoSummary 在没有任何人工摘要时,依据本次改动自动生成一句话摘要(仅本地记录用)。 +func (e *Engine) autoSummary(repoPath string) string { + out, err := gitRun(repoPath, 15*time.Second, "status", "--porcelain") + if err != nil { + return "自动提交" + } + lines := strings.Split(strings.TrimSpace(out), "\n") + var files []string + for _, ln := range lines { + ln = strings.TrimSpace(ln) + if ln == "" { + continue + } + p := strings.TrimSpace(ln[3:]) + if i := strings.Index(p, "->"); i >= 0 { + p = strings.TrimSpace(p[i+2:]) + } + p = strings.Trim(p, `"`) + if p != "" { + files = append(files, p) + } + } + if len(files) == 0 { + return "自动提交(无改动文件)" + } + const max = 6 + shown := files + if len(shown) > max { + shown = files[:max] + } + more := "" + if len(files) > max { + more = fmt.Sprintf(" 等%d个", len(files)) + } + return fmt.Sprintf("改动 %d 个文件:%s%s", len(files), strings.Join(shown, ", "), more) +} + +// RecordCommit 把一次提交(commit id + 摘要)追加写入本地按项目分开的摘要文件(不上传)。 +func (e *Engine) RecordCommit(name, sha, summary string) { + if err := e.ensureSummariesDir(); err != nil { + return + } + ts := time.Now().Format("2006-01-02 15:04:05") + entry := fmt.Sprintf("## %s · %s\n%s\n\n", strings.TrimSpace(sha), ts, strings.TrimSpace(summary)) + f, err := os.OpenFile(e.summaryPath(name), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + _, _ = f.WriteString(entry) +} + +func (e *Engine) GetSummaryLog(name string) string { + b, err := os.ReadFile(e.summaryPath(name)) + if err != nil { + return "" + } + return string(b) +} + func (e *Engine) Bus() *EventBus { return e.bus } func (e *Engine) configNames() []string { @@ -619,10 +724,22 @@ func (e *Engine) syncOnce(s *repoState, trigger string) { return } cmsg := "git-sync-web 自动提交 " + time.Now().Format("2006-01-02 15:04:05") + pend := e.GetPendingSummary(s.cfg.Name) + if pend != "" { + cmsg = pend + } if _, cerr := gitRun(cfg.RepoPath, 120*time.Second, "commit", "-m", cmsg); cerr != nil { e.setFail(s, "自动提交失败: "+cerr.Error()) return } + if sha, serr := gitRun(cfg.RepoPath, 10*time.Second, "rev-parse", "--short", "HEAD"); serr == nil { + sum := cmsg + if pend == "" { + sum = e.autoSummary(cfg.RepoPath) + } + e.RecordCommit(s.cfg.Name, sha, sum) + } + _ = os.Remove(e.pendingPath(s.cfg.Name)) e.logf(s, "info", "自动提交完成,将随本轮同步推送") dirty = 0 } else { @@ -986,6 +1103,13 @@ func (e *Engine) ConflictResolve(name string) error { } func (e *Engine) Create(c *RepoConfig) error { + branch := c.Branch + if branch == "" { + branch = "main" + } + if err := ensureRepo(c.RepoPath, branch); err != nil { + return fmt.Errorf("初始化仓库失败: %w", err) + } if err := validateRepoConfig(e.cfgDir, c, true); err != nil { return err } @@ -1006,6 +1130,13 @@ func (e *Engine) Update(c *RepoConfig) error { if s == nil { return fmt.Errorf("配置不存在: %s", c.Name) } + branch := c.Branch + if branch == "" { + branch = "main" + } + if err := ensureRepo(c.RepoPath, branch); err != nil { + return fmt.Errorf("初始化仓库失败: %w", err) + } if err := validateRepoConfig(e.cfgDir, c, false); err != nil { return err } @@ -1250,10 +1381,11 @@ func (e *Engine) FileTree(name string) []FileNode { } type FileContent struct { - Path string `json:"path"` - Content string `json:"content"` - Binary bool `json:"binary"` - Size int `json:"size"` + Path string `json:"path"` + Content string `json:"content"` + Binary bool `json:"binary"` + Size int `json:"size"` + Changed []int `json:"changed,omitempty"` } func (e *Engine) ReadFileContent(name, rel string) (*FileContent, error) { @@ -1282,7 +1414,106 @@ func (e *Engine) ReadFileContent(name, rel string) (*FileContent, error) { 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 + content := string(data) + changed, _ := fileChangeLines(s.cfg.RepoPath, rel, content) + return &FileContent{Path: rel, Content: content, Size: int(info.Size()), Changed: changed}, nil +} + +func isUntracked(repoPath, rel string) bool { + out, err := gitRun(repoPath, 10*time.Second, "status", "--porcelain", "--", rel) + if err != nil { + return false + } + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "??") { + return true + } + } + return false +} + +func fileChangeLines(repoPath, rel, content string) ([]int, error) { + if _, err := gitRun(repoPath, 10*time.Second, "rev-parse", "--verify", "HEAD"); err != nil { + return nil, nil + } + if isUntracked(repoPath, rel) { + return allLineNumbers(content), nil + } + out, err := gitRun(repoPath, 30*time.Second, "diff", "--no-color", "HEAD", "--", rel) + if err != nil { + return nil, nil + } + return parseDiffAddedLines(out), nil +} + +func allLineNumbers(content string) []int { + n := strings.Count(content, "\n") + if content == "" { + return nil + } + n++ + lines := make([]int, 0, n) + for i := 1; i <= n; i++ { + lines = append(lines, i) + } + return lines +} + +func parseDiffAddedLines(diff string) []int { + set := map[int]struct{}{} + newLine := 0 + for _, raw := range strings.Split(diff, "\n") { + line := raw + if strings.HasPrefix(line, "@@") { + if i := strings.Index(line, " +"); i >= 0 { + rest := line[i+2:] + if c, ok := parseHunkNewStart(rest); ok { + newLine = c + } + } + continue + } + if newLine == 0 { + continue + } + switch { + case strings.HasPrefix(line, "+"): + if strings.HasPrefix(line, "+++") { + continue + } + set[newLine] = struct{}{} + newLine++ + case strings.HasPrefix(line, " "): + newLine++ + case strings.HasPrefix(line, "-"): + default: + } + } + out := make([]int, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Ints(out) + return out +} + +func parseHunkNewStart(rest string) (int, bool) { + end := strings.IndexAny(rest, " @") + if end >= 0 { + rest = rest[:end] + } + if i := strings.Index(rest, ","); i >= 0 { + rest = rest[:i] + } + n := strings.TrimSpace(rest) + if n == "" { + return 0, false + } + v, err := strconv.Atoi(n) + if err != nil { + return 0, false + } + return v, true } func (e *Engine) WriteFileContent(name, rel, content string) error { @@ -1323,6 +1554,9 @@ func (e *Engine) CommitFile(name, rel, message string) error { if _, err := gitRun(s.cfg.RepoPath, 60*time.Second, "commit", "-m", message, "--", rel); err != nil { return fmt.Errorf("git commit 失败: %w", err) } + if sha, serr := gitRun(s.cfg.RepoPath, 10*time.Second, "rev-parse", "--short", "HEAD"); serr == nil { + e.RecordCommit(name, sha, message) + } e.logf(s, "info", "网页端提交 %s 成功", rel) return nil } diff --git a/git.go b/git.go index fa467e9..b0e2e31 100644 --- a/git.go +++ b/git.go @@ -54,6 +54,29 @@ func isRepo(path string) bool { return err == nil } +func ensureRepo(path, branch string) error { + if path == "" { + return fmt.Errorf("仓库路径为空") + } + path = normalizeWinPath(path) + if err := os.MkdirAll(path, 0o755); err != nil { + return err + } + if isRepo(path) { + return nil + } + if branch == "" { + branch = "main" + } + if _, err := gitRun(path, 30*time.Second, "init", "-b", branch); err != nil { + if _, e2 := gitRun(path, 30*time.Second, "init"); e2 != nil { + return e2 + } + _, _ = gitRun(path, 15*time.Second, "checkout", "-b", branch) + } + return nil +} + func currentBranch(path string) (string, error) { return gitRun(path, 15*time.Second, "rev-parse", "--abbrev-ref", "HEAD") } @@ -210,5 +233,23 @@ func changedFiles(path string) map[string]string { m[p] = "M" // 含 UU/AU 冲突状态,统一显示为已修改 } } + for p, st := range m { + if st == "?" && strings.HasSuffix(p, "/") { + expandUntracked(path, p, m) + } + } return m } + +func expandUntracked(repoPath, dir string, m map[string]string) { + out, err := gitRun(repoPath, 15*time.Second, "ls-files", "--others", "--exclude-standard", "--", dir) + if err != nil { + return + } + for _, ln := range strings.Split(out, "\n") { + ln = strings.TrimSpace(ln) + if ln != "" { + m[ln] = "?" + } + } +} diff --git a/server.go b/server.go index dae6149..65e19ce 100644 --- a/server.go +++ b/server.go @@ -53,6 +53,8 @@ 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("GET /api/repos/{name}/summary", s.wrapRepo(s.summaryGetRepo)) + mux.HandleFunc("POST /api/repos/{name}/summary", s.wrapRepo(s.summarySaveRepo)) 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)) @@ -376,6 +378,28 @@ func (s *Server) fileWriteRepo(w http.ResponseWriter, r *http.Request, name stri writeJSON(w, 200, map[string]any{"ok": true}) } +func (s *Server) summaryGetRepo(w http.ResponseWriter, r *http.Request, name string) { + writeJSON(w, 200, map[string]any{ + "pending": s.engine.GetPendingSummary(name), + "log": s.engine.GetSummaryLog(name), + }) +} + +func (s *Server) summarySaveRepo(w http.ResponseWriter, r *http.Request, name string) { + var req struct { + Summary string `json:"summary"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, 400, "请求体格式错误") + return + } + if err := s.engine.SavePendingSummary(name, req.Summary); err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "pending": s.engine.GetPendingSummary(name)}) +} + func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) { var req struct { Path string `json:"path"` diff --git a/static/app.js b/static/app.js index 05a6eff..63a6460 100644 --- a/static/app.js +++ b/static/app.js @@ -113,6 +113,17 @@ const Editor = { if (this.cm) this.cm.setOption('theme', dark ? 'material-darker' : 'eclipse'); }, refresh() { if (this.cm) setTimeout(() => this.cm.refresh(), 30); }, + setChangedLines(lines) { + this._changed = lines && lines.length ? lines : null; + if (!this.cm) return; + const n = this.cm.lineCount(); + for (let i = 0; i < n; i++) this.cm.removeLineClass(i, 'background', 'cm-chg'); + if (!this._changed) return; + for (const ln of this._changed) { + const idx = ln - 1; + if (idx >= 0 && idx < n) this.cm.addLineClass(idx, 'background', 'cm-chg'); + } + }, onSave: null, // 由 cm-boot.js 的快捷键触发 onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存) }; @@ -316,6 +327,8 @@ function openDetail(name) { $('#btn-delete').hidden = false; $('#btn-clone').hidden = false; fillForm(r); + $('#summary-box').hidden = false; + loadSummary(name); showTab('log'); $('#modal').hidden = false; } @@ -328,10 +341,22 @@ function openCreate() { $('#btn-delete').hidden = true; $('#btn-clone').hidden = true; fillForm(null); + $('#summary-box').hidden = true; showTab('config'); $('#modal').hidden = false; } +async function loadSummary(name) { + if (!name) return; + try { + const d = await api(`/api/repos/${name}/summary`); + $('#f-summary').value = d.pending || ''; + $('#summary-log').textContent = d.log ? d.log.trim() : '(暂无本地变更摘要记录)'; + } catch (err) { + $('#summary-log').textContent = '读取摘要失败:' + err.message; + } +} + function closeModal() { $('#modal').hidden = true; current = null; @@ -817,6 +842,8 @@ async function loadFile(name, path) { showDraftNote(false); const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`); if (d.binary) { + Editor.setChangedLines(null); + const bh = $('#file-chg-hint'); if (bh) bh.hidden = true; Editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`; Editor.disabled = true; draftServer = ''; @@ -830,8 +857,13 @@ async function loadFile(name, path) { } else { Editor.value = draftServer; } + Editor.setChangedLines(d.changed); + const hint = $('#file-chg-hint'); + if (hint) hint.hidden = !(d.changed && d.changed.length); Editor.disabled = false; } catch (err) { + Editor.setChangedLines(null); + const eh = $('#file-chg-hint'); if (eh) eh.hidden = true; Editor.value = '读取失败:' + err.message; Editor.disabled = true; } finally { @@ -1069,6 +1101,19 @@ $('#btn-clone').addEventListener('click', () => { $('#f-name').focus(); }); +$('#btn-summary-save').addEventListener('click', async () => { + if (!current) return; + try { + const res = await api(`/api/repos/${current}/summary`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ summary: $('#f-summary').value }) + }); + toast(res.ok ? '摘要已保存(本地,下次自动提交将使用此信息)' : '摘要保存失败', res.ok ? 'info' : 'error'); + await loadSummary(current); + } catch (err) { toast('摘要保存失败:' + err.message, 'error'); } +}); + $('#btn-new').addEventListener('click', openCreate); $('#btn-empty-new').addEventListener('click', openCreate); $('#btn-sync-all').addEventListener('click', () => { diff --git a/static/index.html b/static/index.html index dd4d599..1e6ccca 100644 --- a/static/index.html +++ b/static/index.html @@ -111,8 +111,9 @@
加载中…
-
- +
+ +
@@ -205,10 +206,18 @@ - + + +
diff --git a/static/style.css b/static/style.css index e19b5b4..ba17cbd 100644 --- a/static/style.css +++ b/static/style.css @@ -145,11 +145,19 @@ textarea { font-family: Consolas, monospace; resize: vertical; } .file-item { padding: 4px 8px; border-radius: 6px; font-size: 12.5px; font-family: Consolas, monospace; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .file-item:hover { background: #f0f3f6; } .file-edit-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; } +.file-chg-hint { font-size: 12px; color: #9a6b00; background: rgba(255, 196, 0, 0.12); border: 1px solid rgba(224, 160, 32, 0.4); border-radius: 6px; padding: 4px 8px; } +html.dark .file-chg-hint { color: #d9a93a; background: rgba(255, 196, 0, 0.1); border-color: rgba(201, 143, 28, 0.4); } .file-editor { flex: 1; min-height: 360px; width: 100%; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12.5px/1.55 Consolas, monospace; color: var(--text); background: #fff; white-space: pre; overflow: auto; } .file-editor:disabled { background: #f6f7f9; color: var(--muted); } .file-actions { display: flex; gap: 8px; align-items: center; } .file-msg { flex: 1; } +.summary-box { margin-top: 16px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--card); } +.summary-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 13px; color: var(--muted); } +.summary-head .btn-sm { padding: 4px 10px; font-size: 12px; } +.summary-log { margin-top: 10px; padding: 10px 12px; border: 1px dashed var(--border); border-radius: 8px; background: #fafbfc; font: 12px/1.6 Consolas, "Microsoft YaHei", monospace; color: var(--text); white-space: pre-wrap; max-height: 220px; overflow: auto; } +html.dark .summary-log { background: #12151a; } + @media (max-width: 760px) { .file-body { flex-direction: column; } .file-list { width: 100%; max-height: 200px; } @@ -241,6 +249,16 @@ html.dark .file-editor { background: #12151a; } .CodeMirror { height: 420px; border: 1px solid var(--border); border-radius: 8px; font: 12.5px/1.55 Consolas, monospace; } .CodeMirror-scroll { min-height: 380px; } +/* 未提交修改行高亮 */ +.CodeMirror-linebackground.cm-chg { + background-color: rgba(255, 196, 0, 0.16); + box-shadow: inset 3px 0 0 0 #e0a020; +} +html.dark .CodeMirror-linebackground.cm-chg { + background-color: rgba(255, 196, 0, 0.13); + box-shadow: inset 3px 0 0 0 #c98f1c; +} + /* ---------- 冲突文件内联编辑 ---------- */ .cf-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; } .cf-head h4 { margin: 0; flex: 1; }