git-sync-web 自动提交 2026-09-09 12:38:18
This commit is contained in:
@@ -1209,4 +1209,240 @@ func (e *Engine) ReadCommitDiff(name, hash string) (string, error) {
|
|||||||
out = out[:131072] + "\n…(内容过长已截断)"
|
out = out[:131072] + "\n…(内容过长已截断)"
|
||||||
}
|
}
|
||||||
return out, nil
|
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
|
||||||
}
|
}
|
||||||
@@ -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}/stats", s.wrapRepo(s.statsRepo))
|
||||||
mux.HandleFunc("GET /api/repos/{name}/commits", s.wrapRepo(s.commitsRepo))
|
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}/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)))
|
mux.Handle("/", http.FileServer(http.FS(static)))
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -329,4 +334,77 @@ func (s *Server) abortPushRepo(w http.ResponseWriter, r *http.Request, name stri
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, 200, map[string]any{"ok": true})
|
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)
|
||||||
}
|
}
|
||||||
+114
@@ -108,6 +108,7 @@ function connect() {
|
|||||||
if (i >= 0) repos[i] = msg.data; else repos.push(msg.data);
|
if (i >= 0) repos[i] = msg.data; else repos.push(msg.data);
|
||||||
render();
|
render();
|
||||||
updateConflictActions();
|
updateConflictActions();
|
||||||
|
if (current === msg.repo && !$('#tab-diff').hidden) loadDiff(msg.repo);
|
||||||
} else if (msg.type === 'log') {
|
} else if (msg.type === 'log') {
|
||||||
appendLiveLine(msg);
|
appendLiveLine(msg);
|
||||||
if (current === msg.repo && !$('#tab-log').hidden) appendLogLine(`[${msg.time}] ${msg.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; });
|
document.querySelectorAll('.tab-pane').forEach(p => { p.hidden = p.id !== 'tab-' + t; });
|
||||||
if (t === 'log' && current) loadLog(current);
|
if (t === 'log' && current) loadLog(current);
|
||||||
if (t === 'history' && current) loadHistory(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 === 'conflict' && current) { loadConflicts(current); updateConflictActions(); }
|
||||||
if (t === 'stats' && current) loadStats(current);
|
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 =>
|
||||||
|
`<span class="df ${f.add ? 'df-add' : ''} ${f.del ? 'df-del' : ''}">${esc(f.path)} <b style="color:var(--green)">+${f.add}</b> <b style="color:var(--red)">-${f.del}</b></span>`
|
||||||
|
).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 =>
|
||||||
|
`<div class="file-item" data-path="${esc(f.path)}" title="${esc(f.path)}">${esc(f.path)}</div>`
|
||||||
|
).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) {
|
function dateStr(d) {
|
||||||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||||
|
|||||||
@@ -45,6 +45,8 @@
|
|||||||
<div class="tabs" id="m-tabs">
|
<div class="tabs" id="m-tabs">
|
||||||
<button data-tab="log" class="tab active">日志</button>
|
<button data-tab="log" class="tab active">日志</button>
|
||||||
<button data-tab="history" class="tab">历史</button>
|
<button data-tab="history" class="tab">历史</button>
|
||||||
|
<button data-tab="diff" class="tab">差异</button>
|
||||||
|
<button data-tab="file" class="tab">文件</button>
|
||||||
<button data-tab="conflict" class="tab">冲突</button>
|
<button data-tab="conflict" class="tab">冲突</button>
|
||||||
<button data-tab="stats" class="tab">统计</button>
|
<button data-tab="stats" class="tab">统计</button>
|
||||||
<button data-tab="config" class="tab">配置</button>
|
<button data-tab="config" class="tab">配置</button>
|
||||||
@@ -56,6 +58,38 @@
|
|||||||
<div id="tab-history" class="tab-pane" hidden>
|
<div id="tab-history" class="tab-pane" hidden>
|
||||||
<div id="commit-list" class="commit-list">加载中…</div>
|
<div id="commit-list" class="commit-list">加载中…</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tab-diff" class="tab-pane" hidden>
|
||||||
|
<div class="diff-head">
|
||||||
|
<label>范围
|
||||||
|
<select id="f-diff-scope">
|
||||||
|
<option value="working">工作区(未暂存)</option>
|
||||||
|
<option value="staged">暂存区</option>
|
||||||
|
<option value="all">全部(含已提交未推送)</option>
|
||||||
|
<option value="remote">与远程对比</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-sm" id="btn-diff-refresh">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div id="diff-stat" class="diff-stat"></div>
|
||||||
|
<pre id="diffbox" class="diffbox">加载中…</pre>
|
||||||
|
</div>
|
||||||
|
<div id="tab-file" class="tab-pane" hidden>
|
||||||
|
<div class="file-head">
|
||||||
|
<button class="btn btn-sm" id="btn-file-refresh">刷新文件列表</button>
|
||||||
|
<span id="file-current" class="file-current"></span>
|
||||||
|
</div>
|
||||||
|
<div class="file-body">
|
||||||
|
<div id="file-list" class="file-list">加载中…</div>
|
||||||
|
<div class="file-edit-col">
|
||||||
|
<textarea id="file-editor" class="file-editor" placeholder="选择左侧文件进行编辑" spellcheck="false"></textarea>
|
||||||
|
<div class="file-actions">
|
||||||
|
<input id="file-msg" class="file-msg" placeholder="提交说明(可选)">
|
||||||
|
<button class="btn btn-sm" id="btn-file-save">保存</button>
|
||||||
|
<button class="btn btn-sm btn-primary" id="btn-file-commit">保存并提交</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="tab-conflict" class="tab-pane" hidden>
|
<div id="tab-conflict" class="tab-pane" hidden>
|
||||||
<div class="manual-actions">
|
<div class="manual-actions">
|
||||||
<button class="btn btn-sm" id="btn-fetch">获取</button>
|
<button class="btn btn-sm" id="btn-fetch">获取</button>
|
||||||
|
|||||||
@@ -121,3 +121,35 @@ textarea { font-family: Consolas, monospace; resize: vertical; }
|
|||||||
.form-actions { display: flex; gap: 8px; margin-top: 16px; }
|
.form-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
|
|
||||||
@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } }
|
@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* ---------- 差异显示(网页端) ---------- */
|
||||||
|
.diff-head { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
|
||||||
|
.diff-head label { font-size: 12px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
|
||||||
|
.diff-stat { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; font-size: 12px; }
|
||||||
|
.diff-stat .df { background: var(--gray-bg); border-radius: 6px; padding: 1px 8px; font-family: Consolas, monospace; }
|
||||||
|
.diff-stat .df.df-add { background: var(--green-bg); }
|
||||||
|
.diff-stat .df.df-del { background: var(--red-bg); }
|
||||||
|
.diffbox { margin: 0; background: #f6f7f9; border: 1px solid var(--border); padding: 10px; border-radius: 8px; font: 12px/1.5 Consolas, monospace; white-space: pre; word-break: break-all; max-height: 56vh; overflow: auto; }
|
||||||
|
.diff-line { white-space: pre; }
|
||||||
|
.diff-line.diff-add { background: var(--green-bg); color: var(--green); }
|
||||||
|
.diff-line.diff-del { background: var(--red-bg); color: var(--red); }
|
||||||
|
.diff-line.diff-hunk { color: var(--blue); background: #eef3f8; }
|
||||||
|
|
||||||
|
/* ---------- 文件编辑(网页端) ---------- */
|
||||||
|
.file-head { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
|
||||||
|
.file-current { font-size: 12px; color: var(--muted); font-family: Consolas, monospace; }
|
||||||
|
.file-body { display: flex; gap: 14px; align-items: stretch; min-height: 420px; }
|
||||||
|
.file-list { flex: none; width: 280px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 6px; background: #fff; max-height: 56vh; }
|
||||||
|
.file-list:empty::after { content: '(无文件)'; color: var(--muted); font-size: 12px; padding: 4px; }
|
||||||
|
.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-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; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.file-body { flex-direction: column; }
|
||||||
|
.file-list { width: 100%; max-height: 200px; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user