git-sync-web 自动提交 2026-09-09 16:33:32
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
BINARY := git-auto.exe
|
||||||
|
|
||||||
|
# 关闭 VCS 标记:绕过本地 .git 对象库不持久导致的 build 报错
|
||||||
|
.PHONY: build run
|
||||||
|
build:
|
||||||
|
go build -buildvcs=false -o $(BINARY) .
|
||||||
|
|
||||||
|
run: build
|
||||||
|
./$(BINARY) -dir .. -addr 127.0.0.1:5757
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Git 自动同步 · 网页控制台(git-sync-web)
|
||||||
|
|
||||||
|
将 `git-auto-sync`(原 Bash 脚本版定时同步工具)的能力搬上网页:用浏览器即可监控、手动触发、查看差异、在线编辑文件、就地解决冲突,无需登录服务器敲命令行。
|
||||||
|
|
||||||
|
单二进制、纯标准库实现(Go 1.25+),前端资源通过 `go:embed` 内嵌,**零外部依赖、零运行时下载**,拷贝一个 exe 即可运行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
### 1. 仓库总览(Dashboard)
|
||||||
|
- 每个被监控仓库一张卡片:同步状态灯、领先/落后提交数、本地改动文件数。
|
||||||
|
- 运行中的仓库实时显示;关闭「自动同步」的仓库标注「手动模式」徽标,仅响应手动触发。
|
||||||
|
- 顶部按钮「全部启动 / 全部暂停」一键管控。
|
||||||
|
|
||||||
|
### 2. 实时活动(SSE 日志)
|
||||||
|
- 右侧实时日志面板通过 Server-Sent Events 推送每个仓库的同步过程、提交、冲突等事件,并带错误/警告高亮。
|
||||||
|
- 支持按关键字过滤、一键清空。
|
||||||
|
|
||||||
|
### 3. 手动同步
|
||||||
|
- 每个仓库提供「获取 / 拉取 / 推送」按钮,随时手动触发,不必等待定时调度。
|
||||||
|
|
||||||
|
### 4. 差异查看(Diff)
|
||||||
|
- 支持「工作区 / 已暂存 / 某次提交」三种范围;统一视图与**并排(split)视图**切换。
|
||||||
|
- 可选的**词级差异**(`git --word-diff=plain`),改动以行内增删高亮呈现。
|
||||||
|
- 一键**复制**差异内容、下载 `.patch` 文件;编辑器内「对比此文件」可只看当前文件的改动。
|
||||||
|
|
||||||
|
### 5. 网页端文件编辑
|
||||||
|
- 文件树列出仓库内可跟踪 / 未跟踪文件,并标注改动状态(M/A/D/?)。
|
||||||
|
- 点击文件即可**在线读取与编辑**,保存可「仅写盘」或「保存并提交」。
|
||||||
|
- 内置 **CodeMirror** 语法高亮;编辑过程中自动缓存草稿到浏览器 `localStorage`,刷新不丢稿,可「放弃草稿」回滚。
|
||||||
|
- 二进制文件自动识别并只读提示。
|
||||||
|
|
||||||
|
### 6. 冲突解决
|
||||||
|
- **拉取冲突**:选择「放弃合并」或「我已在本地解决,继续」。
|
||||||
|
- **推送冲突**(non-fast-forward):提供「强制推送(--force-with-lease)」或「放弃」。
|
||||||
|
- **冲突文件内联编辑**:直接在网页展开冲突文件、编辑解决、保存并标记已解决;所有冲突处理完毕时自动结束合并/变基。
|
||||||
|
|
||||||
|
### 7. 自动同步 / 自动提交 / Cron
|
||||||
|
- 每个仓库可设固定间隔(秒)或 **Cron 表达式**自动拉取推送(Cron 非空时优先于固定间隔)。
|
||||||
|
- 「自动提交」开启后,检测到工作区脏文件会自动 `add -A` + `commit` 再推送。
|
||||||
|
- 「自动同步」开关可单独关闭——关闭后该仓库不再自动运行,仅响应手动同步。
|
||||||
|
|
||||||
|
### 8. 提交历史
|
||||||
|
- 浏览仓库提交记录,点击展开单条提交的详细 diff。提交哈希经正则白名单校验,防止命令注入。
|
||||||
|
|
||||||
|
### 9. 统计
|
||||||
|
- 每次同步写入 CSV 统计(领先/落后/改动等),网页端以图表呈现近 30 天趋势。
|
||||||
|
|
||||||
|
### 10. 访问密码(可选)
|
||||||
|
- 启动时加 `-password` 参数启用鉴权:生成 30 天会话 cookie,除静态资源外的 API 均受保护。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 编译与运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编译(会重新内嵌 static 目录下的前端资源,改前端后必须重新 build)
|
||||||
|
go build -o git-auto.exe .
|
||||||
|
|
||||||
|
# Windows下可能需要执行如下命令编译:
|
||||||
|
go build -buildvcs=false -o git-auto.exe .
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
./git-auto.exe -dir "C:/OtherFiles/Github/git-auto-sync" -addr 127.0.0.1:5757
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 说明 | 默认值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `-dir` | 项目根目录,需包含 `configs/`、`logs/`、`stats/` | `.` |
|
||||||
|
| `-addr` | 监听地址 | `127.0.0.1:5757` |
|
||||||
|
| `-password` | 访问密码(为空则不启用鉴权) | 空 |
|
||||||
|
|
||||||
|
启动后浏览器打开 `http://127.0.0.1:5757` 即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置文件格式
|
||||||
|
|
||||||
|
监控目标以 `.conf` 文件存放在 `-dir` 指定的 `configs/` 目录下,兼容原 Bash 脚本的配置字段:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# Git 自动同步配置 - 示例
|
||||||
|
REPO_PATH="/c/OtherFiles/Github/your-repo" # 支持 Git Bash 风格 /c/... 路径,会自动转为 Windows 路径
|
||||||
|
REMOTE_NAME="origin"
|
||||||
|
REMOTE_URL="https://example.com/you/repo.git"
|
||||||
|
# 多远程写法:REMOTES="origin=https://a.git,fork=https://b.git"
|
||||||
|
BRANCH="main"
|
||||||
|
MODE="both" # both / push / pull
|
||||||
|
INTERVAL="60" # 固定间隔(秒)
|
||||||
|
CRON="" # 可选,cron 表达式优先于 INTERVAL
|
||||||
|
LOG_FILE="/c/.../sync.log"
|
||||||
|
AUTH_METHOD="ssh" # ssh / https
|
||||||
|
AUTO_COMMIT="0" # 1 开启自动提交
|
||||||
|
AUTO_SYNC="1" # 0 关闭自动同步(仅手动)
|
||||||
|
CONFLICT_STRATEGY="merge" # merge / rebase
|
||||||
|
PUSH_STRATEGY="manual" # manual / auto
|
||||||
|
```
|
||||||
|
|
||||||
|
> 说明:路径既支持 `C:/...` 也支持 Git Bash 的 `/c/...`,后者会在加载时自动规范化;`.git` 内部目录被禁止通过网页访问。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
git-sync-web/
|
||||||
|
├── main.go # 入口:-dir / -addr / -password,内嵌 static
|
||||||
|
├── config.go # .conf 解析与写回、Windows 路径规范化
|
||||||
|
├── git.go # git 执行器、领先/落后、冲突检测
|
||||||
|
├── engine.go # 每仓库调度循环、SSE 事件总线、文件树/读写
|
||||||
|
├── cron.go # 纯标准库 5 段 cron 解析(含单测)
|
||||||
|
├── server.go # REST API 与静态资源服务
|
||||||
|
└── static/ # 前端:index.html / app.js / style.css / cm-boot.js / vendor/cm/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设计要点
|
||||||
|
|
||||||
|
- **单二进制 + 内嵌前端**:部署只需一个可执行文件,不依赖任何外部 CDN 或运行时。
|
||||||
|
- **安全边界**:仓库内路径经 `safeRepoPath` 严格校验,禁止 `..` 越界与访问 `.git`;提交哈希白名单防注入;网页编辑单文件 ≤1MB 且拒绝二进制写入。
|
||||||
|
- **优雅降级**:CodeMirror 资源缺失或初始化失败时,编辑器自动回退原生 `<textarea>`,核心功能不受影响。
|
||||||
@@ -35,7 +35,6 @@ type RepoConfig struct {
|
|||||||
var nameRe = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`)
|
var nameRe = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`)
|
||||||
|
|
||||||
// normalizeWinPath 把 Git Bash 风格路径 /c/foo/bar 转成 Windows 的 C:/foo/bar,
|
// normalizeWinPath 把 Git Bash 风格路径 /c/foo/bar 转成 Windows 的 C:/foo/bar,
|
||||||
// 兼容旧脚本配置里存放的 bash 风格路径
|
|
||||||
func normalizeWinPath(p string) string {
|
func normalizeWinPath(p string) string {
|
||||||
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
|
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
|
||||||
if len(p) >= 3 && p[0] == '/' && p[2] == '/' {
|
if len(p) >= 3 && p[0] == '/' && p[2] == '/' {
|
||||||
@@ -110,7 +109,6 @@ func loadRepoConfig(dir, name string) (*RepoConfig, error) {
|
|||||||
c.ConflictStrategy = orDefault(m["CONFLICT_STRATEGY"], "merge")
|
c.ConflictStrategy = orDefault(m["CONFLICT_STRATEGY"], "merge")
|
||||||
c.PushStrategy = orDefault(m["PUSH_STRATEGY"], "manual")
|
c.PushStrategy = orDefault(m["PUSH_STRATEGY"], "manual")
|
||||||
c.AutoCommit = strings.EqualFold(m["AUTO_COMMIT"], "1") || strings.EqualFold(m["AUTO_COMMIT"], "true")
|
c.AutoCommit = strings.EqualFold(m["AUTO_COMMIT"], "1") || strings.EqualFold(m["AUTO_COMMIT"], "true")
|
||||||
// 自动同步默认开启;仅在显式配置 0 / false 时关闭(关闭后不再自动触发,但手动同步仍可用)
|
|
||||||
c.AutoSync = !(strings.EqualFold(m["AUTO_SYNC"], "0") || strings.EqualFold(m["AUTO_SYNC"], "false"))
|
c.AutoSync = !(strings.EqualFold(m["AUTO_SYNC"], "0") || strings.EqualFold(m["AUTO_SYNC"], "false"))
|
||||||
c.Cron = strings.TrimSpace(m["CRON"])
|
c.Cron = strings.TrimSpace(m["CRON"])
|
||||||
if remotes := strings.TrimSpace(m["REMOTES"]); remotes != "" {
|
if remotes := strings.TrimSpace(m["REMOTES"]); remotes != "" {
|
||||||
@@ -126,7 +124,6 @@ func loadRepoConfig(dir, name string) (*RepoConfig, error) {
|
|||||||
c.Remotes = append(c.Remotes, Remote{Name: strings.TrimSpace(n), URL: strings.TrimSpace(u)})
|
c.Remotes = append(c.Remotes, Remote{Name: strings.TrimSpace(n), URL: strings.TrimSpace(u)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 兼容旧版单远程格式:REMOTE_NAME + REMOTE_URL
|
|
||||||
if len(c.Remotes) == 0 && m["REMOTE_NAME"] != "" && m["REMOTE_URL"] != "" {
|
if len(c.Remotes) == 0 && m["REMOTE_NAME"] != "" && m["REMOTE_URL"] != "" {
|
||||||
c.Remotes = append(c.Remotes, Remote{Name: m["REMOTE_NAME"], URL: m["REMOTE_URL"]})
|
c.Remotes = append(c.Remotes, Remote{Name: m["REMOTE_NAME"], URL: m["REMOTE_URL"]})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,7 @@ AUTH_METHOD="https"
|
|||||||
RETRY_COUNT="0"
|
RETRY_COUNT="0"
|
||||||
RETRY_INTERVAL="0"
|
RETRY_INTERVAL="0"
|
||||||
CONFLICT_STRATEGY="merge"
|
CONFLICT_STRATEGY="merge"
|
||||||
AUTO_COMMIT="1"
|
PUSH_STRATEGY="manual"
|
||||||
|
AUTO_COMMIT="0"
|
||||||
|
AUTO_SYNC="0"
|
||||||
CRON=""
|
CRON=""
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 5 段 cron:分 时 日 月 周。支持 * 、数字、范围 a-b、步长 */n 与 a-b/n、逗号列表。
|
|
||||||
// 日(day-of-month) 与 周(day-of-week) 均受限时采用标准 cron 的「或」语义。
|
|
||||||
|
|
||||||
type cronSpec struct {
|
type cronSpec struct {
|
||||||
min, hour, dom, mon, dow map[int]bool
|
min, hour, dom, mon, dow map[int]bool
|
||||||
@@ -92,7 +90,6 @@ func parseCronField(f string, min, max int) (map[int]bool, error) {
|
|||||||
return set, nil
|
return set, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cronNextString 返回 cron 表达式相对当前时间的下次触发时间(供配置表单实时预览)
|
|
||||||
func cronNextString(expr string) (string, error) {
|
func cronNextString(expr string) (string, error) {
|
||||||
spec, err := parseCron(expr)
|
spec, err := parseCron(expr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -101,7 +98,6 @@ func cronNextString(expr string) (string, error) {
|
|||||||
return cronNext(spec, time.Now()).Format("2006-01-02 15:04"), nil
|
return cronNext(spec, time.Now()).Format("2006-01-02 15:04"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cronNext 返回 spec 在 after 之后的下一次触发时间(分钟精度,最多向前搜索一年)
|
|
||||||
func cronNext(spec *cronSpec, after time.Time) time.Time {
|
func cronNext(spec *cronSpec, after time.Time) time.Time {
|
||||||
t := after.Truncate(time.Minute).Add(time.Minute)
|
t := after.Truncate(time.Minute).Add(time.Minute)
|
||||||
limit := t.AddDate(1, 0, 0)
|
limit := t.AddDate(1, 0, 0)
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ func TestParseCron(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCronNext(t *testing.T) {
|
func TestCronNext(t *testing.T) {
|
||||||
// 每日 09:30:从当天 10:00 出发应得到次日 09:30
|
|
||||||
spec, _ := parseCron("30 9 * * *")
|
spec, _ := parseCron("30 9 * * *")
|
||||||
next := cronNext(spec, time.Date(2026, 9, 8, 10, 0, 0, 0, time.Local))
|
next := cronNext(spec, time.Date(2026, 9, 8, 10, 0, 0, 0, time.Local))
|
||||||
want := time.Date(2026, 9, 9, 9, 30, 0, 0, time.Local)
|
want := time.Date(2026, 9, 9, 9, 30, 0, 0, time.Local)
|
||||||
@@ -27,7 +26,6 @@ func TestCronNext(t *testing.T) {
|
|||||||
t.Fatalf("每日 09:30:期望 %v,得到 %v", want, next)
|
t.Fatalf("每日 09:30:期望 %v,得到 %v", want, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 每 15 分钟
|
|
||||||
spec2, _ := parseCron("*/15 * * * *")
|
spec2, _ := parseCron("*/15 * * * *")
|
||||||
next2 := cronNext(spec2, time.Date(2026, 9, 8, 10, 7, 0, 0, time.Local))
|
next2 := cronNext(spec2, time.Date(2026, 9, 8, 10, 7, 0, 0, time.Local))
|
||||||
want2 := time.Date(2026, 9, 8, 10, 15, 0, 0, time.Local)
|
want2 := time.Date(2026, 9, 8, 10, 15, 0, 0, time.Local)
|
||||||
@@ -35,7 +33,6 @@ func TestCronNext(t *testing.T) {
|
|||||||
t.Fatalf("*/15:期望 %v,得到 %v", want2, next2)
|
t.Fatalf("*/15:期望 %v,得到 %v", want2, next2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅周五 12:00:2026-09-08 是周二,应得到 2026-09-11(周五)
|
|
||||||
spec3, _ := parseCron("0 12 * * 5")
|
spec3, _ := parseCron("0 12 * * 5")
|
||||||
next3 := cronNext(spec3, time.Date(2026, 9, 8, 12, 0, 0, 0, time.Local))
|
next3 := cronNext(spec3, time.Date(2026, 9, 8, 12, 0, 0, 0, time.Local))
|
||||||
want3 := time.Date(2026, 9, 11, 12, 0, 0, 0, time.Local)
|
want3 := time.Date(2026, 9, 11, 12, 0, 0, 0, time.Local)
|
||||||
@@ -43,7 +40,6 @@ func TestCronNext(t *testing.T) {
|
|||||||
t.Fatalf("仅周五:期望 %v,得到 %v", want3, next3)
|
t.Fatalf("仅周五:期望 %v,得到 %v", want3, next3)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7 表示周日:2026-09-13 是周日
|
|
||||||
spec4, _ := parseCron("0 0 * * 7")
|
spec4, _ := parseCron("0 0 * * 7")
|
||||||
next4 := cronNext(spec4, time.Date(2026, 9, 12, 0, 0, 0, 0, time.Local)) // 周六
|
next4 := cronNext(spec4, time.Date(2026, 9, 12, 0, 0, 0, 0, time.Local)) // 周六
|
||||||
want4 := time.Date(2026, 9, 13, 0, 0, 0, 0, time.Local)
|
want4 := time.Date(2026, 9, 13, 0, 0, 0, 0, time.Local)
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------- 事件总线 ----------
|
|
||||||
type EventBus struct {
|
type EventBus struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
next int
|
next int
|
||||||
@@ -55,7 +54,6 @@ func (b *EventBus) Publish(data []byte) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 状态 ----------
|
|
||||||
type SyncResult struct {
|
type SyncResult struct {
|
||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
Result string `json:"result"` // ok | error | conflict | push_conflict
|
Result string `json:"result"` // ok | error | conflict | push_conflict
|
||||||
@@ -86,7 +84,6 @@ type RepoSnapshot struct {
|
|||||||
Conflicts []string `json:"conflicts"`
|
Conflicts []string `json:"conflicts"`
|
||||||
LastError string `json:"last_error"`
|
LastError string `json:"last_error"`
|
||||||
LastSync *SyncResult `json:"last_sync"`
|
LastSync *SyncResult `json:"last_sync"`
|
||||||
// 新增:推送冲突信息
|
|
||||||
PushConflict bool `json:"push_conflict"`
|
PushConflict bool `json:"push_conflict"`
|
||||||
PushConflictRemote string `json:"push_conflict_remote"`
|
PushConflictRemote string `json:"push_conflict_remote"`
|
||||||
PushConflictBranch string `json:"push_conflict_branch"`
|
PushConflictBranch string `json:"push_conflict_branch"`
|
||||||
@@ -105,7 +102,6 @@ type repoState struct {
|
|||||||
lastError string
|
lastError string
|
||||||
lastSync *SyncResult
|
lastSync *SyncResult
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
// 推送冲突信息
|
|
||||||
pushConflict bool
|
pushConflict bool
|
||||||
pushConflictRemote string
|
pushConflictRemote string
|
||||||
pushConflictBranch string
|
pushConflictBranch string
|
||||||
@@ -206,7 +202,6 @@ func (e *Engine) logf(s *repoState, level, format string, args ...any) {
|
|||||||
if logPath != "" {
|
if logPath != "" {
|
||||||
_ = os.MkdirAll(filepath.Dir(logPath), 0o755)
|
_ = os.MkdirAll(filepath.Dir(logPath), 0o755)
|
||||||
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
|
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
|
||||||
// 落盘格式:[时间] [级别] 消息,便于网页端按级别过滤
|
|
||||||
fmt.Fprintln(f, "["+ts+"] ["+level+"] "+msg)
|
fmt.Fprintln(f, "["+ts+"] ["+level+"] "+msg)
|
||||||
_ = f.Close()
|
_ = f.Close()
|
||||||
}
|
}
|
||||||
@@ -214,7 +209,6 @@ func (e *Engine) logf(s *repoState, level, format string, args ...any) {
|
|||||||
e.publish(map[string]any{"type": "log", "repo": s.cfg.Name, "level": level, "msg": msg, "time": ts})
|
e.publish(map[string]any{"type": "log", "repo": s.cfg.Name, "level": level, "msg": msg, "time": ts})
|
||||||
}
|
}
|
||||||
|
|
||||||
// statMaxLines 控制单个仓库统计 CSV 的最大行数(含表头),超过则滚动保留最近 N 行
|
|
||||||
const statMaxLines = 2000
|
const statMaxLines = 2000
|
||||||
|
|
||||||
func (e *Engine) stat(s *repoState, action, result string, commits int) {
|
func (e *Engine) stat(s *repoState, action, result string, commits int) {
|
||||||
@@ -222,7 +216,6 @@ func (e *Engine) stat(s *repoState, action, result string, commits int) {
|
|||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
_ = os.WriteFile(path, []byte("timestamp,action,result,commits\n"), 0o644)
|
_ = os.WriteFile(path, []byte("timestamp,action,result,commits\n"), 0o644)
|
||||||
}
|
}
|
||||||
// 文件过大时滚动截断,避免长期运行无限膨胀(统计频率低,重写成本可接受)
|
|
||||||
if rotateStatIfNeeded(path, statMaxLines) {
|
if rotateStatIfNeeded(path, statMaxLines) {
|
||||||
e.logf(s, "info", "统计文件已滚动截断至最近 %d 行", statMaxLines)
|
e.logf(s, "info", "统计文件已滚动截断至最近 %d 行", statMaxLines)
|
||||||
}
|
}
|
||||||
@@ -234,7 +227,6 @@ func (e *Engine) stat(s *repoState, action, result string, commits int) {
|
|||||||
fmt.Fprintf(f, "%s,%s,%s,%d\n", time.Now().Format("2006-01-02 15:04:05"), action, result, commits)
|
fmt.Fprintf(f, "%s,%s,%s,%d\n", time.Now().Format("2006-01-02 15:04:05"), action, result, commits)
|
||||||
}
|
}
|
||||||
|
|
||||||
// rotateStatIfNeeded 当 CSV 行数超过 maxLines 时,保留表头 + 最近 (maxLines-1) 条数据行并重写
|
|
||||||
func rotateStatIfNeeded(path string, maxLines int) bool {
|
func rotateStatIfNeeded(path string, maxLines int) bool {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -322,7 +314,6 @@ func (e *Engine) pushStatus(s *repoState) {
|
|||||||
e.publish(map[string]any{"type": "status", "repo": s.cfg.Name, "data": s.snapshot(false)})
|
e.publish(map[string]any{"type": "status", "repo": s.cfg.Name, "data": s.snapshot(false)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 启动 / 停止 ----------
|
|
||||||
func (e *Engine) Start(name string) error {
|
func (e *Engine) Start(name string) error {
|
||||||
s := e.get(name)
|
s := e.get(name)
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -354,7 +345,6 @@ func (e *Engine) loop(s *repoState, stopCh chan struct{}) {
|
|||||||
cronStr := s.cfg.Cron
|
cronStr := s.cfg.Cron
|
||||||
autoSync := s.cfg.AutoSync
|
autoSync := s.cfg.AutoSync
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
// 自动同步关闭:不再自动触发,仅保留监控与手动同步能力
|
|
||||||
if !autoSync {
|
if !autoSync {
|
||||||
e.logf(s, "info", "已关闭自动同步,仅响应手动同步")
|
e.logf(s, "info", "已关闭自动同步,仅响应手动同步")
|
||||||
e.pushStatus(s)
|
e.pushStatus(s)
|
||||||
@@ -444,7 +434,6 @@ func (e *Engine) SyncNow(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 同步核心 ----------
|
|
||||||
func (e *Engine) withRetry(s *repoState, label string, fn func() (string, error)) (string, error) {
|
func (e *Engine) withRetry(s *repoState, label string, fn func() (string, error)) (string, error) {
|
||||||
maxAttempts, sleep, retryCount := 1, 0, 0
|
maxAttempts, sleep, retryCount := 1, 0, 0
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -491,7 +480,6 @@ func (e *Engine) setOK(s *repoState, detail string) {
|
|||||||
e.pushStatus(s)
|
e.pushStatus(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureRemotes 把配置中的远程同步到仓库
|
|
||||||
func (e *Engine) ensureRemotes(s *repoState, cfg *RepoConfig) {
|
func (e *Engine) ensureRemotes(s *repoState, cfg *RepoConfig) {
|
||||||
for _, r := range cfg.Remotes {
|
for _, r := range cfg.Remotes {
|
||||||
out, err := gitRun(cfg.RepoPath, 15*time.Second, "remote", "get-url", r.Name)
|
out, err := gitRun(cfg.RepoPath, 15*time.Second, "remote", "get-url", r.Name)
|
||||||
@@ -538,7 +526,6 @@ func (e *Engine) safeSync(s *repoState, trigger string) {
|
|||||||
e.syncOnce(s, trigger)
|
e.syncOnce(s, trigger)
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncOnce 核心同步逻辑(已调整顺序,并增加 push_conflict 处理)
|
|
||||||
func (e *Engine) syncOnce(s *repoState, trigger string) {
|
func (e *Engine) syncOnce(s *repoState, trigger string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.syncing {
|
if s.syncing {
|
||||||
@@ -554,7 +541,6 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 自动 stash 恢复(B4):若本次同步前暂存了改动,结束时尝试恢复
|
|
||||||
stashed := false
|
stashed := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if !stashed {
|
if !stashed {
|
||||||
@@ -587,7 +573,6 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动 stash(B4):未开启自动提交且有未提交改动时先暂存,避免分支切换/推送被脏工作区阻断
|
|
||||||
if !cfg.AutoCommit && dirtyCount(cfg.RepoPath) > 0 {
|
if !cfg.AutoCommit && dirtyCount(cfg.RepoPath) > 0 {
|
||||||
if _, serr := gitRun(cfg.RepoPath, 60*time.Second, "stash", "push", "-u", "-m", "git-sync-web auto-stash"); serr == nil {
|
if _, serr := gitRun(cfg.RepoPath, 60*time.Second, "stash", "push", "-u", "-m", "git-sync-web auto-stash"); serr == nil {
|
||||||
stashed = true
|
stashed = true
|
||||||
@@ -625,7 +610,6 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动提交
|
|
||||||
dirty := dirtyCount(cfg.RepoPath)
|
dirty := dirtyCount(cfg.RepoPath)
|
||||||
if dirty > 0 {
|
if dirty > 0 {
|
||||||
if cfg.AutoCommit {
|
if cfg.AutoCommit {
|
||||||
@@ -646,7 +630,6 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算 ahead/behind
|
|
||||||
var ahead, behind int
|
var ahead, behind int
|
||||||
var err error
|
var err error
|
||||||
tip, remoteHasBranch := remoteTip(cfg.RepoPath, remote, cfg.Branch)
|
tip, remoteHasBranch := remoteTip(cfg.RepoPath, remote, cfg.Branch)
|
||||||
@@ -667,7 +650,6 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
e.pushStatus(s)
|
e.pushStatus(s)
|
||||||
|
|
||||||
// ---- 先拉取 ----
|
|
||||||
if behind > 0 && (cfg.Mode == "pull" || cfg.Mode == "both") {
|
if behind > 0 && (cfg.Mode == "pull" || cfg.Mode == "both") {
|
||||||
if !e.pullRemote(s, cfg, remote, behind) {
|
if !e.pullRemote(s, cfg, remote, behind) {
|
||||||
return
|
return
|
||||||
@@ -679,11 +661,9 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
e.pushStatus(s)
|
e.pushStatus(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 再推送 ----
|
|
||||||
if ahead > 0 && (cfg.Mode == "push" || cfg.Mode == "both") {
|
if ahead > 0 && (cfg.Mode == "push" || cfg.Mode == "both") {
|
||||||
pushOK, pushErr := e.pushAll(s, cfg, ahead)
|
pushOK, pushErr := e.pushAll(s, cfg, ahead)
|
||||||
if !pushOK {
|
if !pushOK {
|
||||||
// 检查是否是 non-fast-forward 错误,按推送策略处理(B3)
|
|
||||||
if pushErr != nil && strings.Contains(strings.ToLower(pushErr.Error()), "non-fast-forward") {
|
if pushErr != nil && strings.Contains(strings.ToLower(pushErr.Error()), "non-fast-forward") {
|
||||||
switch cfg.PushStrategy {
|
switch cfg.PushStrategy {
|
||||||
case "force":
|
case "force":
|
||||||
@@ -715,7 +695,6 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
s.lastError = ""
|
s.lastError = ""
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
default:
|
default:
|
||||||
// manual:保持 push_conflict 状态,等待用户在控制台决策(强制推送 / 放弃)
|
|
||||||
e.logf(s, "warn", "推送被拒绝(non-fast-forward),等待用户决策")
|
e.logf(s, "warn", "推送被拒绝(non-fast-forward),等待用户决策")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -726,7 +705,6 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最终刷新
|
|
||||||
ahead, behind, _ = aheadBehind(cfg.RepoPath, remote, cfg.Branch)
|
ahead, behind, _ = aheadBehind(cfg.RepoPath, remote, cfg.Branch)
|
||||||
now := time.Now().Format("2006-01-02 15:04:05")
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -734,7 +712,6 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
s.status = "ok"
|
s.status = "ok"
|
||||||
s.lastError = ""
|
s.lastError = ""
|
||||||
s.lastSync = &SyncResult{Time: now, Result: "ok", Detail: "同步完成"}
|
s.lastSync = &SyncResult{Time: now, Result: "ok", Detail: "同步完成"}
|
||||||
// 清除可能遗留的推送冲突状态
|
|
||||||
s.pushConflict = false
|
s.pushConflict = false
|
||||||
s.pushConflictRemote = ""
|
s.pushConflictRemote = ""
|
||||||
s.pushConflictBranch = ""
|
s.pushConflictBranch = ""
|
||||||
@@ -743,8 +720,6 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
e.pushStatus(s)
|
e.pushStatus(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pushAll 返回 (是否全部成功, 错误信息)
|
|
||||||
// 如果遇到 non-fast-forward,会设置 pushConflict 状态并返回 false,错误包含 "non-fast-forward"
|
|
||||||
func (e *Engine) pushAll(s *repoState, cfg *RepoConfig, n int) (bool, error) {
|
func (e *Engine) pushAll(s *repoState, cfg *RepoConfig, n int) (bool, error) {
|
||||||
allOK := true
|
allOK := true
|
||||||
var firstErr error
|
var firstErr error
|
||||||
@@ -758,7 +733,6 @@ func (e *Engine) pushAll(s *repoState, cfg *RepoConfig, n int) (bool, error) {
|
|||||||
if firstErr == nil {
|
if firstErr == nil {
|
||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
// 检测到 non-fast-forward,记录状态并继续尝试其他远程
|
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "non-fast-forward") {
|
if strings.Contains(strings.ToLower(err.Error()), "non-fast-forward") {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.status = "push_conflict"
|
s.status = "push_conflict"
|
||||||
@@ -786,7 +760,6 @@ func (e *Engine) pushAll(s *repoState, cfg *RepoConfig, n int) (bool, error) {
|
|||||||
return false, firstErr
|
return false, firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// pullRemote 已使用重试
|
|
||||||
func (e *Engine) pullRemote(s *repoState, cfg *RepoConfig, remote string, n int) bool {
|
func (e *Engine) pullRemote(s *repoState, cfg *RepoConfig, remote string, n int) bool {
|
||||||
args := []string{"pull"}
|
args := []string{"pull"}
|
||||||
if cfg.ConflictStrategy == "rebase" {
|
if cfg.ConflictStrategy == "rebase" {
|
||||||
@@ -826,8 +799,6 @@ func (e *Engine) pullRemote(s *repoState, cfg *RepoConfig, remote string, n int)
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 推送冲突处理 ----------
|
|
||||||
// ForcePush 强制推送(--force-with-lease)到冲突的远程
|
|
||||||
func (e *Engine) ForcePush(name string) error {
|
func (e *Engine) ForcePush(name string) error {
|
||||||
s := e.get(name)
|
s := e.get(name)
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -862,7 +833,6 @@ func (e *Engine) ForcePush(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AbortPush 放弃强制推送,清除冲突状态
|
|
||||||
func (e *Engine) AbortPush(name string) error {
|
func (e *Engine) AbortPush(name string) error {
|
||||||
s := e.get(name)
|
s := e.get(name)
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -884,7 +854,6 @@ func (e *Engine) AbortPush(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 手动操作 ----------
|
|
||||||
func (e *Engine) ManualGit(name, action string) error {
|
func (e *Engine) ManualGit(name, action string) error {
|
||||||
s := e.get(name)
|
s := e.get(name)
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -964,7 +933,6 @@ func (e *Engine) ManualGit(name, action string) error {
|
|||||||
return fmt.Errorf("未知操作: %s", action)
|
return fmt.Errorf("未知操作: %s", action)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 冲突处理 ----------
|
|
||||||
func (e *Engine) ConflictAbort(name string) error {
|
func (e *Engine) ConflictAbort(name string) error {
|
||||||
s := e.get(name)
|
s := e.get(name)
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -1017,7 +985,6 @@ func (e *Engine) ConflictResolve(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 配置 CRUD ----------
|
|
||||||
func (e *Engine) Create(c *RepoConfig) error {
|
func (e *Engine) Create(c *RepoConfig) error {
|
||||||
if err := validateRepoConfig(e.cfgDir, c, true); err != nil {
|
if err := validateRepoConfig(e.cfgDir, c, true); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1058,7 +1025,6 @@ func (e *Engine) Update(c *RepoConfig) error {
|
|||||||
s.lastError = ""
|
s.lastError = ""
|
||||||
s.ahead, s.behind, s.dirty = 0, 0, 0
|
s.ahead, s.behind, s.dirty = 0, 0, 0
|
||||||
s.lastSync = nil
|
s.lastSync = nil
|
||||||
// 清除推送冲突
|
|
||||||
s.pushConflict = false
|
s.pushConflict = false
|
||||||
s.pushConflictRemote = ""
|
s.pushConflictRemote = ""
|
||||||
s.pushConflictBranch = ""
|
s.pushConflictBranch = ""
|
||||||
@@ -1088,7 +1054,6 @@ func (e *Engine) Delete(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 日志 / 统计 / 冲突 diff ----------
|
|
||||||
type StatRow struct {
|
type StatRow struct {
|
||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
@@ -1168,7 +1133,6 @@ func (e *Engine) ConflictDiffs(name string) []ConflictFile {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 提交历史 ----------
|
|
||||||
var hashRe = regexp.MustCompile(`^[0-9a-fA-F]{6,40}$`)
|
var hashRe = regexp.MustCompile(`^[0-9a-fA-F]{6,40}$`)
|
||||||
|
|
||||||
type Commit struct {
|
type Commit struct {
|
||||||
@@ -1222,7 +1186,6 @@ func (e *Engine) ReadCommitDiff(name, hash string) (string, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 差异与文件编辑(网页端) ----------
|
|
||||||
|
|
||||||
const fileEditMaxBytes = 1 << 20 // 1MB,网页编辑单文件上限
|
const fileEditMaxBytes = 1 << 20 // 1MB,网页编辑单文件上限
|
||||||
|
|
||||||
@@ -1250,13 +1213,11 @@ func (e *Engine) safeRepoPath(s *repoState, rel string) (string, error) {
|
|||||||
return full, nil
|
return full, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileNode 文件树节点(网页端)
|
|
||||||
type FileNode struct {
|
type FileNode struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Status string `json:"status"` // M 已修改 / A 新增 / D 删除 / ? 未跟踪 / 空 未改动
|
Status string `json:"status"` // M 已修改 / A 新增 / D 删除 / ? 未跟踪 / 空 未改动
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileTree 列出仓库内可编辑文件(已跟踪 + 未跟踪且未被忽略),天然避开 .git 内部
|
|
||||||
func (e *Engine) FileTree(name string) []FileNode {
|
func (e *Engine) FileTree(name string) []FileNode {
|
||||||
s := e.get(name)
|
s := e.get(name)
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -1280,7 +1241,6 @@ func (e *Engine) FileTree(name string) []FileNode {
|
|||||||
paths = append(paths, p)
|
paths = append(paths, p)
|
||||||
}
|
}
|
||||||
sort.Strings(paths)
|
sort.Strings(paths)
|
||||||
// 附带每个文件的改动状态(M/A/D/?),便于网页端一眼看出哪些文件被动过
|
|
||||||
status := changedFiles(s.cfg.RepoPath)
|
status := changedFiles(s.cfg.RepoPath)
|
||||||
nodes := make([]FileNode, 0, len(paths))
|
nodes := make([]FileNode, 0, len(paths))
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
@@ -1289,7 +1249,6 @@ func (e *Engine) FileTree(name string) []FileNode {
|
|||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileContent 文件内容读取结果
|
|
||||||
type FileContent struct {
|
type FileContent struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
@@ -1297,7 +1256,6 @@ type FileContent struct {
|
|||||||
Size int `json:"size"`
|
Size int `json:"size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadFileContent 读取仓库内文件内容(带大小上限与二进制检测)
|
|
||||||
func (e *Engine) ReadFileContent(name, rel string) (*FileContent, error) {
|
func (e *Engine) ReadFileContent(name, rel string) (*FileContent, error) {
|
||||||
s := e.get(name)
|
s := e.get(name)
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -1327,7 +1285,6 @@ func (e *Engine) ReadFileContent(name, rel string) (*FileContent, error) {
|
|||||||
return &FileContent{Path: rel, Content: string(data), 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 {
|
func (e *Engine) WriteFileContent(name, rel, content string) error {
|
||||||
s := e.get(name)
|
s := e.get(name)
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -1349,7 +1306,6 @@ func (e *Engine) WriteFileContent(name, rel, content string) error {
|
|||||||
return os.WriteFile(full, []byte(content), 0o644)
|
return os.WriteFile(full, []byte(content), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommitFile 仅提交单个被网页端编辑的文件
|
|
||||||
func (e *Engine) CommitFile(name, rel, message string) error {
|
func (e *Engine) CommitFile(name, rel, message string) error {
|
||||||
s := e.get(name)
|
s := e.get(name)
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -1371,8 +1327,6 @@ func (e *Engine) CommitFile(name, rel, message string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveConflictFile 网页端解决冲突:写入内容 → 暂存 → (可选)结束合并/变基
|
|
||||||
// finish=true 且已无其它冲突文件时才真正结束,避免把带冲突标记的内容提交进去
|
|
||||||
func (e *Engine) ResolveConflictFile(name, rel, content string, finish bool) error {
|
func (e *Engine) ResolveConflictFile(name, rel, content string, finish bool) error {
|
||||||
s := e.get(name)
|
s := e.get(name)
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -1396,14 +1350,12 @@ func (e *Engine) ResolveConflictFile(name, rel, content string, finish bool) err
|
|||||||
return e.ConflictResolve(name)
|
return e.ConflictResolve(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiffFile 单文件增删统计
|
|
||||||
type DiffFile struct {
|
type DiffFile struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Add int `json:"add"`
|
Add int `json:"add"`
|
||||||
Del int `json:"del"`
|
Del int `json:"del"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiffResult 差异结果(网页端「差异」tab)
|
|
||||||
type DiffResult struct {
|
type DiffResult struct {
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
Stat string `json:"stat"`
|
Stat string `json:"stat"`
|
||||||
@@ -1413,7 +1365,6 @@ type DiffResult struct {
|
|||||||
Word bool `json:"word"`
|
Word bool `json:"word"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkingDiff 返回工作区/暂存/全部/与远程的差异
|
|
||||||
// rel 非空时只对比单个文件(已做路径安全校验)
|
// rel 非空时只对比单个文件(已做路径安全校验)
|
||||||
// mode = "word" 时改用 --word-diff=plain,便于词级定位改动
|
// mode = "word" 时改用 --word-diff=plain,便于词级定位改动
|
||||||
func (e *Engine) WorkingDiff(name, scope, rel, mode string) (*DiffResult, error) {
|
func (e *Engine) WorkingDiff(name, scope, rel, mode string) (*DiffResult, error) {
|
||||||
@@ -1441,7 +1392,6 @@ func (e *Engine) WorkingDiff(name, scope, rel, mode string) (*DiffResult, error)
|
|||||||
args = []string{"diff", "--no-color"}
|
args = []string{"diff", "--no-color"}
|
||||||
}
|
}
|
||||||
// 词级差异:--word-diff=plain 用 [-x-] / {+x+} 标记行内改动
|
// 词级差异:--word-diff=plain 用 [-x-] / {+x+} 标记行内改动
|
||||||
// 注意:该模式下 git 输出的上下文行不带前导空格,前端解析需兼容(见 parseUnifiedDiff)
|
|
||||||
if mode == "word" {
|
if mode == "word" {
|
||||||
args = append(args, "--word-diff=plain")
|
args = append(args, "--word-diff=plain")
|
||||||
res.Word = true
|
res.Word = true
|
||||||
@@ -1476,7 +1426,6 @@ func (e *Engine) WorkingDiff(name, scope, rel, mode string) (*DiffResult, error)
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripWarnings 去掉 git 在 stderr 输出的环境告警(如 autocrlf 提示),避免污染差异展示
|
|
||||||
func stripWarnings(s string) string {
|
func stripWarnings(s string) string {
|
||||||
lines := strings.Split(s, "\n")
|
lines := strings.Split(s, "\n")
|
||||||
out := make([]string, 0, len(lines))
|
out := make([]string, 0, len(lines))
|
||||||
@@ -1489,7 +1438,6 @@ func stripWarnings(s string) string {
|
|||||||
return strings.Join(out, "\n")
|
return strings.Join(out, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseStatFiles 解析 `git diff --stat` 输出为单文件增删统计
|
|
||||||
func parseStatFiles(stat string) []DiffFile {
|
func parseStatFiles(stat string) []DiffFile {
|
||||||
var files []DiffFile
|
var files []DiffFile
|
||||||
for _, line := range strings.Split(stat, "\n") {
|
for _, line := range strings.Split(stat, "\n") {
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// gitRun 在指定仓库目录执行 git 命令,返回合并后的输出。
|
|
||||||
// 非交互环境:GIT_TERMINAL_PROMPT=0 只能挡住终端提示,挡不住 Windows 凭据管理器(GCM)弹窗,
|
|
||||||
// 因此额外禁用 GCM 交互、askpass 和 ssh 交互,避免后台同步被凭据弹窗永久卡死。
|
|
||||||
// WaitDelay 保证超时 Kill 后即使 git 的子进程仍占用管道,Wait() 也能强制返回(Windows 上
|
|
||||||
// CommandContext 只杀主进程,不杀 git-remote-https / 凭据 helper 等子进程)。
|
|
||||||
func gitRun(repoPath string, timeout time.Duration, args ...string) (string, error) {
|
func gitRun(repoPath string, timeout time.Duration, args ...string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -30,16 +25,10 @@ func gitRun(repoPath string, timeout time.Duration, args ...string) (string, err
|
|||||||
"GIT_SSH_COMMAND=ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new",
|
"GIT_SSH_COMMAND=ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new",
|
||||||
)
|
)
|
||||||
cmd.WaitDelay = 10 * time.Second
|
cmd.WaitDelay = 10 * time.Second
|
||||||
// stdout 与 stderr 分开收集:成功时只返回干净的 stdout 供解析,
|
|
||||||
// 避免 git 的环境告警(如 autocrlf、进度信息)污染解析结果;
|
|
||||||
// 失败时优先用 stderr 作为错误信息,保证诊断信息不丢失。
|
|
||||||
var outBuf, errBuf bytes.Buffer
|
var outBuf, errBuf bytes.Buffer
|
||||||
cmd.Stdout = &outBuf
|
cmd.Stdout = &outBuf
|
||||||
cmd.Stderr = &errBuf
|
cmd.Stderr = &errBuf
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
// 只裁剪尾部空白,不要用 TrimSpace 裁整体:
|
|
||||||
// git 输出首行的前导空格具有语义(status --porcelain 的暂存位、diff 的上下文行),
|
|
||||||
// 整体 TrimSpace 会把它吃掉,导致按固定偏移解析路径时错位。
|
|
||||||
out := strings.TrimRight(outBuf.String(), "\r\n\t ")
|
out := strings.TrimRight(outBuf.String(), "\r\n\t ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
@@ -69,7 +58,6 @@ func currentBranch(path string) (string, error) {
|
|||||||
return gitRun(path, 15*time.Second, "rev-parse", "--abbrev-ref", "HEAD")
|
return gitRun(path, 15*time.Second, "rev-parse", "--abbrev-ref", "HEAD")
|
||||||
}
|
}
|
||||||
|
|
||||||
// aheadBehind 返回 (领先, 落后) 远程跟踪分支的提交数(需先 fetch 才准确)
|
|
||||||
func aheadBehind(path, remote, branch string) (int, int, error) {
|
func aheadBehind(path, remote, branch string) (int, int, error) {
|
||||||
out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+remote+"/"+branch)
|
out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+remote+"/"+branch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,21 +72,16 @@ func aheadBehind(path, remote, branch string) (int, int, error) {
|
|||||||
return a, b, nil
|
return a, b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// remoteRefExists 检查远程跟踪引用是否存在(远程仓库为空或分支名不一致时为 false)
|
|
||||||
func remoteRefExists(path, remote, branch string) bool {
|
func remoteRefExists(path, remote, branch string) bool {
|
||||||
_, err := gitRun(path, 15*time.Second, "rev-parse", "--verify", "-q", remote+"/"+branch)
|
_, err := gitRun(path, 15*time.Second, "rev-parse", "--verify", "-q", remote+"/"+branch)
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// branchExists 检查本地分支是否存在
|
|
||||||
func branchExists(path, branch string) bool {
|
func branchExists(path, branch string) bool {
|
||||||
_, err := gitRun(path, 15*time.Second, "show-ref", "--verify", "-q", "refs/heads/"+branch)
|
_, err := gitRun(path, 15*time.Second, "show-ref", "--verify", "-q", "refs/heads/"+branch)
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// remoteTip 返回远程分支当前指向的 SHA。
|
|
||||||
// 优先读本地远程跟踪引用(refs/remotes/<remote>/<branch>,快);
|
|
||||||
// 若本地跟踪引用不可用(个别环境下 ref 存储读写异常),回退用 ls-remote 直查远程。
|
|
||||||
func remoteTip(path, remote, branch string) (sha string, exists bool) {
|
func remoteTip(path, remote, branch string) (sha string, exists bool) {
|
||||||
if remoteRefExists(path, remote, branch) {
|
if remoteRefExists(path, remote, branch) {
|
||||||
out, err := gitRun(path, 15*time.Second, "rev-parse", remote+"/"+branch)
|
out, err := gitRun(path, 15*time.Second, "rev-parse", remote+"/"+branch)
|
||||||
@@ -117,7 +100,6 @@ func remoteTip(path, remote, branch string) (sha string, exists bool) {
|
|||||||
return fields[0], true
|
return fields[0], true
|
||||||
}
|
}
|
||||||
|
|
||||||
// aheadBehindSha 基于远程分支的具体 SHA 计算 (领先, 落后),不依赖本地跟踪引用
|
|
||||||
func aheadBehindSha(path, tip string) (int, int, error) {
|
func aheadBehindSha(path, tip string) (int, int, error) {
|
||||||
out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+tip)
|
out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+tip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -183,7 +165,6 @@ func abortMerge(path string) (string, error) {
|
|||||||
return gitRun(path, 30*time.Second, "merge", "--abort")
|
return gitRun(path, 30*time.Second, "merge", "--abort")
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveDone 在用户网页上标记"已解决"后调用:暂存全部 → 继续 merge/rebase 流程
|
|
||||||
func resolveDone(path string, rebase bool) error {
|
func resolveDone(path string, rebase bool) error {
|
||||||
if _, err := gitRun(path, 30*time.Second, "add", "-A"); err != nil {
|
if _, err := gitRun(path, 30*time.Second, "add", "-A"); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -199,8 +180,6 @@ func resolveDone(path string, rebase bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// changedFiles 解析 git status --porcelain,返回 路径 -> 状态标记(M/A/D/?),
|
|
||||||
// 供网页端文件树展示每个文件的改动状态。
|
|
||||||
func changedFiles(path string) map[string]string {
|
func changedFiles(path string) map[string]string {
|
||||||
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
|
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
|
||||||
m := map[string]string{}
|
m := map[string]string{}
|
||||||
@@ -213,11 +192,9 @@ func changedFiles(path string) map[string]string {
|
|||||||
}
|
}
|
||||||
code := line[:2]
|
code := line[:2]
|
||||||
p := strings.TrimSpace(line[3:])
|
p := strings.TrimSpace(line[3:])
|
||||||
// 重命名/复制:取目标路径
|
|
||||||
if i := strings.Index(p, "->"); i >= 0 {
|
if i := strings.Index(p, "->"); i >= 0 {
|
||||||
p = strings.TrimSpace(p[i+2:])
|
p = strings.TrimSpace(p[i+2:])
|
||||||
}
|
}
|
||||||
// git 对含特殊字符的路径会加引号
|
|
||||||
p = strings.Trim(p, `"`)
|
p = strings.Trim(p, `"`)
|
||||||
if p == "" {
|
if p == "" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
2026/09/09 15:59:43 Git Sync Web 已启动: http://127.0.0.1:5799(鉴权:false)
|
||||||
@@ -63,7 +63,6 @@ func NewServer(e *Engine, static fs.FS, password string) *Server {
|
|||||||
|
|
||||||
|
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
// 访问密码保护:静态资源开放,/api/* 需要会话(登录接口除外)
|
|
||||||
if s.password != "" && strings.HasPrefix(r.URL.Path, "/api/") &&
|
if s.password != "" && strings.HasPrefix(r.URL.Path, "/api/") &&
|
||||||
r.URL.Path != "/api/login" && !s.validToken(r) {
|
r.URL.Path != "/api/login" && !s.validToken(r) {
|
||||||
writeErr(w, 401, "需要登录")
|
writeErr(w, 401, "需要登录")
|
||||||
@@ -338,7 +337,6 @@ func (s *Server) abortPushRepo(w http.ResponseWriter, r *http.Request, name stri
|
|||||||
writeJSON(w, 200, map[string]any{"ok": true})
|
writeJSON(w, 200, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 差异与文件编辑(网页端) ----------
|
|
||||||
|
|
||||||
func (s *Server) treeRepo(w http.ResponseWriter, r *http.Request, name string) {
|
func (s *Server) treeRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
writeJSON(w, 200, map[string]any{"files": s.engine.FileTree(name)})
|
writeJSON(w, 200, map[string]any{"files": s.engine.FileTree(name)})
|
||||||
@@ -378,7 +376,6 @@ func (s *Server) fileWriteRepo(w http.ResponseWriter, r *http.Request, name stri
|
|||||||
writeJSON(w, 200, map[string]any{"ok": true})
|
writeJSON(w, 200, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveFileRepo 网页端解决单个冲突文件:写入 + 暂存,finish=true 时尝试结束合并/变基
|
|
||||||
func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) {
|
func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
@@ -425,7 +422,6 @@ func (s *Server) diffRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|||||||
if scope == "" {
|
if scope == "" {
|
||||||
scope = "working"
|
scope = "working"
|
||||||
}
|
}
|
||||||
// path 可选:传入时只对比该文件(用于编辑器「对比此文件」)
|
|
||||||
// mode = word 时返回 --word-diff=plain 结果(词级差异)
|
// mode = word 时返回 --word-diff=plain 结果(词级差异)
|
||||||
res, err := s.engine.WorkingDiff(name, scope, r.URL.Query().Get("path"), r.URL.Query().Get("mode"))
|
res, err := s.engine.WorkingDiff(name, scope, r.URL.Query().Get("path"), r.URL.Query().Get("mode"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -435,7 +431,6 @@ func (s *Server) diffRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|||||||
writeJSON(w, 200, res)
|
writeJSON(w, 200, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCronNext 返回 cron 表达式的下次执行时间,供配置表单实时预览
|
|
||||||
func (s *Server) handleCronNext(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCronNext(w http.ResponseWriter, r *http.Request) {
|
||||||
expr := r.URL.Query().Get("expr")
|
expr := r.URL.Query().Get("expr")
|
||||||
if expr == "" {
|
if expr == "" {
|
||||||
|
|||||||
+17
-68
@@ -9,11 +9,9 @@ const STATUS = {
|
|||||||
};
|
};
|
||||||
const RESULT_TEXT = { ok: '成功', error: '失败', conflict: '冲突' };
|
const RESULT_TEXT = { ok: '成功', error: '失败', conflict: '冲突' };
|
||||||
|
|
||||||
// ---------- 相对时间(卡片「上次同步」) ----------
|
|
||||||
const STALE_HOURS = 24; // 超过该小时数且仍在运行 → 标记「久未同步」
|
const STALE_HOURS = 24; // 超过该小时数且仍在运行 → 标记「久未同步」
|
||||||
function parseTs(s) {
|
function parseTs(s) {
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
// 后端时间格式 "2006-01-02 15:04:05",补齐为 ISO 本地时间
|
|
||||||
const t = new Date(String(s).replace(' ', 'T'));
|
const t = new Date(String(s).replace(' ', 'T'));
|
||||||
return isNaN(t.getTime()) ? null : t;
|
return isNaN(t.getTime()) ? null : t;
|
||||||
}
|
}
|
||||||
@@ -39,7 +37,6 @@ let current = null;
|
|||||||
let creating = false;
|
let creating = false;
|
||||||
let chart = null;
|
let chart = null;
|
||||||
|
|
||||||
// ---------- 基础 ----------
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
}
|
}
|
||||||
@@ -56,10 +53,8 @@ async function api(url, opts) {
|
|||||||
}
|
}
|
||||||
const post = (url) => api(url, { method: 'POST' });
|
const post = (url) => api(url, { method: 'POST' });
|
||||||
|
|
||||||
// ---------- Toast 轻提示(替代原生 alert,避免打断操作) ----------
|
|
||||||
function toast(msg, type) {
|
function toast(msg, type) {
|
||||||
const box = $('#toasts');
|
const box = $('#toasts');
|
||||||
// 兜底:容器缺失时不能递归调用自身
|
|
||||||
if (!box) { console.warn('toast 容器缺失:', msg); return; }
|
if (!box) { console.warn('toast 容器缺失:', msg); return; }
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'toast t-' + (type || 'info');
|
el.className = 'toast t-' + (type || 'info');
|
||||||
@@ -75,9 +70,6 @@ function toast(msg, type) {
|
|||||||
}, life);
|
}, life);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 编辑器抽象:统一原生 textarea 与 CodeMirror 的读写 ----------
|
|
||||||
// CodeMirror 由 cm-boot.js 在脚本加载后接管并写入 Editor.cm;
|
|
||||||
// 库缺失 / 初始化失败时 Editor.cm 保持 null,自动回退原生 textarea,功能不受影响。
|
|
||||||
const Editor = {
|
const Editor = {
|
||||||
cm: null,
|
cm: null,
|
||||||
get el() { return $('#file-editor'); },
|
get el() { return $('#file-editor'); },
|
||||||
@@ -98,7 +90,6 @@ const Editor = {
|
|||||||
if (this.cm) this.cm.setOption('readOnly', b ? 'nocursor' : false);
|
if (this.cm) this.cm.setOption('readOnly', b ? 'nocursor' : false);
|
||||||
if (this.el) this.el.disabled = !!b;
|
if (this.el) this.el.disabled = !!b;
|
||||||
},
|
},
|
||||||
// 按扩展名选择语法高亮模式
|
|
||||||
setMode(path) {
|
setMode(path) {
|
||||||
if (!this.cm) return;
|
if (!this.cm) return;
|
||||||
const ext = String(path || '').split('.').pop().toLowerCase();
|
const ext = String(path || '').split('.').pop().toLowerCase();
|
||||||
@@ -121,15 +112,12 @@ const Editor = {
|
|||||||
setTheme(dark) {
|
setTheme(dark) {
|
||||||
if (this.cm) this.cm.setOption('theme', dark ? 'material-darker' : 'eclipse');
|
if (this.cm) this.cm.setOption('theme', dark ? 'material-darker' : 'eclipse');
|
||||||
},
|
},
|
||||||
// 弹窗 / tab 切换后需要重算尺寸,否则 CodeMirror 可能不显示内容
|
|
||||||
refresh() { if (this.cm) setTimeout(() => this.cm.refresh(), 30); },
|
refresh() { if (this.cm) setTimeout(() => this.cm.refresh(), 30); },
|
||||||
onSave: null, // 由 cm-boot.js 的快捷键触发
|
onSave: null, // 由 cm-boot.js 的快捷键触发
|
||||||
onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存)
|
onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存)
|
||||||
};
|
};
|
||||||
// 顶层 const 不会挂到 window,显式暴露给 cm-boot.js 使用
|
|
||||||
window.Editor = Editor;
|
window.Editor = Editor;
|
||||||
|
|
||||||
// ---------- 渲染 ----------
|
|
||||||
function render() {
|
function render() {
|
||||||
const grid = $('#grid');
|
const grid = $('#grid');
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
@@ -192,7 +180,6 @@ $('#grid').addEventListener('click', async (e) => {
|
|||||||
} catch (err) { toast(err.message, 'error'); }
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- SSE 实时事件 ----------
|
|
||||||
function connect() {
|
function connect() {
|
||||||
const es = new EventSource('/api/events');
|
const es = new EventSource('/api/events');
|
||||||
es.onopen = () => setConn(true);
|
es.onopen = () => setConn(true);
|
||||||
@@ -227,7 +214,6 @@ function setConn(ok) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 全局实时活动面板 ----------
|
|
||||||
const LIVE_MAX = 400;
|
const LIVE_MAX = 400;
|
||||||
function appendLiveLine(msg) {
|
function appendLiveLine(msg) {
|
||||||
const box = $('#live-log');
|
const box = $('#live-log');
|
||||||
@@ -248,7 +234,6 @@ function clearLive() {
|
|||||||
if (box) box.innerHTML = '';
|
if (box) box.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 载入各仓库最近日志作为初始上下文,避免面板一开始空白(仅启动播种一次)
|
|
||||||
let liveSeeded = false;
|
let liveSeeded = false;
|
||||||
async function seedLive() {
|
async function seedLive() {
|
||||||
if (liveSeeded) return;
|
if (liveSeeded) return;
|
||||||
@@ -259,7 +244,6 @@ async function seedLive() {
|
|||||||
const d = await api(`/api/repos/${name}/log?lines=8`);
|
const d = await api(`/api/repos/${name}/log?lines=8`);
|
||||||
if (d.lines && d.lines.length) {
|
if (d.lines && d.lines.length) {
|
||||||
d.lines.forEach(t => {
|
d.lines.forEach(t => {
|
||||||
// 兼容新格式 [时间] [级别] 消息 与历史格式 [时间] 消息
|
|
||||||
const m = t.match(/^\[(.+?)\]\s*(?:\[(info|warn|error)\]\s*)?(.*)$/i);
|
const m = t.match(/^\[(.+?)\]\s*(?:\[(info|warn|error)\]\s*)?(.*)$/i);
|
||||||
appendLiveLine({
|
appendLiveLine({
|
||||||
time: m ? m[1] : '',
|
time: m ? m[1] : '',
|
||||||
@@ -276,7 +260,6 @@ async function seedLive() {
|
|||||||
|
|
||||||
$('#btn-live-clear').addEventListener('click', clearLive);
|
$('#btn-live-clear').addEventListener('click', clearLive);
|
||||||
|
|
||||||
// ---------- 登录 ----------
|
|
||||||
function showLogin() {
|
function showLogin() {
|
||||||
const el = $('#login');
|
const el = $('#login');
|
||||||
if (el.hidden) {
|
if (el.hidden) {
|
||||||
@@ -308,7 +291,6 @@ async function doLogin() {
|
|||||||
$('#btn-login').addEventListener('click', doLogin);
|
$('#btn-login').addEventListener('click', doLogin);
|
||||||
$('#login-password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
$('#login-password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||||||
|
|
||||||
// ---------- 详情弹窗 ----------
|
|
||||||
function showTab(t) {
|
function showTab(t) {
|
||||||
document.querySelectorAll('#m-tabs .tab').forEach(b => b.classList.toggle('active', b.dataset.tab === t));
|
document.querySelectorAll('#m-tabs .tab').forEach(b => b.classList.toggle('active', b.dataset.tab === t));
|
||||||
document.querySelectorAll('.tab-pane').forEach(p => { p.hidden = p.id !== 'tab-' + t; });
|
document.querySelectorAll('.tab-pane').forEach(p => { p.hidden = p.id !== 'tab-' + t; });
|
||||||
@@ -359,12 +341,10 @@ $('#m-close').addEventListener('click', closeModal);
|
|||||||
$('#modal').addEventListener('click', (e) => { if (e.target === $('#modal')) closeModal(); });
|
$('#modal').addEventListener('click', (e) => { if (e.target === $('#modal')) closeModal(); });
|
||||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
|
||||||
|
|
||||||
// ---------- 日志(级别过滤 + 关键词搜索) ----------
|
|
||||||
let logLines = [];
|
let logLines = [];
|
||||||
let logLevel = 'all';
|
let logLevel = 'all';
|
||||||
let logQuery = '';
|
let logQuery = '';
|
||||||
|
|
||||||
// 落盘格式:[时间] [级别] 消息;历史日志无级别前缀时按 info 处理
|
|
||||||
function logLevelOf(line) {
|
function logLevelOf(line) {
|
||||||
const m = /^\[[^\]]+\]\s*\[(info|warn|error)\]/i.exec(line);
|
const m = /^\[[^\]]+\]\s*\[(info|warn|error)\]/i.exec(line);
|
||||||
return m ? m[1].toLowerCase() : 'info';
|
return m ? m[1].toLowerCase() : 'info';
|
||||||
@@ -405,7 +385,6 @@ $('#btn-log-clear').addEventListener('click', () => {
|
|||||||
renderLog();
|
renderLog();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 提交历史 ----------
|
|
||||||
function loadHistory(name) {
|
function loadHistory(name) {
|
||||||
const list = $('#commit-list');
|
const list = $('#commit-list');
|
||||||
list.textContent = '加载中…';
|
list.textContent = '加载中…';
|
||||||
@@ -442,7 +421,6 @@ $('#commit-list').addEventListener('click', async (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 冲突 ----------
|
|
||||||
function loadConflicts(name) {
|
function loadConflicts(name) {
|
||||||
api(`/api/repos/${name}/conflicts`).then(files => {
|
api(`/api/repos/${name}/conflicts`).then(files => {
|
||||||
const list = $('#conflict-list');
|
const list = $('#conflict-list');
|
||||||
@@ -471,7 +449,6 @@ function loadConflicts(name) {
|
|||||||
}).catch(err => { $('#conflict-list').innerHTML = `<p style="color:var(--red)">${esc(err.message)}</p>`; });
|
}).catch(err => { $('#conflict-list').innerHTML = `<p style="color:var(--red)">${esc(err.message)}</p>`; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 冲突文件内联编辑:展开编辑区 → 读取原文 → 保存 / 保存并标记已解决
|
|
||||||
$('#conflict-list').addEventListener('click', async (e) => {
|
$('#conflict-list').addEventListener('click', async (e) => {
|
||||||
const btn = e.target.closest('button[data-cf]');
|
const btn = e.target.closest('button[data-cf]');
|
||||||
if (!btn || !current) return;
|
if (!btn || !current) return;
|
||||||
@@ -511,7 +488,6 @@ $('#conflict-list').addEventListener('click', async (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// save / resolve
|
|
||||||
if (ta.disabled) { toast('该文件当前不可编辑', 'error'); return; }
|
if (ta.disabled) { toast('该文件当前不可编辑', 'error'); return; }
|
||||||
try {
|
try {
|
||||||
await api(`/api/repos/${current}/resolve-file`, {
|
await api(`/api/repos/${current}/resolve-file`, {
|
||||||
@@ -546,12 +522,10 @@ $('#btn-abort').addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
$('#btn-resolved').addEventListener('click', () => doAction('resolve', '冲突处理流程完成'));
|
$('#btn-resolved').addEventListener('click', () => doAction('resolve', '冲突处理流程完成'));
|
||||||
|
|
||||||
// 手动拉取 / 推送 / 获取(B1:后端 ManualGit 已支持)
|
|
||||||
$('#btn-fetch').addEventListener('click', () => doAction('fetch', '获取完成'));
|
$('#btn-fetch').addEventListener('click', () => doAction('fetch', '获取完成'));
|
||||||
$('#btn-pull').addEventListener('click', () => doAction('pull', '拉取完成'));
|
$('#btn-pull').addEventListener('click', () => doAction('pull', '拉取完成'));
|
||||||
$('#btn-push').addEventListener('click', () => doAction('push', '推送完成'));
|
$('#btn-push').addEventListener('click', () => doAction('push', '推送完成'));
|
||||||
|
|
||||||
// 推送冲突(non-fast-forward)的解决入口(B3)
|
|
||||||
$('#btn-force-push').addEventListener('click', async () => {
|
$('#btn-force-push').addEventListener('click', async () => {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
try {
|
try {
|
||||||
@@ -569,7 +543,6 @@ $('#btn-abort-push').addEventListener('click', async () => {
|
|||||||
} catch (err) { toast(err.message, 'error'); }
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// 根据当前仓库快照切换推送冲突操作区显隐(B3)
|
|
||||||
function updateConflictActions() {
|
function updateConflictActions() {
|
||||||
const r = repos.find(x => x.name === current);
|
const r = repos.find(x => x.name === current);
|
||||||
const box = $('#push-conflict-actions');
|
const box = $('#push-conflict-actions');
|
||||||
@@ -583,10 +556,7 @@ function updateConflictActions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 差异显示(网页端) ----------
|
|
||||||
// diffPath 非空时只显示单个文件的差异(编辑器「对比此文件」进入)
|
|
||||||
let diffPath = '';
|
let diffPath = '';
|
||||||
// diffText 保存当前差异原文,供「复制 / 下载 .patch」使用
|
|
||||||
let diffText = '';
|
let diffText = '';
|
||||||
|
|
||||||
function updateDiffFilterNote() {
|
function updateDiffFilterNote() {
|
||||||
@@ -626,8 +596,6 @@ function loadDiff(name) {
|
|||||||
}).catch(err => { box.textContent = '差异加载失败:' + err.message; });
|
}).catch(err => { box.textContent = '差异加载失败:' + err.message; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 差异渲染:统一 / 并排 / 词级 ----------
|
|
||||||
// 把 unified diff 解析成「文件 → 行记录」,统一与并排两种视图共用同一份结构
|
|
||||||
function parseUnifiedDiff(text) {
|
function parseUnifiedDiff(text) {
|
||||||
const lines = String(text).split('\n');
|
const lines = String(text).split('\n');
|
||||||
const files = [];
|
const files = [];
|
||||||
@@ -657,7 +625,6 @@ function parseUnifiedDiff(text) {
|
|||||||
if (line.startsWith(' ')) { cur.rows.push({ type: 'ctx', text: line.slice(1), no: oldNo++, nNo: newNo++ }); 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; }
|
if (line === '') { cur.rows.push({ type: 'ctx', text: '', no: oldNo++, nNo: newNo++ }); continue; }
|
||||||
// 词级模式(--word-diff=plain)下 git 的上下文行不带前导空格,
|
// 词级模式(--word-diff=plain)下 git 的上下文行不带前导空格,
|
||||||
// 因此 hunk 内未识别的行一律按上下文处理;hunk 外的(如 "\ No newline")按元信息处理。
|
|
||||||
if (inHunk && !line.startsWith('\\')) {
|
if (inHunk && !line.startsWith('\\')) {
|
||||||
cur.rows.push({ type: 'ctx', text: line, no: oldNo++, nNo: newNo++ });
|
cur.rows.push({ type: 'ctx', text: line, no: oldNo++, nNo: newNo++ });
|
||||||
continue;
|
continue;
|
||||||
@@ -667,7 +634,6 @@ function parseUnifiedDiff(text) {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 词级:把 [-删除-] / {+新增+} 渲染成高亮片段(textContent 拼接,天然防 XSS)
|
|
||||||
function fillWord(el, text) {
|
function fillWord(el, text) {
|
||||||
const re = /\[-([\s\S]*?)-\]|\{\+([\s\S]*?)\+\}/g;
|
const re = /\[-([\s\S]*?)-\]|\{\+([\s\S]*?)\+\}/g;
|
||||||
let last = 0, m;
|
let last = 0, m;
|
||||||
@@ -685,7 +651,6 @@ function fillWord(el, text) {
|
|||||||
if (!el.childNodes.length) el.appendChild(document.createTextNode(text));
|
if (!el.childNodes.length) el.appendChild(document.createTextNode(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 并排:把连续的 del/add 段配对成行,多余的一侧留空
|
|
||||||
function buildSplitRows(rows) {
|
function buildSplitRows(rows) {
|
||||||
const out = [];
|
const out = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -693,7 +658,6 @@ function buildSplitRows(rows) {
|
|||||||
const r = rows[i];
|
const r = rows[i];
|
||||||
if (r.type === 'hunk' || r.type === 'meta') { out.push({ kind: r.type, text: r.text }); i++; continue; }
|
if (r.type === 'hunk' || r.type === 'meta') { out.push({ kind: r.type, text: r.text }); i++; continue; }
|
||||||
if (r.type === 'ctx') {
|
if (r.type === 'ctx') {
|
||||||
// 左右列行号分别取旧/新行号,故右侧用副本
|
|
||||||
out.push({ kind: 'ctx', left: r, right: { text: r.text, no: r.nNo == null ? r.no : r.nNo } });
|
out.push({ kind: 'ctx', left: r, right: { text: r.text, no: r.nNo == null ? r.no : r.nNo } });
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
@@ -793,7 +757,6 @@ function renderDiff(box, text) {
|
|||||||
box.appendChild(frag);
|
box.appendChild(frag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 文件编辑(网页端) ----------
|
|
||||||
let fileCurrent = null;
|
let fileCurrent = null;
|
||||||
const STATUS_LABEL = { M: '已修改', A: '已新增', D: '已删除', '?': '未跟踪' };
|
const STATUS_LABEL = { M: '已修改', A: '已新增', D: '已删除', '?': '未跟踪' };
|
||||||
function loadTree(name) {
|
function loadTree(name) {
|
||||||
@@ -813,7 +776,6 @@ function loadTree(name) {
|
|||||||
}).catch(err => { list.textContent = '文件列表加载失败:' + err.message; });
|
}).catch(err => { list.textContent = '文件列表加载失败:' + err.message; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 草稿缓存(localStorage,防刷新丢稿) ----------
|
|
||||||
const draftKey = (name, path) => `gitautosync:draft:${name}:${path}`;
|
const draftKey = (name, path) => `gitautosync:draft:${name}:${path}`;
|
||||||
let draftServer = ''; // 当前文件在磁盘上的原文,用于「放弃草稿」回滚
|
let draftServer = ''; // 当前文件在磁盘上的原文,用于「放弃草稿」回滚
|
||||||
let draftTimer = null;
|
let draftTimer = null;
|
||||||
@@ -828,12 +790,11 @@ function showDraftNote(on) {
|
|||||||
if (a) a.addEventListener('click', (e) => {
|
if (a) a.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
localStorage.removeItem(draftKey(current, fileCurrent));
|
localStorage.removeItem(draftKey(current, fileCurrent));
|
||||||
$('#file-editor').value = draftServer;
|
Editor.value = draftServer;
|
||||||
showDraftNote(false);
|
showDraftNote(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑时防抖缓存草稿;内容与磁盘一致时自动清除
|
|
||||||
function scheduleDraft() {
|
function scheduleDraft() {
|
||||||
if (!current || !fileCurrent) return;
|
if (!current || !fileCurrent) return;
|
||||||
if (draftTimer) clearTimeout(draftTimer);
|
if (draftTimer) clearTimeout(draftTimer);
|
||||||
@@ -846,32 +807,35 @@ function scheduleDraft() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadFile(name, path) {
|
async function loadFile(name, path) {
|
||||||
const editor = $('#file-editor');
|
Editor._suspend = true;
|
||||||
editor.value = '加载中…';
|
try {
|
||||||
editor.disabled = false;
|
Editor.value = '加载中…';
|
||||||
|
Editor.disabled = false;
|
||||||
|
Editor.setMode(path);
|
||||||
$('#file-current').textContent = path;
|
$('#file-current').textContent = path;
|
||||||
fileCurrent = path;
|
fileCurrent = path;
|
||||||
showDraftNote(false);
|
showDraftNote(false);
|
||||||
try {
|
|
||||||
const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`);
|
const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`);
|
||||||
if (d.binary) {
|
if (d.binary) {
|
||||||
editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
|
Editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
|
||||||
editor.disabled = true;
|
Editor.disabled = true;
|
||||||
draftServer = '';
|
draftServer = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
draftServer = d.content || '';
|
draftServer = d.content || '';
|
||||||
const saved = localStorage.getItem(draftKey(name, path));
|
const saved = localStorage.getItem(draftKey(name, path));
|
||||||
if (saved !== null && saved !== draftServer) {
|
if (saved !== null && saved !== draftServer) {
|
||||||
editor.value = saved;
|
Editor.value = saved;
|
||||||
showDraftNote(true);
|
showDraftNote(true);
|
||||||
} else {
|
} else {
|
||||||
editor.value = draftServer;
|
Editor.value = draftServer;
|
||||||
}
|
}
|
||||||
editor.disabled = false;
|
Editor.disabled = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
editor.value = '读取失败:' + err.message;
|
Editor.value = '读取失败:' + err.message;
|
||||||
editor.disabled = true;
|
Editor.disabled = true;
|
||||||
|
} finally {
|
||||||
|
Editor._suspend = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,13 +846,11 @@ async function saveFile(commit) {
|
|||||||
if (Editor.disabled) { toast('当前文件不可编辑(二进制或读取失败)'); return; }
|
if (Editor.disabled) { toast('当前文件不可编辑(二进制或读取失败)'); return; }
|
||||||
const content = Editor.value;
|
const content = Editor.value;
|
||||||
try {
|
try {
|
||||||
// 先写盘
|
|
||||||
await api(`/api/repos/${name}/file`, {
|
await api(`/api/repos/${name}/file`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ path, content })
|
body: JSON.stringify({ path, content })
|
||||||
});
|
});
|
||||||
// 写盘成功 → 草稿作废
|
|
||||||
localStorage.removeItem(draftKey(name, path));
|
localStorage.removeItem(draftKey(name, path));
|
||||||
showDraftNote(false);
|
showDraftNote(false);
|
||||||
draftServer = content;
|
draftServer = content;
|
||||||
@@ -900,7 +862,6 @@ async function saveFile(commit) {
|
|||||||
});
|
});
|
||||||
toast('已保存并提交');
|
toast('已保存并提交');
|
||||||
} else {
|
} else {
|
||||||
// 保存后自动切到差异预览
|
|
||||||
$('#f-diff-scope').value = 'working';
|
$('#f-diff-scope').value = 'working';
|
||||||
showTab('diff');
|
showTab('diff');
|
||||||
toast('已保存到磁盘(未提交)。可在「差异」tab 查看改动,或稍后由自动同步提交。');
|
toast('已保存到磁盘(未提交)。可在「差异」tab 查看改动,或稍后由自动同步提交。');
|
||||||
@@ -909,12 +870,10 @@ async function saveFile(commit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
|
$('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
|
||||||
// 并排/统一纯前端重渲染(同一份 diff 原文);词级需后端重新生成,故重新拉取
|
|
||||||
$('#f-diff-view').addEventListener('change', () => { renderDiff($('#diffbox'), diffText || '(无差异)'); });
|
$('#f-diff-view').addEventListener('change', () => { renderDiff($('#diffbox'), diffText || '(无差异)'); });
|
||||||
$('#f-diff-word').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
|
$('#f-diff-word').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
|
||||||
$('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(current); });
|
$('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(current); });
|
||||||
|
|
||||||
// 差异:复制 / 下载 .patch
|
|
||||||
$('#btn-diff-copy').addEventListener('click', async () => {
|
$('#btn-diff-copy').addEventListener('click', async () => {
|
||||||
if (!diffText) { toast('当前没有可复制的差异内容'); return; }
|
if (!diffText) { toast('当前没有可复制的差异内容'); return; }
|
||||||
try {
|
try {
|
||||||
@@ -939,7 +898,6 @@ $('#btn-diff-download').addEventListener('click', () => {
|
|||||||
|
|
||||||
$('#btn-file-refresh').addEventListener('click', () => { if (current) loadTree(current); });
|
$('#btn-file-refresh').addEventListener('click', () => { if (current) loadTree(current); });
|
||||||
|
|
||||||
// 编辑器「对比此文件」→ 跳到差异 tab 并只看该文件
|
|
||||||
$('#btn-file-diff').addEventListener('click', () => {
|
$('#btn-file-diff').addEventListener('click', () => {
|
||||||
if (!current || !fileCurrent) { toast('请先在左侧选择一个文件'); return; }
|
if (!current || !fileCurrent) { toast('请先在左侧选择一个文件'); return; }
|
||||||
diffPath = fileCurrent;
|
diffPath = fileCurrent;
|
||||||
@@ -955,7 +913,6 @@ $('#file-list').addEventListener('click', (e) => {
|
|||||||
$('#btn-file-save').addEventListener('click', () => saveFile(false));
|
$('#btn-file-save').addEventListener('click', () => saveFile(false));
|
||||||
$('#btn-file-commit').addEventListener('click', () => saveFile(true));
|
$('#btn-file-commit').addEventListener('click', () => saveFile(true));
|
||||||
|
|
||||||
// 编辑器:输入时缓存草稿,Ctrl/Cmd + S 直接保存
|
|
||||||
$('#file-editor').addEventListener('input', scheduleDraft);
|
$('#file-editor').addEventListener('input', scheduleDraft);
|
||||||
$('#file-editor').addEventListener('keydown', (e) => {
|
$('#file-editor').addEventListener('keydown', (e) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
||||||
@@ -963,8 +920,9 @@ $('#file-editor').addEventListener('keydown', (e) => {
|
|||||||
saveFile(false);
|
saveFile(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
Editor.onChange = scheduleDraft;
|
||||||
|
Editor.onSave = () => saveFile(false);
|
||||||
|
|
||||||
// ---------- 统计 ----------
|
|
||||||
function dateStr(d) {
|
function dateStr(d) {
|
||||||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||||
}
|
}
|
||||||
@@ -977,7 +935,6 @@ function loadStats(name) {
|
|||||||
d.setDate(d.getDate() - i);
|
d.setDate(d.getDate() - i);
|
||||||
days.push(dateStr(d));
|
days.push(dateStr(d));
|
||||||
}
|
}
|
||||||
// 后端写入的 result 词汇是 success/failed/conflict,这里归一化到 ok/error/conflict 再统计
|
|
||||||
const normResult = r => r.result === 'success' ? 'ok' : r.result === 'failed' ? 'error' : r.result;
|
const normResult = r => r.result === 'success' ? 'ok' : r.result === 'failed' ? 'error' : r.result;
|
||||||
const mk = (key) => days.map(d => rows.filter(r => normResult(r) === key && r.time.slice(0, 10) === d).length);
|
const mk = (key) => days.map(d => rows.filter(r => normResult(r) === key && r.time.slice(0, 10) === d).length);
|
||||||
const total = { ok: 0, error: 0, conflict: 0 };
|
const total = { ok: 0, error: 0, conflict: 0 };
|
||||||
@@ -1011,7 +968,6 @@ function loadStats(name) {
|
|||||||
}).catch(err => { $('#stat-summary').textContent = '统计加载失败:' + err.message; });
|
}).catch(err => { $('#stat-summary').textContent = '统计加载失败:' + err.message; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 配置表单 ----------
|
|
||||||
function fillForm(r) {
|
function fillForm(r) {
|
||||||
$('#f-name').value = r ? r.name : '';
|
$('#f-name').value = r ? r.name : '';
|
||||||
$('#f-path').value = r ? r.repo_path : '';
|
$('#f-path').value = r ? r.repo_path : '';
|
||||||
@@ -1090,8 +1046,6 @@ $('#btn-delete').addEventListener('click', async () => {
|
|||||||
} catch (err) { toast(err.message, 'error'); }
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 配置「克隆」按钮(多仓库同构时省得重填) ----------
|
|
||||||
// 复用 readForm 取当前表单值 → 切到新建态:名称自动加 -copy(去重),仓库路径清空
|
|
||||||
$('#btn-clone').addEventListener('click', () => {
|
$('#btn-clone').addEventListener('click', () => {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
const base = readForm();
|
const base = readForm();
|
||||||
@@ -1115,7 +1069,6 @@ $('#btn-clone').addEventListener('click', () => {
|
|||||||
$('#f-name').focus();
|
$('#f-name').focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 顶部按钮 ----------
|
|
||||||
$('#btn-new').addEventListener('click', openCreate);
|
$('#btn-new').addEventListener('click', openCreate);
|
||||||
$('#btn-empty-new').addEventListener('click', openCreate);
|
$('#btn-empty-new').addEventListener('click', openCreate);
|
||||||
$('#btn-sync-all').addEventListener('click', () => {
|
$('#btn-sync-all').addEventListener('click', () => {
|
||||||
@@ -1123,7 +1076,6 @@ $('#btn-sync-all').addEventListener('click', () => {
|
|||||||
toast(`已向 ${repos.filter(r => r.running).length} 个运行中的仓库发起同步`);
|
toast(`已向 ${repos.filter(r => r.running).length} 个运行中的仓库发起同步`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 批量启停:并发调用,汇总成功/失败
|
|
||||||
async function batchToggle(act) {
|
async function batchToggle(act) {
|
||||||
const isStart = act === 'start';
|
const isStart = act === 'start';
|
||||||
const targets = repos.filter(r => isStart ? !r.running : r.running);
|
const targets = repos.filter(r => isStart ? !r.running : r.running);
|
||||||
@@ -1142,7 +1094,6 @@ async function batchToggle(act) {
|
|||||||
$('#btn-start-all').addEventListener('click', () => batchToggle('start'));
|
$('#btn-start-all').addEventListener('click', () => batchToggle('start'));
|
||||||
$('#btn-stop-all').addEventListener('click', () => batchToggle('stop'));
|
$('#btn-stop-all').addEventListener('click', () => batchToggle('stop'));
|
||||||
|
|
||||||
// ---------- 暗色模式 ----------
|
|
||||||
const THEME_KEY = 'gitautosync:theme';
|
const THEME_KEY = 'gitautosync:theme';
|
||||||
function applyTheme(dark) {
|
function applyTheme(dark) {
|
||||||
document.documentElement.classList.toggle('dark', !!dark);
|
document.documentElement.classList.toggle('dark', !!dark);
|
||||||
@@ -1159,7 +1110,6 @@ $('#btn-theme').addEventListener('click', () => {
|
|||||||
applyTheme(dark);
|
applyTheme(dark);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- Cron 下次执行预览 ----------
|
|
||||||
let cronTimer = null;
|
let cronTimer = null;
|
||||||
async function previewCron() {
|
async function previewCron() {
|
||||||
const el = $('#cron-next');
|
const el = $('#cron-next');
|
||||||
@@ -1180,7 +1130,6 @@ $('#f-cron').addEventListener('input', () => {
|
|||||||
cronTimer = setTimeout(previewCron, 400);
|
cronTimer = setTimeout(previewCron, 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 初始化 ----------
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const d = await api('/api/status');
|
const d = await api('/api/status');
|
||||||
|
|||||||
+1
-5
@@ -1,5 +1,3 @@
|
|||||||
// CodeMirror 引导:在 codemirror.min.js 及其 mode 加载完成后接管 #file-editor。
|
|
||||||
// 若库未加载或初始化失败,window.Editor.cm 保持 null,自动回退原生 textarea,功能不受影响。
|
|
||||||
(function () {
|
(function () {
|
||||||
if (typeof window.CodeMirror === 'undefined' || typeof window.Editor === 'undefined') return;
|
if (typeof window.CodeMirror === 'undefined' || typeof window.Editor === 'undefined') return;
|
||||||
var ta = document.getElementById('file-editor');
|
var ta = document.getElementById('file-editor');
|
||||||
@@ -19,9 +17,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.Editor.cm = cm;
|
window.Editor.cm = cm;
|
||||||
// CodeMirror 的 change 事件桥接到草稿缓存
|
cm.on('change', function () { if (window.Editor._suspend) return; if (window.Editor.onChange) window.Editor.onChange(); });
|
||||||
cm.on('change', function () { if (window.Editor.onChange) window.Editor.onChange(); });
|
|
||||||
// 模式跟随打开文件时的 setMode
|
|
||||||
var cur = window.Editor.value;
|
var cur = window.Editor.value;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (window.console) console.warn('CodeMirror 初始化失败,已回退原生编辑器:', e);
|
if (window.console) console.warn('CodeMirror 初始化失败,已回退原生编辑器:', e);
|
||||||
|
|||||||
Reference in New Issue
Block a user