Compare commits

...
11 Commits
25 changed files with 1857 additions and 127 deletions
+9
View File
@@ -92,6 +92,15 @@ func parseCronField(f string, min, max int) (map[int]bool, error) {
return set, nil
}
// cronNextString 返回 cron 表达式相对当前时间的下次触发时间(供配置表单实时预览)
func cronNextString(expr string) (string, error) {
spec, err := parseCron(expr)
if err != nil {
return "", err
}
return cronNext(spec, time.Now()).Format("2006-01-02 15:04"), nil
}
// cronNext 返回 spec 在 after 之后的下一次触发时间(分钟精度,最多向前搜索一年)
func cronNext(spec *cronSpec, after time.Time) time.Time {
t := after.Truncate(time.Minute).Add(time.Minute)
+298 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
@@ -204,7 +205,8 @@ func (e *Engine) logf(s *repoState, level, format string, args ...any) {
if logPath != "" {
_ = os.MkdirAll(filepath.Dir(logPath), 0o755)
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
fmt.Fprintln(f, "["+ts+"] "+msg)
// 落盘格式:[时间] [级别] 消息,便于网页端按级别过滤
fmt.Fprintln(f, "["+ts+"] ["+level+"] "+msg)
_ = f.Close()
}
}
@@ -1208,4 +1210,299 @@ 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"`
Status string `json:"status"` // M 已修改 / A 新增 / D 删除 / ? 未跟踪 / 空 未改动
}
// 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)
// 附带每个文件的改动状态(M/A/D/?),便于网页端一眼看出哪些文件被动过
status := changedFiles(s.cfg.RepoPath)
nodes := make([]FileNode, 0, len(paths))
for _, p := range paths {
nodes = append(nodes, FileNode{Path: p, Status: status[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
}
// 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"`
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"`
Word bool `json:"word"`
}
// 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)
}
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"}
}
// 词级差异:--word-diff=plain 用 [-x-] / {+x+} 标记行内改动
// 注意:该模式下 git 输出的上下文行不带前导空格,前端解析需兼容(见 parseUnifiedDiff)
if mode == "word" {
args = append(args, "--word-diff=plain")
res.Word = true
}
statArgs := append([]string{}, args...)
if scope == "remote" {
statArgs = []string{"diff", "--no-color", "--stat", remote + "/" + s.cfg.Branch, "HEAD"}
} else {
statArgs = append(statArgs, "--stat")
}
// 单文件过滤:追加 -- <path>,先做路径安全校验(防穿越 / .git)
if rel != "" {
if _, err := e.safeRepoPath(s, rel); err != nil {
return nil, err
}
args = append(args, "--", rel)
statArgs = append(statArgs, "--", rel)
}
if statOut, serr := gitRun(s.cfg.RepoPath, 30*time.Second, statArgs...); serr == nil {
statOut = stripWarnings(statOut)
res.Stat = statOut
res.Files = parseStatFiles(statOut)
}
if textOut, terr := gitRun(s.cfg.RepoPath, 60*time.Second, args...); terr == nil {
res.Text = stripWarnings(textOut)
} else {
res.Text = terr.Error()
}
if len(res.Text) > 262144 {
res.Text = res.Text[:262144] + "\n…(diff 过长已截断)"
}
return res, nil
}
// stripWarnings 去掉 git 在 stderr 输出的环境告警(如 autocrlf 提示),避免污染差异展示
func stripWarnings(s string) string {
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
for _, l := range lines {
if strings.HasPrefix(l, "warning:") {
continue
}
out = append(out, l)
}
return strings.Join(out, "\n")
}
// 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
}
+51 -5
View File
@@ -30,16 +30,25 @@ func gitRun(repoPath string, timeout time.Duration, args ...string) (string, err
"GIT_SSH_COMMAND=ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new",
)
cmd.WaitDelay = 10 * time.Second
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
// stdout 与 stderr 分开收集:成功时只返回干净的 stdout 供解析,
// 避免 git 的环境告警(如 autocrlf、进度信息)污染解析结果;
// 失败时优先用 stderr 作为错误信息,保证诊断信息不丢失。
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err := cmd.Run()
out := strings.TrimSpace(buf.String())
// 只裁剪尾部空白,不要用 TrimSpace 裁整体:
// git 输出首行的前导空格具有语义(status --porcelain 的暂存位、diff 的上下文行),
// 整体 TrimSpace 会把它吃掉,导致按固定偏移解析路径时错位。
out := strings.TrimRight(outBuf.String(), "\r\n\t ")
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return out, fmt.Errorf("命令超时: git %s", strings.Join(args, " "))
}
msg := out
msg := strings.TrimSpace(errBuf.String())
if msg == "" {
msg = out
}
if msg == "" {
msg = err.Error()
}
@@ -189,3 +198,40 @@ func resolveDone(path string, rebase bool) error {
}
return nil
}
// changedFiles 解析 git status --porcelain,返回 路径 -> 状态标记(M/A/D/?),
// 供网页端文件树展示每个文件的改动状态。
func changedFiles(path string) map[string]string {
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
m := map[string]string{}
if err != nil {
return m
}
for _, line := range strings.Split(out, "\n") {
if len(line) < 4 {
continue
}
code := line[:2]
p := strings.TrimSpace(line[3:])
// 重命名/复制:取目标路径
if i := strings.Index(p, "->"); i >= 0 {
p = strings.TrimSpace(p[i+2:])
}
// git 对含特殊字符的路径会加引号
p = strings.Trim(p, `"`)
if p == "" {
continue
}
switch {
case code == "??":
m[p] = "?"
case code[0] == 'A' || code[0] == 'C':
m[p] = "A"
case code[0] == 'D' || code[1] == 'D':
m[p] = "D"
default:
m[p] = "M" // 含 UU/AU 冲突状态,统一显示为已修改
}
}
return m
}
+119
View File
@@ -50,6 +50,13 @@ 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}/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)
mux.Handle("/", http.FileServer(http.FS(static)))
return s
}
@@ -329,4 +336,116 @@ 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})
}
// 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"`
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"
}
// 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
}
writeJSON(w, 200, res)
}
// handleCronNext 返回 cron 表达式的下次执行时间,供配置表单实时预览
func (s *Server) handleCronNext(w http.ResponseWriter, r *http.Request) {
expr := r.URL.Query().Get("expr")
if expr == "" {
writeErr(w, 400, "缺少 expr 参数")
return
}
next, err := cronNextString(expr)
if err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 200, map[string]any{"next": next})
}
+743 -22
View File
@@ -9,6 +9,31 @@ const STATUS = {
};
const RESULT_TEXT = { ok: '成功', error: '失败', conflict: '冲突' };
// ---------- 相对时间(卡片「上次同步」) ----------
const STALE_HOURS = 24; // 超过该小时数且仍在运行 → 标记「久未同步」
function parseTs(s) {
if (!s) return null;
// 后端时间格式 "2006-01-02 15:04:05",补齐为 ISO 本地时间
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;
@@ -31,6 +56,79 @@ 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 事件触发(草稿缓存)
};
// 顶层 const 不会挂到 window,显式暴露给 cm-boot.js 使用
window.Editor = Editor;
// ---------- 渲染 ----------
function render() {
const grid = $('#grid');
@@ -45,7 +143,7 @@ function card(r) {
el.className = 'card ' + st.cls;
el.dataset.name = r.name;
const last = r.last_sync
? `${r.last_sync.time.slice(5, 16)} · ${RESULT_TEXT[r.last_sync.result] || r.last_sync.result}`
? `${relTime(r.last_sync.time)} · ${RESULT_TEXT[r.last_sync.result] || r.last_sync.result}`
: '从未同步';
el.innerHTML = `
<div class="card-head">
@@ -62,8 +160,10 @@ function card(r) {
${r.auto_commit ? '<span class="badge">自动提交</span>' : ''}
${r.cron ? `<span class="badge" title="${esc(r.cron)}">cron</span>` : ''}
${r.conflicts && r.conflicts.length ? `<span class="badge b-conflict">冲突 ${r.conflicts.length}</span>` : ''}
${r.push_conflict ? `<span class="badge b-conflict">推送冲突</span>` : ''}
${r.running && r.last_sync && isStale(r.last_sync.time) ? '<span class="badge b-stale">久未同步</span>' : ''}
</div>
<div class="last">上次同步:${esc(last)}</div>
<div class="last" title="${r.last_sync ? esc(r.last_sync.time) : ''}">上次同步:${esc(last)}</div>
${r.last_sync && r.last_sync.detail ? `<div class="last-detail" title="${esc(r.last_sync.detail)}">${esc(r.last_sync.detail)}</div>` : ''}
${r.status === 'error' && r.last_error ? `<div class="err" title="${esc(r.last_error)}">${esc(r.last_error)}</div>` : ''}
<div class="card-btns">
@@ -88,7 +188,7 @@ $('#grid').addEventListener('click', async (e) => {
} else if (act === 'detail') {
openDetail(name);
}
} catch (err) { alert(err.message); }
} catch (err) { toast(err.message, 'error'); }
});
// ---------- SSE 实时事件 ----------
@@ -106,9 +206,11 @@ function connect() {
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.msg}`);
if (current === msg.repo && !$('#tab-log').hidden) appendLogLine(`[${msg.time}] [${msg.level || 'info'}] ${msg.msg}`);
}
};
}
@@ -156,8 +258,15 @@ async function seedLive() {
const d = await api(`/api/repos/${name}/log?lines=8`);
if (d.lines && d.lines.length) {
d.lines.forEach(t => {
const m = t.match(/^\[(.+?)\] (.*)$/);
appendLiveLine({ time: m ? m[1] : '', repo: name, msg: m ? m[2] : t, level: 'info', seed: true });
// 兼容新格式 [时间] [级别] 消息 与历史格式 [时间] 消息
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 (_) { /* 忽略 */ }
@@ -204,7 +313,9 @@ 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 === 'conflict' && current) loadConflicts(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);
}
@@ -220,6 +331,7 @@ function openDetail(name) {
$('#m-title').textContent = name;
$('#f-name').disabled = true;
$('#btn-delete').hidden = false;
$('#btn-clone').hidden = false;
fillForm(r);
showTab('log');
$('#modal').hidden = false;
@@ -231,6 +343,7 @@ function openCreate() {
$('#m-title').textContent = '新建配置';
$('#f-name').disabled = false;
$('#btn-delete').hidden = true;
$('#btn-clone').hidden = true;
fillForm(null);
showTab('config');
$('#modal').hidden = false;
@@ -245,22 +358,52 @@ $('#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 = '';
// 落盘格式:[时间] [级别] 消息;历史日志无级别前缀时按 info 处理
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 => {
$('#logbox').textContent = d.lines && d.lines.length ? d.lines.join('\n') : '(暂无日志)';
$('#logbox').scrollTop = $('#logbox').scrollHeight;
}).catch(err => { $('#logbox').textContent = '日志加载失败:' + err.message; });
logLines = d.lines && d.lines.length ? d.lines : [];
renderLog();
}).catch(err => { logLines = []; $('#logbox').textContent = '日志加载失败:' + err.message; });
}
function appendLogLine(line) {
const el = $('#logbox');
if (el.textContent === '(暂无日志)') el.textContent = '';
el.textContent += (el.textContent ? '\n' : '') + line;
el.scrollTop = el.scrollHeight;
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');
@@ -306,12 +449,86 @@ function loadConflicts(name) {
list.innerHTML = '<p style="color:var(--muted)">当前没有冲突。</p>';
return;
}
list.innerHTML = files.map(f =>
`<div class="conflict-file"><h4>${esc(f.file)}</h4><pre>${esc(f.diff) || '(无差异内容)'}</pre></div>`
).join('');
list.innerHTML = files.map(f => `
<div class="conflict-file" data-path="${esc(f.file)}">
<div class="cf-head">
<h4 title="${esc(f.file)}">${esc(f.file)}</h4>
<button class="btn btn-sm" data-cf="edit">编辑解决</button>
<button class="btn btn-sm" data-cf="close" hidden>收起</button>
</div>
<div class="cf-edit" hidden>
<textarea class="cf-ta" spellcheck="false" placeholder="加载中…"></textarea>
<div class="cf-actions">
<input class="file-msg cf-msg" placeholder="提交说明(可选)">
<button class="btn btn-sm" data-cf="save">仅保存</button>
<button class="btn btn-sm btn-primary" data-cf="resolve">保存并标记已解决</button>
</div>
<div class="cf-hint">「仅保存」只写回磁盘;「保存并标记已解决」会暂存该文件,并在已无其它冲突文件时结束合并/变基。</div>
</div>
<pre>${esc(f.diff) || '(无差异内容)'}</pre>
</div>`).join('');
}).catch(err => { $('#conflict-list').innerHTML = `<p style="color:var(--red)">${esc(err.message)}</p>`; });
}
// 冲突文件内联编辑:展开编辑区 → 读取原文 → 保存 / 保存并标记已解决
$('#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`, {
@@ -319,15 +536,432 @@ 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', '已放弃本次合并');
});
$('#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)} <a href="#" id="diff-filter-clear" style="color:inherit">✕ 显示全部</a>`;
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 =>
`<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; });
}
// ---------- 差异渲染:统一 / 并排 / 词级 ----------
// 把 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); }
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
? `<span class="fi-status s-${esc(cls)}" title="${esc(STATUS_LABEL[st] || st)}">${esc(st)}</span>`
: '';
return `<div class="file-item" data-path="${esc(f.path)}" title="${esc(f.path)}"><span class="fi-name">${esc(f.path)}</span>${badge}</div>`;
}).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 = '已恢复未保存的草稿 <a href="#" id="draft-discard" style="color:inherit">放弃草稿</a>';
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');
@@ -385,11 +1019,13 @@ function fillForm(r) {
$('#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-cron').value = r ? (r.cron || '') : '';
$('#f-log').value = r ? (r.log_file || '') : '';
previewCron();
}
function readForm() {
@@ -411,6 +1047,7 @@ function readForm() {
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,
cron: $('#f-cron').value.trim(),
log_file: $('#f-log').value.trim(),
@@ -436,7 +1073,7 @@ $('#cfg-form').addEventListener('submit', async (e) => {
}
closeModal();
refresh();
} catch (err) { alert('保存失败:' + err.message); }
} catch (err) { toast('保存失败:' + err.message); }
});
$('#btn-delete').addEventListener('click', async () => {
@@ -446,7 +1083,32 @@ $('#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'); }
});
// ---------- 配置「克隆」按钮(多仓库同构时省得重填) ----------
// 复用 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();
});
// ---------- 顶部按钮 ----------
@@ -454,6 +1116,64 @@ $('#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);
});
// ---------- 初始化 ----------
@@ -466,6 +1186,7 @@ async function refresh() {
} catch (e) { /* 忽略,SSE 会重试 */ }
}
initTheme();
refresh();
connect();
setInterval(refresh, 20000);
+29
View File
@@ -0,0 +1,29 @@
// 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);
}
})();
+202 -99
View File
@@ -1,130 +1,214 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<html lang="zh-CN" data-page-node-id="nRvVfOmQzU2twwSAvqUwfl">
<head data-page-node-id="BuWdwjwKPwwSCpow9JSC3n">
<meta charset="utf-8" data-page-node-id="tfHCUSqYqPzGPxp06Gd1tM">
<meta name="viewport" content="width=device-width, initial-scale=1" data-page-node-id="jb078uU5LM9D2ifPdaUWAq">
<title>Git Sync 控制台</title>
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="/style.css" data-page-node-id="ORLXZi9XzxrEfwCcuWSocY">
<!-- CodeMirror(本地 vendor,离线可用;浅色/深色主题均按类名隔离,可同时加载) -->
<link rel="stylesheet" href="/vendor/cm/codemirror.min.css" data-page-node-id="DEsUZn7j7yjeQYLdcwspqH">
<link rel="stylesheet" href="/vendor/cm/eclipse.css" data-page-node-id="MaPxkDtHnCAcvhei4GQ7iw">
<link rel="stylesheet" href="/vendor/cm/material-darker.css" data-page-node-id="ChKgT48SiWzjCMcWsGLrje">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
</head>
<body>
<header>
<div class="brand"><span class="brand-dot"></span>Git Sync 控制台</div>
<div class="header-actions">
<span id="conn" class="conn">连接中…</span>
<button class="btn" id="btn-sync-all">全部同步</button>
<button class="btn btn-primary" id="btn-new">+ 新建配置</button>
<body data-page-node-id="pZxw2WnQAZycSEktQenh8f">
<header data-page-node-id="XYY4eaCzyeDqRPq55yjUTY">
<div class="brand" data-page-node-id="xYRyIDur3LHCyDOfcx2CSX"><span class="brand-dot" data-page-node-id="GP41MDt2LXjJaYhsp8InyW"></span>Git Sync 控制台</div>
<div class="header-actions" data-page-node-id="XruBG1DkVAqNNsyOl4J41n">
<span id="conn" class="conn" data-page-node-id="aGv9c7D5e6Izpi92PjfxBz">连接中…</span>
<button class="btn btn-icon" id="btn-theme" title="切换深色 / 浅色模式" data-page-node-id="AP0nSHePHnQDDMylbBKMkA">🌙</button>
<button class="btn" id="btn-start-all" title="启动所有仓库的自动同步" data-page-node-id="QThBRVKpwwHv4WTWoGl0XA">全部启动</button>
<button class="btn" id="btn-stop-all" title="暂停所有仓库的自动同步" data-page-node-id="skWzYCenmPbVdRXS3TNxMK">全部暂停</button>
<button class="btn" id="btn-sync-all" data-page-node-id="6ag20ptiBl3F5MUA22HF68">全部同步</button>
<button class="btn btn-primary" id="btn-new" data-page-node-id="c0DooX9PlqcLzk8PGhoZR3">+ 新建配置</button>
</div>
</header>
<main class="layout">
<section class="main-col">
<div id="grid" class="grid"></div>
<div id="empty" class="empty" hidden>
<p>还没有任何仓库配置。</p>
<button class="btn btn-primary" id="btn-empty-new">创建第一个配置</button>
<main class="layout" data-page-node-id="9QmwKKoxuxwUQhg1t97g3S">
<section class="main-col" data-page-node-id="sKKLeDH2PGoGGIHbEhFid9">
<div id="grid" class="grid" data-page-node-id="xoQF8aiwcrnknxwxd80TI1"></div>
<div id="empty" class="empty" hidden data-page-node-id="C97b1VODLutQfNM13MXmAQ">
<p data-page-node-id="5nTKGjtKBDEFtovt25xVkQ">还没有任何仓库配置。</p>
<button class="btn btn-primary" id="btn-empty-new" data-page-node-id="SOs5r2CqrkMlyKyIZCGjkc">创建第一个配置</button>
</div>
</section>
<aside id="live" class="live">
<div class="live-head">
<h2>实时活动</h2>
<span id="live-conn" class="conn">连接中…</span>
<button class="btn btn-sm" id="btn-live-clear" title="清空本面板">清空</button>
<aside id="live" class="live" data-page-node-id="sgJJOcfDk9vPJs2DHcddtg">
<div class="live-head" data-page-node-id="znZimXC9iQsawFUMoA0lCu">
<h2 data-page-node-id="nGI3EfHmGzvRvuwP0oie2v">实时活动</h2>
<span id="live-conn" class="conn" data-page-node-id="Kg4dQkALsqywtSpd37Zz3B">连接中…</span>
<button class="btn btn-sm" id="btn-live-clear" title="清空本面板" data-page-node-id="KmUck5XGik1k7kP9zyfV2B">清空</button>
</div>
<div class="live-hint">所有仓库的同步(手动与自动)实时显示在此,无需点进详情</div>
<div id="live-log" class="live-log"></div>
<div class="live-hint" data-page-node-id="camBOmhyguvcYnuUJmA2E7">所有仓库的同步(手动与自动)实时显示在此,无需点进详情</div>
<div id="live-log" class="live-log" data-page-node-id="VBl8S1HvgHrCW0882nIFt2"></div>
</aside>
</main>
<div id="modal" class="modal" hidden>
<div class="modal-box">
<div class="modal-head">
<h2 id="m-title">详情</h2>
<button class="btn-icon" id="m-close" title="关闭">✕</button>
<div id="modal" class="modal" hidden data-page-node-id="txBcrv6ZrmZhFBqb699zeN">
<div class="modal-box" data-page-node-id="KdrABdnmnNUg8UQdCa7Nqv">
<div class="modal-head" data-page-node-id="QdJ8BjaSvq7DxthECgP16z">
<h2 id="m-title" data-page-node-id="5kDCaJqI0QPGtb0QllYLBm">详情</h2>
<button class="btn-icon" id="m-close" title="关闭" data-page-node-id="cEUcru162HY4bHMBMNTEfI">✕</button>
</div>
<div class="tabs" id="m-tabs">
<button data-tab="log" class="tab active">日志</button>
<button data-tab="history" class="tab">历史</button>
<button data-tab="conflict" class="tab">冲突</button>
<button data-tab="stats" class="tab">统计</button>
<button data-tab="config" class="tab">配置</button>
<div class="tabs" id="m-tabs" data-page-node-id="CuT5FO4bado4BoZKGRXLGA">
<button data-tab="log" class="tab active" data-page-node-id="UirzBTtYe9llMyNeM4Q7cS">日志</button>
<button data-tab="history" class="tab" data-page-node-id="CPGPmAmMmERkIKBuv5Dw20">历史</button>
<button data-tab="diff" class="tab" data-page-node-id="hxChwjabjboAjspxC9VQyT">差异</button>
<button data-tab="file" class="tab" data-page-node-id="TotQXXPG6HhUSfeSm8XHz2">文件</button>
<button data-tab="conflict" class="tab" data-page-node-id="ujZu5YIEdpocOLfXLYhPv8">冲突</button>
<button data-tab="stats" class="tab" data-page-node-id="iIUiptYinU7iEa6VFcmceV">统计</button>
<button data-tab="config" class="tab" data-page-node-id="LRRFTfdian98kzNy3ca7U3">配置</button>
</div>
<div class="tab-body">
<div id="tab-log" class="tab-pane">
<pre id="logbox" class="logbox">加载中…</pre>
<div class="tab-body" data-page-node-id="BrgjnFG38oxkdJfBGHBRIX">
<div id="tab-log" class="tab-pane" data-page-node-id="LeZ5xD39VJIgWzOgMDLmji">
<div class="log-tools" data-page-node-id="VeZ2yA8bXiB5QkTnVxho88">
<label data-page-node-id="mu1MsfnHFce2PblMBUHuBz">级别
<select id="log-level" data-page-node-id="qRmCfGUzlRUbqzPfRd3GMb">
<option value="all" data-page-node-id="MBqUhLyyEFwF5KlLC2KzZq">全部</option>
<option value="error" data-page-node-id="PAJzGYoj7CRpKDjFKGgRF9">仅错误</option>
<option value="warn" data-page-node-id="m58wjzleIydsdSo49RQDUn">仅警告</option>
<option value="info" data-page-node-id="AGIjFdNqVxkFMzLjdbM76q">仅信息</option>
</select>
</label>
<input id="log-search" class="log-search" placeholder="搜索日志内容(关键词)" data-page-node-id="diPbIGZMP5CvkxPAGMsEnR">
<button class="btn btn-sm" id="btn-log-clear" title="清除过滤条件" data-page-node-id="WTCY4f4B9KEUbcx59P7S1K">清除</button>
</div>
<div id="tab-history" class="tab-pane" hidden>
<div id="commit-list" class="commit-list">加载中…</div>
<pre id="logbox" class="logbox" data-page-node-id="xXJ1Cc3SnDycFy75rRr8FY">加载中…</pre>
</div>
<div id="tab-history" class="tab-pane" hidden data-page-node-id="StRYtav5tS7RxMlmHkgMSV">
<div id="commit-list" class="commit-list" data-page-node-id="vLBDjSJ9KvjCVXYgjXFluN">加载中…</div>
</div>
<div id="tab-conflict" class="tab-pane" hidden>
<div class="conflict-actions">
<button class="btn btn-danger" id="btn-abort">放弃合并</button>
<button class="btn btn-primary" id="btn-resolved">我已在本地解决,继续</button>
<div id="tab-diff" class="tab-pane" hidden data-page-node-id="8ZEuDbkKtSFB1H2vNCD76h">
<div class="diff-head" data-page-node-id="pXqbBCb7m3PfpYSKGgGBJX">
<label data-page-node-id="9logygXL6fEhQpwTrZDwHR">范围
<select id="f-diff-scope" data-page-node-id="tjqhjYveDmQDIx4B9g1B58">
<option value="working" data-page-node-id="hmF9tmY9YCefzG7ppCmi7K">工作区(未暂存)</option>
<option value="staged" data-page-node-id="O5a6sN0qQaMZQPE6tGLqhU">暂存区</option>
<option value="all" data-page-node-id="xCr8XjV5khXje91R7F0axX">全部(含已提交未推送)</option>
<option value="remote" data-page-node-id="YWBratpqmpHwx7GuFLa2cC">与远程对比</option>
</select>
</label>
<label data-page-node-id="HM7TUuaIIeWw55c6H6p8et">视图
<select id="f-diff-view" data-page-node-id="pPFl4XiZHHV6OW02F6izDl">
<option value="unified" data-page-node-id="UBjVfLe9r8IoW6n4Br722d">统一</option>
<option value="split" data-page-node-id="52oaT6kkwDaWCwPJgQesYV">并排</option>
</select>
</label>
<label class="check-inline" title="按单词而非整行显示改动" data-page-node-id="Tn8hpGEewmZoD4cEfS4KUV"><input type="checkbox" id="f-diff-word" data-page-node-id="lxIM4byqutKFfAsBwujha6"> 词级</label>
<button class="btn btn-sm" id="btn-diff-refresh" data-page-node-id="3nrxtZxQJZHYXHAE7NR1Qq">刷新</button>
<button class="btn btn-sm" id="btn-diff-copy" data-page-node-id="oNXn145DgiaPDu4Aw4mOxG">复制</button>
<button class="btn btn-sm" id="btn-diff-download" data-page-node-id="ibtJzrmQVfhLYTIWAdCeqI">下载 .patch</button>
<span id="diff-filter" class="diff-filter" hidden data-page-node-id="XcHdGYotv6fgDrzsjgMerW"></span>
</div>
<div id="conflict-list"></div>
<div id="diff-stat" class="diff-stat" data-page-node-id="2hoANSTC9oMHaGMrEQ4HIl"></div>
<pre id="diffbox" class="diffbox" data-page-node-id="19VEbYBBwIIk0rsVl1M5FX">加载中…</pre>
</div>
<div id="tab-stats" class="tab-pane" hidden>
<div id="stat-summary" class="stat-summary"></div>
<canvas id="stat-chart" height="120"></canvas>
<div id="tab-file" class="tab-pane" hidden data-page-node-id="kAFseOkCuTSGBdvb4EmQZd">
<div class="file-head" data-page-node-id="UjmP651Cok2T5VcpTyNEEA">
<button class="btn btn-sm" id="btn-file-refresh" data-page-node-id="RF7EO9SpUEx62Gxu2GDfnJ">刷新文件列表</button>
<button class="btn btn-sm" id="btn-file-diff" title="只看当前文件的差异" data-page-node-id="4EiZjZ2rCfX5ZqD5ACDd7U">对比此文件</button>
<span id="file-current" class="file-current" data-page-node-id="Y29ei6OnYN1W2AWCe6Jueg"></span>
<span id="file-draft" class="file-draft" hidden data-page-node-id="gQRdJvI800DgkQ6h6trjuL"></span>
</div>
<div class="file-body" data-page-node-id="RXmkN5Ep4iegYWG3iWBiON">
<div id="file-list" class="file-list" data-page-node-id="F75SFyDQShim9rZFPnjuJC">加载中…</div>
<div class="file-edit-col" data-page-node-id="xh1qou2SVboDfvKTLcXFEA">
<textarea id="file-editor" class="file-editor" placeholder="选择左侧文件进行编辑(Ctrl/Cmd + S 保存)" spellcheck="false"></textarea>
<div class="file-actions" data-page-node-id="GAMAnkYKeMi93EVEx8eYOn">
<input id="file-msg" class="file-msg" placeholder="提交说明(可选)" data-page-node-id="aS2jRAr1kAkqElk1T4Cldz">
<button class="btn btn-sm" id="btn-file-save" data-page-node-id="gaVGyD6YkFFElkXJFBtLk5">保存</button>
<button class="btn btn-sm btn-primary" id="btn-file-commit" data-page-node-id="1VDju8pdiboyM0zBgwff21">保存并提交</button>
</div>
</div>
</div>
</div>
<div id="tab-config" class="tab-pane" hidden>
<form id="cfg-form" autocomplete="off">
<div class="form-grid">
<label>配置名称
<input name="name" id="f-name" required pattern="[A-Za-z0-9_\-]+" title="仅字母、数字、下划线、连字符">
<div id="tab-conflict" class="tab-pane" hidden data-page-node-id="EFoL9Iv3JY4DBXK2ovqVl3">
<div class="manual-actions" data-page-node-id="OSsThrKzK22Q1tIcZ0a2bu">
<button class="btn btn-sm" id="btn-fetch" data-page-node-id="JEWw9Jx1x6PWh5EzLd7Bp9">获取</button>
<button class="btn btn-sm" id="btn-pull" data-page-node-id="D3F7c7ZkTj2GaQqrBtshfW">拉取</button>
<button class="btn btn-sm" id="btn-push" data-page-node-id="qTA9vsiTSuWz4dwilu7Prf">推送</button>
</div>
<div class="conflict-actions" id="pull-conflict-actions" data-page-node-id="u1mYEBVApFghZDqR2RFg7V">
<button class="btn btn-danger" id="btn-abort" data-page-node-id="iFRU44FOA3NXieww4PmbAR">放弃合并</button>
<button class="btn btn-primary" id="btn-resolved" data-page-node-id="C4SMNurpXaOJ8jByGjS2zg">我已在本地解决,继续</button>
</div>
<div class="conflict-actions" id="push-conflict-actions" hidden data-page-node-id="C2kukXRbyT5HAgGznQBEOF">
<span class="hint" id="push-conflict-hint" data-page-node-id="meqqutN8Y8BlHoIUOsDuc3"></span>
<button class="btn btn-danger" id="btn-force-push" data-page-node-id="20FtTVRLlB3HrObTPUgS7I">强制推送(--force-with-lease)</button>
<button class="btn" id="btn-abort-push" data-page-node-id="kr5vGtshzwTYewuRpjenau">放弃强制推送</button>
</div>
<div id="conflict-list" data-page-node-id="5cossjGefHTRs2BQmv7ZiE"></div>
</div>
<div id="tab-stats" class="tab-pane" hidden data-page-node-id="AmEsRLuCU7oz0ILjoFOSan">
<div id="stat-summary" class="stat-summary" data-page-node-id="B3x3zXCqPMHxf9Sbo22JsT"></div>
<canvas id="stat-chart" height="120" data-page-node-id="IzH3GbmZh2ogX04vsMxXz5"></canvas>
</div>
<div id="tab-config" class="tab-pane" hidden data-page-node-id="Q0lO9KCy3xEzAkMXn5oQoc">
<form id="cfg-form" autocomplete="off" data-page-node-id="02yHvyuCTFoEYiXQz2Dvv5">
<div class="form-grid" data-page-node-id="SXw7lpnaPt4PKbbHGpCIJT">
<label data-page-node-id="qyzGnJE1Gt2AFnpJFtmmWe">配置名称
<input name="name" id="f-name" required pattern="[A-Za-z0-9_\-]+" title="仅字母、数字、下划线、连字符" data-page-node-id="NF2A817ZBdsM4puREMglkE">
</label>
<label>分支
<input name="branch" id="f-branch" value="main">
<label data-page-node-id="QC3jvGyY6uBpIUe0dtLRyy">分支
<input name="branch" id="f-branch" value="main" data-page-node-id="uIDYoqF9rtqJ0kqijuojOd">
</label>
<label class="full">仓库路径
<input name="repo_path" id="f-path" placeholder="C:\path\to\repo 或 /c/path/to/repo" required>
<label class="full" data-page-node-id="AEwFVqzONKMsh1CjmIWTfB">仓库路径
<input name="repo_path" id="f-path" placeholder="C:\path\to\repo 或 /c/path/to/repo" required data-page-node-id="V8Tqs5XjcAN6ezkffD7A0A">
</label>
<label class="full">远程仓库(每行一个,格式 名称=URL)
<label class="full" data-page-node-id="SZ5RWmnnD0vgIfUoyNcCAP">远程仓库(每行一个,格式 名称=URL)
<textarea name="remotes" id="f-remotes" rows="3" placeholder="origin=https://github.com/user/repo.git"></textarea>
</label>
<label>操作模式
<select name="mode" id="f-mode">
<option value="both">both(拉取并推送)</option>
<option value="push">push(仅推送)</option>
<option value="pull">pull(仅拉取)</option>
<label data-page-node-id="QUBANIDAtqHXOLlp2iHfUw">操作模式
<select name="mode" id="f-mode" data-page-node-id="PYGTzl4KFnOy5QLTnl9bil">
<option value="both" data-page-node-id="4uWdIiNUcKH1klnQC7Yyfk">both(拉取并推送)</option>
<option value="push" data-page-node-id="Fx5PMaVHG9UGFzmyKtNoVC">push(仅推送)</option>
<option value="pull" data-page-node-id="g0oRcyHrhBbgOpG3htuVkZ">pull(仅拉取)</option>
</select>
</label>
<label>检查间隔(秒)
<input name="interval" id="f-interval" type="number" min="5" value="60">
<label data-page-node-id="SR3Eyg3vhv2DEOD4ux6HbU">检查间隔(秒)
<input name="interval" id="f-interval" type="number" min="5" value="60" data-page-node-id="t4WQ8TOUFADFvrxWDqDLOO">
</label>
<label>认证方式
<select name="auth_method" id="f-auth">
<option value="ssh">SSH</option>
<option value="https">HTTPS(支持重试)</option>
<label data-page-node-id="pVjnoOnEBz06wQUDFuGnSG">认证方式
<select name="auth_method" id="f-auth" data-page-node-id="fsjBIeHacR0Y68FPjYrlIE">
<option value="ssh" data-page-node-id="D1rhiol1YcEJjSQptztATh">SSH</option>
<option value="https" data-page-node-id="SC5nYAdz2JBiHIks7TFSaN">HTTPS(支持重试)</option>
</select>
</label>
<label>冲突策略
<select name="conflict_strategy" id="f-conflict">
<option value="merge">merge(冲突后等待人工解决)</option>
<option value="rebase">rebase(变基)</option>
<option value="abort">abort(冲突后自动放弃)</option>
<label data-page-node-id="HzJHn4COhFnLGwn6aIiBKP">冲突策略
<select name="conflict_strategy" id="f-conflict" data-page-node-id="tJK7z7t0ojAK0xkHaM80nd">
<option value="merge" data-page-node-id="BdiprVQi0oHS9aTDD4LQ2l">merge(冲突后等待人工解决)</option>
<option value="rebase" data-page-node-id="jCngOAxfELckp4A3mrk1qI">rebase(变基)</option>
<option value="abort" data-page-node-id="aGj68eybADJcGx5G1g85UB">abort(冲突后自动放弃)</option>
</select>
</label>
<label>重试次数(HTTPS)
<input name="retry_count" id="f-retry" type="number" min="0" value="3">
<label data-page-node-id="gwR8fc4feWQMTOGVirMjCO">推送策略(non-fast-forward)
<select name="push_strategy" id="f-push" data-page-node-id="HCS9CZyBErr1VeYSJxKStW">
<option value="manual" data-page-node-id="yurv3dbmJx7LKFDzzvzIUP">manual(冲突后等待人工决策)</option>
<option value="rebase" data-page-node-id="E3ifgThEynHNJhBnLn0a88">rebase(变基到远程后重试)</option>
<option value="force" data-page-node-id="lhFHAEDyn49ABT02QDys8o">force(--force-with-lease 强制推送)</option>
</select>
</label>
<label>重试间隔(秒,HTTPS)
<input name="retry_interval" id="f-retry-interval" type="number" min="0" value="5">
<label data-page-node-id="Nv2HjS10oANWojhUmKVQE4">重试次数(HTTPS)
<input name="retry_count" id="f-retry" type="number" min="0" value="3" data-page-node-id="UEgQOQBxHxqIfR5kYyZctp">
</label>
<label class="full check-row">
<input type="checkbox" id="f-autocommit">
<span>自动提交模式:检测到未提交改动时自动 commit,并随同步推送到远程</span>
<label data-page-node-id="jNiYqEK7BpWhx0mg0CvdDY">重试间隔(秒,HTTPS)
<input name="retry_interval" id="f-retry-interval" type="number" min="0" value="5" data-page-node-id="sFEylqamHk8vW9H2D0alce">
</label>
<label class="full">Cron 表达式(可选,5 段:分 时 日 月 周;填写后优先于固定间隔)
<input name="cron" id="f-cron" placeholder="*/30 * * * * 表示每 30 分钟,留空使用固定间隔">
<label class="full check-row" data-page-node-id="gWPFS7fXyat6FiuobDoynQ">
<input type="checkbox" id="f-autocommit" data-page-node-id="5ADMjJXNR5EGPDmhwiCBlm">
<span data-page-node-id="z0tpoI6jpAbJCEwUJ4cSxO">自动提交模式:检测到未提交改动时自动 commit,并随同步推送到远程</span>
</label>
<label class="full">日志文件路径(留空使用默认 logs/配置名.log)
<input name="log_file" id="f-log">
<label class="full" data-page-node-id="tei066TFL8N9sqInjysFvD">Cron 表达式(可选,5 段:分 时 日 月 周;填写后优先于固定间隔)
<input name="cron" id="f-cron" placeholder="*/30 * * * * 表示每 30 分钟,留空使用固定间隔" data-page-node-id="6W3Ua8pPoGLuaNh7rGcDOw">
<span id="cron-next" class="cron-next" data-page-node-id="WgXuBnZPqGWvOBGRxzIVCn"></span>
</label>
<label class="full" data-page-node-id="fsjZ6wLid5NUBV0yAHKdII">日志文件路径(留空使用默认 logs/配置名.log)
<input name="log_file" id="f-log" data-page-node-id="KJpv7jEWYnOQZ1PtQMetfP">
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存</button>
<button type="button" class="btn btn-danger" id="btn-delete" hidden>删除配置</button>
<div class="form-actions" data-page-node-id="1GfIJLKdxSyPwBqpqCCMb9">
<button type="submit" class="btn btn-primary" data-page-node-id="E2123htNE6lmoNzZTg7AwV">保存</button>
<button type="button" class="btn" id="btn-clone" hidden data-page-node-id="H6BdDiraff2cjD9EWjF8Eh">克隆此配置</button>
<button type="button" class="btn btn-danger" id="btn-delete" hidden data-page-node-id="iyYF2JZ87tE2mGPCkbHX5w">删除配置</button>
</div>
</form>
</div>
@@ -132,23 +216,42 @@
</div>
</div>
<div id="login" class="modal" hidden>
<div class="modal-box login-box">
<div class="modal-head">
<h2>Git Sync 控制台</h2>
<div id="login" class="modal" hidden data-page-node-id="bALcA8zunmLBixpK2bI55B">
<div class="modal-box login-box" data-page-node-id="kcGN8ZqtOQx7KUIgGpoGCN">
<div class="modal-head" data-page-node-id="TPAEpzey0DyD3tbzQcX6AD">
<h2 data-page-node-id="SxFYrLjYGUJGks4LhmWFGa">Git Sync 控制台</h2>
</div>
<div class="login-body">
<label>访问密码
<input type="password" id="login-password">
<div class="login-body" data-page-node-id="loM04avwKu3ehaXbaPV1dc">
<label data-page-node-id="PQlrAv5lABcwv1V9A2kAZf">访问密码
<input type="password" id="login-password" data-page-node-id="BQQm1tLuMFSt6HhCgtUSzi">
</label>
<p id="login-err" class="login-err" hidden></p>
<div class="form-actions">
<button class="btn btn-primary" id="btn-login">登录</button>
<p id="login-err" class="login-err" hidden data-page-node-id="c5KlyY0GNpRJlp2ZCDLC2P"></p>
<div class="form-actions" data-page-node-id="dKo7nNYzQBz8MQZa7x51Bf">
<button class="btn btn-primary" id="btn-login" data-page-node-id="EOo505vSrX2QIGGOnpcDxp">登录</button>
</div>
</div>
</div>
</div>
<!-- Toast 轻提示容器(替代原生 alert,固定在右下角,不打断操作) -->
<div id="toasts" class="toasts" data-page-node-id="sGCNypYh5YHNDe7nZ7TUeA"></div>
<script src="/app.js"></script>
<!-- CodeMirror:本地 vendor,必须在 app.js 之后加载以便降级检测 -->
<script src="/vendor/cm/codemirror.min.js"></script>
<script src="/vendor/cm/mode/javascript.min.js"></script>
<script src="/vendor/cm/mode/xml.min.js"></script>
<script src="/vendor/cm/mode/css.min.js"></script>
<script src="/vendor/cm/mode/htmlmixed.min.js"></script>
<script src="/vendor/cm/mode/go.min.js"></script>
<script src="/vendor/cm/mode/markdown.min.js"></script>
<script src="/vendor/cm/mode/shell.min.js"></script>
<script src="/vendor/cm/mode/python.min.js"></script>
<script src="/vendor/cm/mode/clike.min.js"></script>
<script src="/vendor/cm/mode/yaml.min.js"></script>
<script src="/vendor/cm/mode/sql.min.js"></script>
<script src="/vendor/cm/mode/properties.min.js"></script>
<script src="/vendor/cm/mode/diff.min.js"></script>
<script src="/cm-boot.js"></script>
</body>
</html>
+128
View File
@@ -121,3 +121,131 @@ textarea { font-family: Consolas, monospace; resize: vertical; }
.form-actions { display: flex; gap: 8px; margin-top: 16px; }
@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; }
}
/* ---------- 暗色模式 ---------- */
html.dark {
--bg: #16181c; --card: #1e2127; --border: #30363d; --text: #d8dee6; --muted: #8b949e;
--blue: #58a6ff; --green: #7ee787; --green-bg: #12261a; --red: #ff7b72; --red-bg: #2d1618;
--amber: #e3b341; --amber-bg: #2b2313; --gray-bg: #24272e;
}
html.dark header { background: var(--card); }
html.dark .btn { background: var(--card); }
html.dark .btn:hover { background: var(--gray-bg); border-color: #4b535d; }
html.dark .btn-sm { background: var(--card); color: var(--text); }
html.dark .btn-danger { background: var(--card); }
html.dark .btn-primary { background: var(--blue); border-color: var(--blue); color: #0d1117; }
html.dark .btn-primary:hover { background: #3d8bfd; border-color: #3d8bfd; }
html.dark .logbox,
html.dark .conflict-file pre,
html.dark .commit-diff,
html.dark .diffbox,
html.dark .file-editor:disabled { background: #12151a; }
html.dark .commit-head:hover { background: var(--gray-bg); }
html.dark .diff-line.diff-hunk { background: #17242f; }
html.dark input, html.dark select, html.dark textarea { background: #12151a; }
html.dark .file-list { background: var(--card); }
html.dark .file-item:hover { background: var(--gray-bg); }
html.dark .file-editor { background: #12151a; }
/* ---------- 日志过滤 ---------- */
.log-tools { display: flex; gap: 10px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
.log-tools label { font-size: 12px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
.log-search { flex: 1; min-width: 140px; }
/* ---------- 差异:单文件过滤提示 ---------- */
.diff-filter { font-size: 12px; color: var(--amber); background: var(--amber-bg); border-radius: 6px; padding: 2px 8px; }
.diff-filter[hidden] { display: none; }
/* ---------- 文件编辑:草稿提示与状态徽标 ---------- */
.file-draft { font-size: 12px; color: var(--amber); background: var(--amber-bg); border-radius: 6px; padding: 2px 8px; }
.file-draft[hidden] { display: none; }
.file-item { display: flex; align-items: center; gap: 6px; }
.file-item .fi-name { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.fi-status { flex: none; font-size: 11px; padding: 0 5px; border-radius: 4px; background: var(--gray-bg); color: var(--muted); }
.fi-status.s-M { background: var(--amber-bg); color: var(--amber); }
.fi-status.s-A { background: var(--green-bg); color: var(--green); }
.fi-status.s-D { background: var(--red-bg); color: var(--red); }
.fi-status.s-Q { background: var(--gray-bg); color: var(--muted); }
/* ---------- 卡片:久未同步 ---------- */
.badge.b-stale { background: var(--amber-bg); color: var(--amber); }
/* ---------- 配置:cron 下次执行预览 ---------- */
.cron-next { font-size: 12px; color: var(--muted); min-height: 16px; }
.cron-next.bad { color: var(--red); }
.cron-next.good { color: var(--green); }
/* ---------- Toast 轻提示(替代 alert) ---------- */
.toasts { position: fixed; right: 20px; bottom: 20px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; pointer-events: none; }
.toast { pointer-events: auto; cursor: pointer; max-width: 380px; padding: 10px 14px; border-radius: 8px; font-size: 13px; color: #fff; background: #333; box-shadow: 0 6px 18px rgba(0, 0, 0, .18); animation: toast-in .22s ease-out; }
.toast.t-info { background: #2f6feb; }
.toast.t-success { background: #2da44e; }
.toast.t-warn { background: #bf8700; }
.toast.t-error { background: #cf222e; }
.toast.out { opacity: 0; transform: translateY(6px); transition: all .3s; }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* ---------- 差异:并排视图 ---------- */
.diff-file-head { font: 12px Consolas, monospace; color: var(--muted); padding: 4px 6px; background: var(--gray-bg); border-radius: 6px; margin: 8px 0 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.srow { display: flex; border-top: 1px solid var(--border); }
.srow .scol { flex: 1; min-width: 0; display: flex; gap: 8px; padding: 0 6px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; }
.srow .sl { border-right: 1px solid var(--border); }
.srow .ln { flex: none; width: 44px; text-align: right; color: var(--muted); user-select: none; opacity: .75; }
.srow .stxt { flex: 1; }
.srow.s-del .sl { background: var(--red-bg); color: var(--red); }
.srow.s-add .sr { background: var(--green-bg); color: var(--green); }
.srow.s-chg .sl { background: var(--red-bg); color: var(--red); }
.srow.s-chg .sr { background: var(--green-bg); color: var(--green); }
/* ---------- 差异:词级高亮 ---------- */
.wd-add { background: var(--green-bg); color: var(--green); border-radius: 3px; }
.wd-del { background: var(--red-bg); color: var(--red); border-radius: 3px; text-decoration: line-through; }
.diff-head label.check-inline { flex-direction: row; align-items: center; gap: 4px; }
.diff-head label.check-inline input { width: auto; margin: 0; }
/* ---------- CodeMirror 适配 ---------- */
.CodeMirror { height: 420px; border: 1px solid var(--border); border-radius: 8px; font: 12.5px/1.55 Consolas, monospace; }
.CodeMirror-scroll { min-height: 380px; }
/* ---------- 冲突文件内联编辑 ---------- */
.cf-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
.cf-head h4 { margin: 0; flex: 1; }
.cf-ta { width: 100%; min-height: 260px; 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; }
html.dark .cf-ta { background: #12151a; }
.cf-actions { display: flex; gap: 8px; margin-top: 6px; }
.cf-hint { font-size: 12px; color: var(--muted); margin-top: 6px; line-height: 1.5; }
.cf-edit { margin-bottom: 10px; }
.conflict-file { border: 1px solid var(--border); border-radius: 8px; padding: 10px; margin-bottom: 10px; }
.conflict-file pre { margin: 0; }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+23
View File
@@ -0,0 +1,23 @@
.cm-s-eclipse span.cm-meta { color: #FF1717; }
.cm-s-eclipse span.cm-keyword { line-height: 1em; font-weight: bold; color: #7F0055; }
.cm-s-eclipse span.cm-atom { color: #219; }
.cm-s-eclipse span.cm-number { color: #164; }
.cm-s-eclipse span.cm-def { color: #00f; }
.cm-s-eclipse span.cm-variable { color: black; }
.cm-s-eclipse span.cm-variable-2 { color: #0000C0; }
.cm-s-eclipse span.cm-variable-3, .cm-s-eclipse span.cm-type { color: #0000C0; }
.cm-s-eclipse span.cm-property { color: black; }
.cm-s-eclipse span.cm-operator { color: black; }
.cm-s-eclipse span.cm-comment { color: #3F7F5F; }
.cm-s-eclipse span.cm-string { color: #2A00FF; }
.cm-s-eclipse span.cm-string-2 { color: #f50; }
.cm-s-eclipse span.cm-qualifier { color: #555; }
.cm-s-eclipse span.cm-builtin { color: #30a; }
.cm-s-eclipse span.cm-bracket { color: #cc7; }
.cm-s-eclipse span.cm-tag { color: #170; }
.cm-s-eclipse span.cm-attribute { color: #00c; }
.cm-s-eclipse span.cm-link { color: #219; }
.cm-s-eclipse span.cm-error { color: #f00; }
.cm-s-eclipse .CodeMirror-activeline-background { background: #e8f2ff; }
.cm-s-eclipse .CodeMirror-matchingbracket { outline:1px solid grey; color:black !important; }
+135
View File
@@ -0,0 +1,135 @@
/*
Name: material
Author: Mattia Astorino (http://github.com/equinusocio)
Website: https://material-theme.site/
*/
.cm-s-material-darker.CodeMirror {
background-color: #212121;
color: #EEFFFF;
}
.cm-s-material-darker .CodeMirror-gutters {
background: #212121;
color: #545454;
border: none;
}
.cm-s-material-darker .CodeMirror-guttermarker,
.cm-s-material-darker .CodeMirror-guttermarker-subtle,
.cm-s-material-darker .CodeMirror-linenumber {
color: #545454;
}
.cm-s-material-darker .CodeMirror-cursor {
border-left: 1px solid #FFCC00;
}
.cm-s-material-darker div.CodeMirror-selected {
background: rgba(97, 97, 97, 0.2);
}
.cm-s-material-darker.CodeMirror-focused div.CodeMirror-selected {
background: rgba(97, 97, 97, 0.2);
}
.cm-s-material-darker .CodeMirror-line::selection,
.cm-s-material-darker .CodeMirror-line>span::selection,
.cm-s-material-darker .CodeMirror-line>span>span::selection {
background: rgba(128, 203, 196, 0.2);
}
.cm-s-material-darker .CodeMirror-line::-moz-selection,
.cm-s-material-darker .CodeMirror-line>span::-moz-selection,
.cm-s-material-darker .CodeMirror-line>span>span::-moz-selection {
background: rgba(128, 203, 196, 0.2);
}
.cm-s-material-darker .CodeMirror-activeline-background {
background: rgba(0, 0, 0, 0.5);
}
.cm-s-material-darker .cm-keyword {
color: #C792EA;
}
.cm-s-material-darker .cm-operator {
color: #89DDFF;
}
.cm-s-material-darker .cm-variable-2 {
color: #EEFFFF;
}
.cm-s-material-darker .cm-variable-3,
.cm-s-material-darker .cm-type {
color: #f07178;
}
.cm-s-material-darker .cm-builtin {
color: #FFCB6B;
}
.cm-s-material-darker .cm-atom {
color: #F78C6C;
}
.cm-s-material-darker .cm-number {
color: #FF5370;
}
.cm-s-material-darker .cm-def {
color: #82AAFF;
}
.cm-s-material-darker .cm-string {
color: #C3E88D;
}
.cm-s-material-darker .cm-string-2 {
color: #f07178;
}
.cm-s-material-darker .cm-comment {
color: #545454;
}
.cm-s-material-darker .cm-variable {
color: #f07178;
}
.cm-s-material-darker .cm-tag {
color: #FF5370;
}
.cm-s-material-darker .cm-meta {
color: #FFCB6B;
}
.cm-s-material-darker .cm-attribute {
color: #C792EA;
}
.cm-s-material-darker .cm-property {
color: #C792EA;
}
.cm-s-material-darker .cm-qualifier {
color: #DECB6B;
}
.cm-s-material-darker .cm-variable-3,
.cm-s-material-darker .cm-type {
color: #DECB6B;
}
.cm-s-material-darker .cm-error {
color: rgba(255, 255, 255, 1.0);
background-color: #FF5370;
}
.cm-s-material-darker .CodeMirror-matchingbracket {
text-decoration: underline;
color: white !important;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using Terser v5.39.0.
* Original file: /npm/[email protected]/mode/diff/diff.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}((function(e){"use strict";e.defineMode("diff",(function(){var e={"+":"positive","-":"negative","@":"meta"};return{token:function(r){var i=r.string.search(/[\t ]+?$/);if(!r.sol()||0===i)return r.skipToEnd(),("error "+(e[r.string.charAt(0)]||"")).replace(/ $/,"");var o=e[r.peek()]||r.skipToEnd();return-1===i?r.skipToEnd():r.pos=i,o}}})),e.defineMIME("text/x-diff","diff")}));
//# sourceMappingURL=/sm/8d9c85fa00786d92f88ffec12c7faecc40483fd8269ff974fc6a9ac45ff24a83.map
+8
View File
@@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using Terser v5.39.0.
* Original file: /npm/[email protected]/mode/go/go.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}((function(e){"use strict";e.defineMode("go",(function(t){var n,r=t.indentUnit,i={break:!0,case:!0,chan:!0,const:!0,continue:!0,default:!0,defer:!0,else:!0,fallthrough:!0,for:!0,func:!0,go:!0,goto:!0,if:!0,import:!0,interface:!0,map:!0,package:!0,range:!0,return:!0,select:!0,struct:!0,switch:!0,type:!0,var:!0,bool:!0,byte:!0,complex64:!0,complex128:!0,float32:!0,float64:!0,int8:!0,int16:!0,int32:!0,int64:!0,string:!0,uint8:!0,uint16:!0,uint32:!0,uint64:!0,int:!0,uint:!0,uintptr:!0,error:!0,rune:!0,any:!0,comparable:!0},o={true:!0,false:!0,iota:!0,nil:!0,append:!0,cap:!0,close:!0,complex:!0,copy:!0,delete:!0,imag:!0,len:!0,make:!0,new:!0,panic:!0,print:!0,println:!0,real:!0,recover:!0},a=/[+\-*&^%:=<>!|\/]/;function c(e,t){var r,l=e.next();if('"'==l||"'"==l||"`"==l)return t.tokenize=(r=l,function(e,t){for(var n,i=!1,o=!1;null!=(n=e.next());){if(n==r&&!i){o=!0;break}i=!i&&"`"!=r&&"\\"==n}return(o||!i&&"`"!=r)&&(t.tokenize=c),"string"}),t.tokenize(e,t);if(/[\d\.]/.test(l))return"."==l?e.match(/^[0-9_]+([eE][\-+]?[0-9_]+)?/):"0"==l?e.match(/^[xX][0-9a-fA-F_]+/)||e.match(/^[0-7_]+/):e.match(/^[0-9_]*\.?[0-9_]*([eE][\-+]?[0-9_]+)?/),"number";if(/[\[\]{}\(\),;\:\.]/.test(l))return n=l,null;if("/"==l){if(e.eat("*"))return t.tokenize=u,u(e,t);if(e.eat("/"))return e.skipToEnd(),"comment"}if(a.test(l))return e.eatWhile(a),"operator";e.eatWhile(/[\w\$_\xa1-\uffff]/);var f=e.current();return i.propertyIsEnumerable(f)?("case"!=f&&"default"!=f||(n="case"),"keyword"):o.propertyIsEnumerable(f)?"atom":"variable"}function u(e,t){for(var n,r=!1;n=e.next();){if("/"==n&&r){t.tokenize=c;break}r="*"==n}return"comment"}function l(e,t,n,r,i){this.indented=e,this.column=t,this.type=n,this.align=r,this.prev=i}function f(e,t,n){return e.context=new l(e.indented,t,n,null,e.context)}function s(e){if(e.context.prev){var t=e.context.type;return")"!=t&&"]"!=t&&"}"!=t||(e.indented=e.context.indented),e.context=e.context.prev}}return{startState:function(e){return{tokenize:null,context:new l((e||0)-r,0,"top",!1),indented:0,startOfLine:!0}},token:function(e,t){var r=t.context;if(e.sol()&&(null==r.align&&(r.align=!1),t.indented=e.indentation(),t.startOfLine=!0,"case"==r.type&&(r.type="}")),e.eatSpace())return null;n=null;var i=(t.tokenize||c)(e,t);return"comment"==i||(null==r.align&&(r.align=!0),"{"==n?f(t,e.column(),"}"):"["==n?f(t,e.column(),"]"):"("==n?f(t,e.column(),")"):"case"==n?r.type="case":("}"==n&&"}"==r.type||n==r.type)&&s(t),t.startOfLine=!1),i},indent:function(t,n){if(t.tokenize!=c&&null!=t.tokenize)return e.Pass;var i=t.context,o=n&&n.charAt(0);if("case"==i.type&&/^(?:case|default)\b/.test(n))return t.context.type="}",i.indented;var a=o==i.type;return i.align?i.column+(a?0:1):i.indented+(a?0:r)},electricChars:"{}):",closeBrackets:"()[]{}''\"\"``",fold:"brace",blockCommentStart:"/*",blockCommentEnd:"*/",lineComment:"//"}})),e.defineMIME("text/x-go","go")}));
//# sourceMappingURL=/sm/afb31834b17dab66dd28a8c66be3854cfcbeada5f859d586a76e7e1dae598035.map
+8
View File
@@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using Terser v5.37.0.
* Original file: /npm/[email protected]/mode/htmlmixed/htmlmixed.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror"),require("../xml/xml"),require("../javascript/javascript"),require("../css/css")):"function"==typeof define&&define.amd?define(["../../lib/codemirror","../xml/xml","../javascript/javascript","../css/css"],t):t(CodeMirror)}((function(t){"use strict";var e={script:[["lang",/(javascript|babel)/i,"javascript"],["type",/^(?:text|application)\/(?:x-)?(?:java|ecma)script$|^module$|^$/i,"javascript"],["type",/./,"text/plain"],[null,null,"javascript"]],style:[["lang",/^css$/i,"css"],["type",/^(text\/)?(x-)?(stylesheet|css)$/i,"css"],["type",/./,"text/plain"],[null,null,"css"]]};var a={};function n(t,e){var n=t.match(function(t){var e=a[t];return e||(a[t]=new RegExp("\\s+"+t+"\\s*=\\s*('|\")?([^'\"]+)('|\")?\\s*"))}(e));return n?/^\s*(.*?)\s*$/.exec(n[2])[1]:""}function l(t,e){return new RegExp((e?"^":"")+"</\\s*"+t+"\\s*>","i")}function r(t,e){for(var a in t)for(var n=e[a]||(e[a]=[]),l=t[a],r=l.length-1;r>=0;r--)n.unshift(l[r])}t.defineMode("htmlmixed",(function(a,o){var i=t.getMode(a,{name:"xml",htmlMode:!0,multilineTagIndentFactor:o.multilineTagIndentFactor,multilineTagIndentPastTag:o.multilineTagIndentPastTag,allowMissingTagName:o.allowMissingTagName}),c={},s=o&&o.tags,u=o&&o.scriptTypes;if(r(e,c),s&&r(s,c),u)for(var m=u.length-1;m>=0;m--)c.script.unshift(["type",u[m].matches,u[m].mode]);function d(e,r){var o,s=i.token(e,r.htmlState),u=/\btag\b/.test(s);if(u&&!/[<>\s\/]/.test(e.current())&&(o=r.htmlState.tagName&&r.htmlState.tagName.toLowerCase())&&c.hasOwnProperty(o))r.inTag=o+" ";else if(r.inTag&&u&&/>$/.test(e.current())){var m=/^([\S]+) (.*)/.exec(r.inTag);r.inTag=null;var f=">"==e.current()&&function(t,e){for(var a=0;a<t.length;a++){var l=t[a];if(!l[0]||l[1].test(n(e,l[0])))return l[2]}}(c[m[1]],m[2]),g=t.getMode(a,f),p=l(m[1],!0),h=l(m[1],!1);r.token=function(t,e){return t.match(p,!1)?(e.token=d,e.localState=e.localMode=null,null):function(t,e,a){var n=t.current(),l=n.search(e);return l>-1?t.backUp(n.length-l):n.match(/<\/?$/)&&(t.backUp(n.length),t.match(e,!1)||t.match(n)),a}(t,h,e.localMode.token(t,e.localState))},r.localMode=g,r.localState=t.startState(g,i.indent(r.htmlState,"",""))}else r.inTag&&(r.inTag+=e.current(),e.eol()&&(r.inTag+=" "));return s}return{startState:function(){return{token:d,inTag:null,localMode:null,localState:null,htmlState:t.startState(i)}},copyState:function(e){var a;return e.localState&&(a=t.copyState(e.localMode,e.localState)),{token:e.token,inTag:e.inTag,localMode:e.localMode,localState:a,htmlState:t.copyState(i,e.htmlState)}},token:function(t,e){return e.token(t,e)},indent:function(e,a,n){return!e.localMode||/^\s*<\//.test(a)?i.indent(e.htmlState,a,n):e.localMode.indent?e.localMode.indent(e.localState,a,n):t.Pass},innerMode:function(t){return{state:t.localState||t.htmlState,mode:t.localMode||i}}}}),"xml","javascript","css"),t.defineMIME("text/html","htmlmixed")}));
//# sourceMappingURL=/sm/e2c7ccb5e794d8515250b328c4947e850defb14ee2247c63cd3549e4879addb0.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using Terser v5.39.0.
* Original file: /npm/codemirror@5.65.16/mode/properties/properties.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}((function(e){"use strict";e.defineMode("properties",(function(){return{token:function(e,i){var t=e.sol()||i.afterSection,n=e.eol();if(i.afterSection=!1,t&&(i.nextMultiline?(i.inMultiline=!0,i.nextMultiline=!1):i.position="def"),n&&!i.nextMultiline&&(i.inMultiline=!1,i.position="def"),t)for(;e.eatSpace(););var o=e.next();return!t||"#"!==o&&"!"!==o&&";"!==o?t&&"["===o?(i.afterSection=!0,e.skipTo("]"),e.eat("]"),"header"):"="===o||":"===o?(i.position="quote",null):("\\"===o&&"quote"===i.position&&e.eol()&&(i.nextMultiline=!0),i.position):(i.position="comment",e.skipToEnd(),"comment")},startState:function(){return{position:"def",nextMultiline:!1,inMultiline:!1,afterSection:!1}}}})),e.defineMIME("text/x-properties","properties"),e.defineMIME("text/x-ini","properties")}));
//# sourceMappingURL=/sm/d7f5d26900daa33cef63eebc8dc4bdbc427e9b294e0f4cf802b0c5e3334df8f6.map
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using Terser v5.39.0.
* Original file: /npm/codemirror@5.65.16/mode/shell/shell.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}((function(e){"use strict";e.defineMode("shell",(function(){var t={};function n(e,n){for(var r=0;r<n.length;r++)t[n[r]]=e}var r=["true","false"],i=["if","then","do","else","elif","while","until","for","in","esac","fi","fin","fil","done","exit","set","unset","export","function"],o=["ab","awk","bash","beep","cat","cc","cd","chown","chmod","chroot","clear","cp","curl","cut","diff","echo","find","gawk","gcc","get","git","grep","hg","kill","killall","ln","ls","make","mkdir","openssl","mv","nc","nl","node","npm","ping","ps","restart","rm","rmdir","sed","service","sh","shopt","shred","source","sort","sleep","ssh","start","stop","su","sudo","svn","tee","telnet","top","touch","vi","vim","wall","wc","wget","who","write","yes","zsh"];function s(e,n){if(e.eatSpace())return null;var r,i=e.sol(),o=e.next();if("\\"===o)return e.next(),null;if("'"===o||'"'===o||"`"===o)return n.tokens.unshift(u(o,"`"===o?"quote":"string")),c(e,n);if("#"===o)return i&&e.eat("!")?(e.skipToEnd(),"meta"):(e.skipToEnd(),"comment");if("$"===o)return n.tokens.unshift(l),c(e,n);if("+"===o||"="===o)return"operator";if("-"===o)return e.eat("-"),e.eatWhile(/\w/),"attribute";if("<"==o){if(e.match("<<"))return"operator";var s=e.match(/^<-?\s*['"]?([^'"]*)['"]?/);if(s)return n.tokens.unshift((r=s[1],function(e,t){return e.sol()&&e.string==r&&t.tokens.shift(),e.skipToEnd(),"string-2"})),"string-2"}if(/\d/.test(o)&&(e.eatWhile(/\d/),e.eol()||!/\w/.test(e.peek())))return"number";e.eatWhile(/[\w-]/);var f=e.current();return"="===e.peek()&&/\w+/.test(f)?"def":t.hasOwnProperty(f)?t[f]:null}function u(e,t){var n="("==e?")":"{"==e?"}":e;return function(r,i){for(var o,s=!1;null!=(o=r.next());){if(o===n&&!s){i.tokens.shift();break}if("$"===o&&!s&&"'"!==e&&r.peek()!=n){s=!0,r.backUp(1),i.tokens.unshift(l);break}if(!s&&e!==n&&o===e)return i.tokens.unshift(u(e,t)),c(r,i);if(!s&&/['"]/.test(o)&&!/['"]/.test(e)){i.tokens.unshift(f(o,"string")),r.backUp(1);break}s=!s&&"\\"===o}return t}}function f(e,t){return function(n,r){return r.tokens[0]=u(e,t),n.next(),c(n,r)}}e.registerHelper("hintWords","shell",r.concat(i,o)),n("atom",r),n("keyword",i),n("builtin",o);var l=function(e,t){t.tokens.length>1&&e.eat("$");var n=e.next();return/['"({]/.test(n)?(t.tokens[0]=u(n,"("==n?"quote":"{"==n?"def":"string"),c(e,t)):(/\d/.test(n)||e.eatWhile(/\w/),t.tokens.shift(),"def")};function c(e,t){return(t.tokens[0]||s)(e,t)}return{startState:function(){return{tokens:[]}},token:function(e,t){return c(e,t)},closeBrackets:"()[]{}''\"\"``",lineComment:"#",fold:"brace"}})),e.defineMIME("text/x-sh","shell"),e.defineMIME("application/x-sh","shell")}));
//# sourceMappingURL=/sm/6326f7536a83bb931d36bf06f21e2f1cde4cdedfae18cc24abc8472357b9ecf0.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using Terser v5.39.0.
* Original file: /npm/codemirror@5.65.16/mode/yaml/yaml.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}((function(e){"use strict";e.defineMode("yaml",(function(){var e=new RegExp("\\b(("+["true","false","on","off","yes","no"].join(")|(")+"))$","i");return{token:function(i,t){var r=i.peek(),n=t.escaped;if(t.escaped=!1,"#"==r&&(0==i.pos||/\s/.test(i.string.charAt(i.pos-1))))return i.skipToEnd(),"comment";if(i.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/))return"string";if(t.literal&&i.indentation()>t.keyCol)return i.skipToEnd(),"string";if(t.literal&&(t.literal=!1),i.sol()){if(t.keyCol=0,t.pair=!1,t.pairStart=!1,i.match("---"))return"def";if(i.match("..."))return"def";if(i.match(/\s*-\s+/))return"meta"}if(i.match(/^(\{|\}|\[|\])/))return"{"==r?t.inlinePairs++:"}"==r?t.inlinePairs--:"["==r?t.inlineList++:t.inlineList--,"meta";if(t.inlineList>0&&!n&&","==r)return i.next(),"meta";if(t.inlinePairs>0&&!n&&","==r)return t.keyCol=0,t.pair=!1,t.pairStart=!1,i.next(),"meta";if(t.pairStart){if(i.match(/^\s*(\||\>)\s*/))return t.literal=!0,"meta";if(i.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i))return"variable-2";if(0==t.inlinePairs&&i.match(/^\s*-?[0-9\.\,]+\s?$/))return"number";if(t.inlinePairs>0&&i.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/))return"number";if(i.match(e))return"keyword"}return!t.pair&&i.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^\s,\[\]{}#&*!|>'"%@`])[^#:]*(?=:($|\s))/)?(t.pair=!0,t.keyCol=i.indentation(),"atom"):t.pair&&i.match(/^:\s*/)?(t.pairStart=!0,"meta"):(t.pairStart=!1,t.escaped="\\"==r,i.next(),null)},startState:function(){return{pair:!1,pairStart:!1,keyCol:0,inlinePairs:0,inlineList:0,literal:!1,escaped:!1}},lineComment:"#",fold:"indent"}})),e.defineMIME("text/x-yaml","yaml"),e.defineMIME("text/yaml","yaml")}));
//# sourceMappingURL=/sm/bc6554c36ed3032264accb3f8afb06468bccf6317bd6643e836095b9a02eabf3.map