Compare commits
11
Commits
main
..
7c9be4b44b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c9be4b44b | ||
|
|
1bf1f72901 | ||
|
|
19099e5267 | ||
|
|
5ddf1aa8de | ||
|
|
4f7b479cec | ||
|
|
281cc6a1d1 | ||
|
|
e726267e5e | ||
|
|
8f4a464e5f | ||
|
|
4a0b2f60df | ||
|
|
f9a8769810 | ||
|
|
a074a80f4d |
@@ -101,12 +101,8 @@ $RECYCLE.BIN/
|
|||||||
|
|
||||||
#logs
|
#logs
|
||||||
logs/*
|
logs/*
|
||||||
*.log
|
|
||||||
|
|
||||||
# 运行时生成的统计/日志(避免自动提交把运行产物入库)
|
# 运行时生成的统计/日志(避免自动提交把运行产物入库)
|
||||||
stats/
|
stats/
|
||||||
|
|
||||||
#configs
|
|
||||||
configs/*
|
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows
|
# End of https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# 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>`,核心功能不受影响。
|
|
||||||
@@ -26,15 +26,14 @@ type RepoConfig struct {
|
|||||||
RetryCount int `json:"retry_count"`
|
RetryCount int `json:"retry_count"`
|
||||||
RetryInterval int `json:"retry_interval"`
|
RetryInterval int `json:"retry_interval"`
|
||||||
ConflictStrategy string `json:"conflict_strategy"`
|
ConflictStrategy string `json:"conflict_strategy"`
|
||||||
PushStrategy string `json:"push_strategy"`
|
|
||||||
AutoCommit bool `json:"auto_commit"`
|
AutoCommit bool `json:"auto_commit"`
|
||||||
AutoSync bool `json:"auto_sync"`
|
|
||||||
Cron string `json:"cron"`
|
Cron string `json:"cron"`
|
||||||
}
|
}
|
||||||
|
|
||||||
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] == '/' {
|
||||||
@@ -107,9 +106,7 @@ func loadRepoConfig(dir, name string) (*RepoConfig, error) {
|
|||||||
c.RetryCount = atoiOr(m["RETRY_COUNT"], 0)
|
c.RetryCount = atoiOr(m["RETRY_COUNT"], 0)
|
||||||
c.RetryInterval = atoiOr(m["RETRY_INTERVAL"], 0)
|
c.RetryInterval = atoiOr(m["RETRY_INTERVAL"], 0)
|
||||||
c.ConflictStrategy = orDefault(m["CONFLICT_STRATEGY"], "merge")
|
c.ConflictStrategy = orDefault(m["CONFLICT_STRATEGY"], "merge")
|
||||||
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")
|
||||||
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 != "" {
|
||||||
for _, pair := range strings.Split(remotes, ",") {
|
for _, pair := range strings.Split(remotes, ",") {
|
||||||
@@ -124,6 +121,7 @@ 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"]})
|
||||||
}
|
}
|
||||||
@@ -149,17 +147,11 @@ func saveRepoConfig(dir string, c *RepoConfig) error {
|
|||||||
fmt.Fprintf(&b, "RETRY_COUNT=%s\n", q(strconv.Itoa(c.RetryCount)))
|
fmt.Fprintf(&b, "RETRY_COUNT=%s\n", q(strconv.Itoa(c.RetryCount)))
|
||||||
fmt.Fprintf(&b, "RETRY_INTERVAL=%s\n", q(strconv.Itoa(c.RetryInterval)))
|
fmt.Fprintf(&b, "RETRY_INTERVAL=%s\n", q(strconv.Itoa(c.RetryInterval)))
|
||||||
fmt.Fprintf(&b, "CONFLICT_STRATEGY=%s\n", q(c.ConflictStrategy))
|
fmt.Fprintf(&b, "CONFLICT_STRATEGY=%s\n", q(c.ConflictStrategy))
|
||||||
fmt.Fprintf(&b, "PUSH_STRATEGY=%s\n", q(c.PushStrategy))
|
|
||||||
ac := "0"
|
ac := "0"
|
||||||
if c.AutoCommit {
|
if c.AutoCommit {
|
||||||
ac = "1"
|
ac = "1"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "AUTO_COMMIT=%s\n", q(ac))
|
fmt.Fprintf(&b, "AUTO_COMMIT=%s\n", q(ac))
|
||||||
as := "0"
|
|
||||||
if c.AutoSync {
|
|
||||||
as = "1"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "AUTO_SYNC=%s\n", q(as))
|
|
||||||
fmt.Fprintf(&b, "CRON=%s\n", q(c.Cron))
|
fmt.Fprintf(&b, "CRON=%s\n", q(c.Cron))
|
||||||
return os.WriteFile(filepath.Join(dir, c.Name+".conf"), []byte(b.String()), 0o644)
|
return os.WriteFile(filepath.Join(dir, c.Name+".conf"), []byte(b.String()), 0o644)
|
||||||
}
|
}
|
||||||
@@ -205,11 +197,6 @@ func validateRepoConfig(cfgDir string, c *RepoConfig, isCreate bool) error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("冲突策略必须是 merge/rebase/abort")
|
return fmt.Errorf("冲突策略必须是 merge/rebase/abort")
|
||||||
}
|
}
|
||||||
switch c.PushStrategy {
|
|
||||||
case "manual", "rebase", "force":
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("推送策略必须是 manual/rebase/force")
|
|
||||||
}
|
|
||||||
if c.Interval < 5 {
|
if c.Interval < 5 {
|
||||||
c.Interval = 60
|
c.Interval = 60
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,5 @@ AUTH_METHOD="https"
|
|||||||
RETRY_COUNT="0"
|
RETRY_COUNT="0"
|
||||||
RETRY_INTERVAL="0"
|
RETRY_INTERVAL="0"
|
||||||
CONFLICT_STRATEGY="merge"
|
CONFLICT_STRATEGY="merge"
|
||||||
PUSH_STRATEGY="manual"
|
AUTO_COMMIT="1"
|
||||||
AUTO_COMMIT="0"
|
|
||||||
AUTO_SYNC="0"
|
|
||||||
CRON=""
|
CRON=""
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ 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
|
||||||
@@ -90,14 +92,7 @@ func parseCronField(f string, min, max int) (map[int]bool, error) {
|
|||||||
return set, nil
|
return set, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cronNextString(expr string) (string, error) {
|
// cronNext 返回 spec 在 after 之后的下一次触发时间(分钟精度,最多向前搜索一年)
|
||||||
spec, err := parseCron(expr)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return cronNext(spec, time.Now()).Format("2006-01-02 15:04"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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,6 +19,7 @@ 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)
|
||||||
@@ -26,6 +27,7 @@ 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)
|
||||||
@@ -33,6 +35,7 @@ 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)
|
||||||
@@ -40,6 +43,7 @@ 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)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -17,6 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---------- 事件总线 ----------
|
||||||
type EventBus struct {
|
type EventBus struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
next int
|
next int
|
||||||
@@ -54,6 +54,7 @@ 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
|
||||||
@@ -70,10 +71,8 @@ type RepoSnapshot struct {
|
|||||||
RetryCount int `json:"retry_count"`
|
RetryCount int `json:"retry_count"`
|
||||||
RetryInterval int `json:"retry_interval"`
|
RetryInterval int `json:"retry_interval"`
|
||||||
ConflictStrategy string `json:"conflict_strategy"`
|
ConflictStrategy string `json:"conflict_strategy"`
|
||||||
PushStrategy string `json:"push_strategy"`
|
|
||||||
LogFile string `json:"log_file"`
|
LogFile string `json:"log_file"`
|
||||||
AutoCommit bool `json:"auto_commit"`
|
AutoCommit bool `json:"auto_commit"`
|
||||||
AutoSync bool `json:"auto_sync"`
|
|
||||||
Cron string `json:"cron"`
|
Cron string `json:"cron"`
|
||||||
Remotes []Remote `json:"remotes"`
|
Remotes []Remote `json:"remotes"`
|
||||||
Running bool `json:"running"`
|
Running bool `json:"running"`
|
||||||
@@ -84,6 +83,7 @@ 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"`
|
||||||
@@ -102,6 +102,7 @@ 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
|
||||||
@@ -131,119 +132,9 @@ func NewEngine(base string) (*Engine, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = e.ensureSummariesDir()
|
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) summariesDir() string {
|
|
||||||
return filepath.Join(e.baseDir, "summaries")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureSummariesDir 创建本地变更摘要目录(gitignored,不同步到远程)。
|
|
||||||
func (e *Engine) ensureSummariesDir() error {
|
|
||||||
dir := e.summariesDir()
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
gi := filepath.Join(dir, ".gitignore")
|
|
||||||
if _, err := os.Stat(gi); err != nil {
|
|
||||||
_ = os.WriteFile(gi, []byte("*\n"), 0o644)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) summaryPath(name string) string {
|
|
||||||
return filepath.Join(e.summariesDir(), name+".md")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) pendingPath(name string) string {
|
|
||||||
return filepath.Join(e.summariesDir(), name+".pending")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) GetPendingSummary(name string) string {
|
|
||||||
b, err := os.ReadFile(e.pendingPath(name))
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) SavePendingSummary(name, text string) error {
|
|
||||||
if err := e.ensureSummariesDir(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
t := strings.TrimSpace(text)
|
|
||||||
if t == "" {
|
|
||||||
return os.Remove(e.pendingPath(name))
|
|
||||||
}
|
|
||||||
return os.WriteFile(e.pendingPath(name), []byte(t+"\n"), 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
// statusFiles 返回工作区相对改动文件列表(用于自动摘要;须在 git add 之前调用)。
|
|
||||||
func (e *Engine) statusFiles(repoPath string) []string {
|
|
||||||
out, err := gitRun(repoPath, 15*time.Second, "status", "--porcelain")
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var files []string
|
|
||||||
for _, ln := range strings.Split(strings.TrimSpace(out), "\n") {
|
|
||||||
ln = strings.TrimSpace(ln)
|
|
||||||
if ln == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p := strings.TrimSpace(ln[3:])
|
|
||||||
if i := strings.Index(p, "->"); i >= 0 {
|
|
||||||
p = strings.TrimSpace(p[i+2:])
|
|
||||||
}
|
|
||||||
p = strings.Trim(p, `"`)
|
|
||||||
if p != "" {
|
|
||||||
files = append(files, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
// autoSummary 在没有任何人工摘要时,依据本次改动自动生成一句话摘要(仅本地记录用)。
|
|
||||||
// files 须为提交前的改动文件列表(见 statusFiles)。
|
|
||||||
func (e *Engine) autoSummary(files []string) string {
|
|
||||||
if len(files) == 0 {
|
|
||||||
return "自动提交(无改动文件)"
|
|
||||||
}
|
|
||||||
const max = 6
|
|
||||||
shown := files
|
|
||||||
if len(shown) > max {
|
|
||||||
shown = files[:max]
|
|
||||||
}
|
|
||||||
more := ""
|
|
||||||
if len(files) > max {
|
|
||||||
more = fmt.Sprintf(" 等%d个", len(files))
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("改动 %d 个文件:%s%s", len(files), strings.Join(shown, ", "), more)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordCommit 把一次提交(commit id + 摘要)追加写入本地按项目分开的摘要文件(不上传)。
|
|
||||||
func (e *Engine) RecordCommit(name, sha, summary string) {
|
|
||||||
if err := e.ensureSummariesDir(); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ts := time.Now().Format("2006-01-02 15:04:05")
|
|
||||||
entry := fmt.Sprintf("## %s · %s\n%s\n\n", strings.TrimSpace(sha), ts, strings.TrimSpace(summary))
|
|
||||||
f, err := os.OpenFile(e.summaryPath(name), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
_, _ = f.WriteString(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) GetSummaryLog(name string) string {
|
|
||||||
b, err := os.ReadFile(e.summaryPath(name))
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) Bus() *EventBus { return e.bus }
|
func (e *Engine) Bus() *EventBus { return e.bus }
|
||||||
|
|
||||||
func (e *Engine) configNames() []string {
|
func (e *Engine) configNames() []string {
|
||||||
@@ -291,7 +182,6 @@ func (e *Engine) LoadAll() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
e.states[name] = &repoState{cfg: cfg, status: "idle"}
|
e.states[name] = &repoState{cfg: cfg, status: "idle"}
|
||||||
e.restoreLastSync(e.states[name])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,23 +203,18 @@ 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+"] "+msg)
|
||||||
_ = f.Close()
|
_ = f.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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})
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
path := filepath.Join(e.statsDir, s.cfg.Name+".csv")
|
path := filepath.Join(e.statsDir, s.cfg.Name+".csv")
|
||||||
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) {
|
|
||||||
e.logf(s, "info", "统计文件已滚动截断至最近 %d 行", statMaxLines)
|
|
||||||
}
|
|
||||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -338,60 +223,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func rotateStatIfNeeded(path string, maxLines int) bool {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
|
||||||
if len(lines) <= maxLines {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
keep := append([]string{lines[0]}, lines[len(lines)-(maxLines-1):]...)
|
|
||||||
if err := os.WriteFile(path, []byte(strings.Join(keep, "\n")+"\n"), 0o644); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// restoreLastSync 从统计 CSV 的最后一条记录恢复 lastSync,使重启后界面不再显示「从未同步」。
|
|
||||||
func (e *Engine) restoreLastSync(s *repoState) {
|
|
||||||
path := filepath.Join(e.statsDir, s.cfg.Name+".csv")
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
recs, err := csv.NewReader(f).ReadAll()
|
|
||||||
if err != nil || len(recs) < 2 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var ts, raw string
|
|
||||||
for _, rec := range recs[1:] {
|
|
||||||
if len(rec) < 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ts, raw = rec[0], rec[2]
|
|
||||||
}
|
|
||||||
if ts == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result := raw
|
|
||||||
switch raw {
|
|
||||||
case "success":
|
|
||||||
result = "ok"
|
|
||||||
case "failed":
|
|
||||||
result = "error"
|
|
||||||
}
|
|
||||||
detail := map[string]string{"ok": "同步完成", "error": "同步失败", "conflict": "存在冲突"}[result]
|
|
||||||
if detail == "" {
|
|
||||||
detail = "上次同步"
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
s.lastSync = &SyncResult{Time: ts, Result: result, Detail: detail}
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstRemoteName(cfg *RepoConfig) string {
|
func firstRemoteName(cfg *RepoConfig) string {
|
||||||
if len(cfg.Remotes) > 0 {
|
if len(cfg.Remotes) > 0 {
|
||||||
return cfg.Remotes[0].Name
|
return cfg.Remotes[0].Name
|
||||||
@@ -412,10 +243,8 @@ func (s *repoState) snapshot(live bool) RepoSnapshot {
|
|||||||
RetryCount: cfg.RetryCount,
|
RetryCount: cfg.RetryCount,
|
||||||
RetryInterval: cfg.RetryInterval,
|
RetryInterval: cfg.RetryInterval,
|
||||||
ConflictStrategy: cfg.ConflictStrategy,
|
ConflictStrategy: cfg.ConflictStrategy,
|
||||||
PushStrategy: cfg.PushStrategy,
|
|
||||||
LogFile: cfg.LogFile,
|
LogFile: cfg.LogFile,
|
||||||
AutoCommit: cfg.AutoCommit,
|
AutoCommit: cfg.AutoCommit,
|
||||||
AutoSync: cfg.AutoSync,
|
|
||||||
Cron: cfg.Cron,
|
Cron: cfg.Cron,
|
||||||
Remotes: cfg.Remotes,
|
Remotes: cfg.Remotes,
|
||||||
Running: s.running,
|
Running: s.running,
|
||||||
@@ -463,6 +292,7 @@ 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 {
|
||||||
@@ -492,14 +322,7 @@ func (e *Engine) loop(s *repoState, stopCh chan struct{}) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
interval := s.cfg.Interval
|
interval := s.cfg.Interval
|
||||||
cronStr := s.cfg.Cron
|
cronStr := s.cfg.Cron
|
||||||
autoSync := s.cfg.AutoSync
|
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if !autoSync {
|
|
||||||
e.logf(s, "info", "已关闭自动同步,仅响应手动同步")
|
|
||||||
e.pushStatus(s)
|
|
||||||
<-stopCh
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if interval < 5 {
|
if interval < 5 {
|
||||||
interval = 5
|
interval = 5
|
||||||
}
|
}
|
||||||
@@ -583,6 +406,7 @@ 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()
|
||||||
@@ -615,7 +439,6 @@ func (e *Engine) setFail(s *repoState, msg string) {
|
|||||||
s.lastError = msg
|
s.lastError = msg
|
||||||
s.lastSync = &SyncResult{Time: now, Result: "error", Detail: msg}
|
s.lastSync = &SyncResult{Time: now, Result: "error", Detail: msg}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
e.stat(s, "sync", "error", 0)
|
|
||||||
e.logf(s, "error", "%s", msg)
|
e.logf(s, "error", "%s", msg)
|
||||||
e.pushStatus(s)
|
e.pushStatus(s)
|
||||||
}
|
}
|
||||||
@@ -627,10 +450,10 @@ func (e *Engine) setOK(s *repoState, detail string) {
|
|||||||
s.lastError = ""
|
s.lastError = ""
|
||||||
s.lastSync = &SyncResult{Time: now, Result: "ok", Detail: detail}
|
s.lastSync = &SyncResult{Time: now, Result: "ok", Detail: detail}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
e.stat(s, "sync", "ok", 0)
|
|
||||||
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)
|
||||||
@@ -677,6 +500,7 @@ 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 {
|
||||||
@@ -692,18 +516,6 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
stashed := false
|
|
||||||
defer func() {
|
|
||||||
if !stashed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, perr := gitRun(cfg.RepoPath, 60*time.Second, "stash", "pop"); perr != nil {
|
|
||||||
e.logf(s, "warn", "同步前自动 stash 的恢复失败(可能与其他改动冲突),请手动执行 git stash pop: %v", perr)
|
|
||||||
} else {
|
|
||||||
e.logf(s, "info", "已恢复同步前自动 stash 的未提交改动")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if !isRepo(cfg.RepoPath) {
|
if !isRepo(cfg.RepoPath) {
|
||||||
e.setFail(s, "仓库路径无效: "+cfg.RepoPath)
|
e.setFail(s, "仓库路径无效: "+cfg.RepoPath)
|
||||||
return
|
return
|
||||||
@@ -724,15 +536,6 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
|
||||||
stashed = true
|
|
||||||
e.logf(s, "info", "已自动 stash 未提交改动(同步结束后恢复)")
|
|
||||||
} else {
|
|
||||||
e.logf(s, "warn", "自动 stash 失败,将尝试直接同步")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分支对齐(安全切换)
|
// 分支对齐(安全切换)
|
||||||
if cur, cerr := currentBranch(cfg.RepoPath); cerr == nil && cur != cfg.Branch {
|
if cur, cerr := currentBranch(cfg.RepoPath); cerr == nil && cur != cfg.Branch {
|
||||||
tip, remoteHasBranch := remoteTip(cfg.RepoPath, remote, cfg.Branch)
|
tip, remoteHasBranch := remoteTip(cfg.RepoPath, remote, cfg.Branch)
|
||||||
@@ -761,6 +564,7 @@ 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 {
|
||||||
@@ -770,26 +574,10 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cmsg := "git-sync-web 自动提交 " + time.Now().Format("2006-01-02 15:04:05")
|
cmsg := "git-sync-web 自动提交 " + time.Now().Format("2006-01-02 15:04:05")
|
||||||
pend := e.GetPendingSummary(s.cfg.Name)
|
|
||||||
if pend != "" {
|
|
||||||
cmsg = pend
|
|
||||||
}
|
|
||||||
var autoFiles []string
|
|
||||||
if pend == "" {
|
|
||||||
autoFiles = e.statusFiles(cfg.RepoPath)
|
|
||||||
}
|
|
||||||
if _, cerr := gitRun(cfg.RepoPath, 120*time.Second, "commit", "-m", cmsg); cerr != nil {
|
if _, cerr := gitRun(cfg.RepoPath, 120*time.Second, "commit", "-m", cmsg); cerr != nil {
|
||||||
e.setFail(s, "自动提交失败: "+cerr.Error())
|
e.setFail(s, "自动提交失败: "+cerr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if sha, serr := gitRun(cfg.RepoPath, 10*time.Second, "rev-parse", "--short", "HEAD"); serr == nil {
|
|
||||||
sum := cmsg
|
|
||||||
if pend == "" {
|
|
||||||
sum = e.autoSummary(autoFiles)
|
|
||||||
}
|
|
||||||
e.RecordCommit(s.cfg.Name, sha, sum)
|
|
||||||
}
|
|
||||||
_ = os.Remove(e.pendingPath(s.cfg.Name))
|
|
||||||
e.logf(s, "info", "自动提交完成,将随本轮同步推送")
|
e.logf(s, "info", "自动提交完成,将随本轮同步推送")
|
||||||
dirty = 0
|
dirty = 0
|
||||||
} else {
|
} else {
|
||||||
@@ -797,6 +585,7 @@ 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)
|
||||||
@@ -817,6 +606,7 @@ 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
|
||||||
@@ -828,50 +618,25 @@ 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") {
|
||||||
|
// pushAll 可能返回错误,如果是 non-fast-forward,设置 push_conflict 状态
|
||||||
pushOK, pushErr := e.pushAll(s, cfg, ahead)
|
pushOK, pushErr := e.pushAll(s, cfg, ahead)
|
||||||
if !pushOK {
|
if !pushOK {
|
||||||
|
// 检查是否是 non-fast-forward 错误
|
||||||
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 {
|
// 记录冲突远程(取第一个冲突的远程)
|
||||||
case "force":
|
// 在 pushAll 内部已经设置了 pushConflict 状态,这里只需退出
|
||||||
e.logf(s, "warn", "按推送策略 force 执行强制推送(--force-with-lease)")
|
|
||||||
if _, ferr := gitRun(cfg.RepoPath, 300*time.Second, "push", "--force-with-lease", remote, cfg.Branch); ferr != nil {
|
|
||||||
e.setFail(s, "强制推送失败: "+ferr.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.logf(s, "info", "强制推送成功")
|
|
||||||
e.stat(s, "push", "success", ahead)
|
|
||||||
s.mu.Lock()
|
|
||||||
s.pushConflict = false
|
|
||||||
s.lastError = ""
|
|
||||||
s.mu.Unlock()
|
|
||||||
case "rebase":
|
|
||||||
e.logf(s, "warn", "按推送策略 rebase 后重试推送")
|
|
||||||
if _, rerr := gitRun(cfg.RepoPath, 300*time.Second, "rebase", remote+"/"+cfg.Branch); rerr != nil {
|
|
||||||
e.setFail(s, "变基到远程失败(请手动解决冲突): "+rerr.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, perr := gitRun(cfg.RepoPath, 300*time.Second, "push", remote, cfg.Branch); perr != nil {
|
|
||||||
e.setFail(s, "变基后推送失败: "+perr.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.logf(s, "info", "变基后推送成功")
|
|
||||||
e.stat(s, "push", "success", ahead)
|
|
||||||
s.mu.Lock()
|
|
||||||
s.pushConflict = false
|
|
||||||
s.lastError = ""
|
|
||||||
s.mu.Unlock()
|
|
||||||
default:
|
|
||||||
e.logf(s, "warn", "推送被拒绝(non-fast-forward),等待用户决策")
|
e.logf(s, "warn", "推送被拒绝(non-fast-forward),等待用户决策")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
// 其他错误
|
||||||
e.setFail(s, "推送失败: "+pushErr.Error())
|
e.setFail(s, "推送失败: "+pushErr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
// 最终刷新
|
||||||
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()
|
||||||
@@ -879,6 +644,7 @@ 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 = ""
|
||||||
@@ -887,6 +653,8 @@ 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
|
||||||
@@ -900,6 +668,7 @@ 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"
|
||||||
@@ -927,6 +696,7 @@ 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" {
|
||||||
@@ -956,7 +726,6 @@ func (e *Engine) pullRemote(s *repoState, cfg *RepoConfig, remote string, n int)
|
|||||||
s.lastError = "拉取产生冲突,等待在冲突处理台解决"
|
s.lastError = "拉取产生冲突,等待在冲突处理台解决"
|
||||||
s.lastSync = &SyncResult{Time: now, Result: "conflict", Detail: s.lastError}
|
s.lastSync = &SyncResult{Time: now, Result: "conflict", Detail: s.lastError}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
e.stat(s, "sync", "conflict", 0)
|
|
||||||
e.stat(s, "pull", "conflict", n)
|
e.stat(s, "pull", "conflict", n)
|
||||||
e.logf(s, "error", "拉取产生冲突:%s(请在详情 → 冲突中处理)", strings.Join(files, ", "))
|
e.logf(s, "error", "拉取产生冲突:%s(请在详情 → 冲突中处理)", strings.Join(files, ", "))
|
||||||
e.pushStatus(s)
|
e.pushStatus(s)
|
||||||
@@ -967,6 +736,8 @@ 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 {
|
||||||
@@ -997,11 +768,11 @@ func (e *Engine) ForcePush(name string) error {
|
|||||||
s.lastError = ""
|
s.lastError = ""
|
||||||
s.lastSync = &SyncResult{Time: time.Now().Format("2006-01-02 15:04:05"), Result: "ok", Detail: "强制推送完成"}
|
s.lastSync = &SyncResult{Time: time.Now().Format("2006-01-02 15:04:05"), Result: "ok", Detail: "强制推送完成"}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
e.stat(s, "sync", "ok", 0)
|
|
||||||
e.pushStatus(s)
|
e.pushStatus(s)
|
||||||
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 {
|
||||||
@@ -1018,12 +789,12 @@ func (e *Engine) AbortPush(name string) error {
|
|||||||
s.status = "ok"
|
s.status = "ok"
|
||||||
s.lastError = "用户放弃强制推送"
|
s.lastError = "用户放弃强制推送"
|
||||||
s.lastSync = &SyncResult{Time: time.Now().Format("2006-01-02 15:04:05"), Result: "ok", Detail: "放弃强制推送,状态已清除"}
|
s.lastSync = &SyncResult{Time: time.Now().Format("2006-01-02 15:04:05"), Result: "ok", Detail: "放弃强制推送,状态已清除"}
|
||||||
e.stat(s, "sync", "ok", 0)
|
|
||||||
e.logf(s, "warn", "放弃强制推送")
|
e.logf(s, "warn", "放弃强制推送")
|
||||||
e.pushStatus(s)
|
e.pushStatus(s)
|
||||||
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 {
|
||||||
@@ -1103,6 +874,7 @@ 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 {
|
||||||
@@ -1155,14 +927,8 @@ 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 {
|
||||||
branch := c.Branch
|
|
||||||
if branch == "" {
|
|
||||||
branch = "main"
|
|
||||||
}
|
|
||||||
if err := ensureRepo(c.RepoPath, branch); err != nil {
|
|
||||||
return fmt.Errorf("初始化仓库失败: %w", err)
|
|
||||||
}
|
|
||||||
if err := validateRepoConfig(e.cfgDir, c, true); err != nil {
|
if err := validateRepoConfig(e.cfgDir, c, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1183,13 +949,6 @@ func (e *Engine) Update(c *RepoConfig) error {
|
|||||||
if s == nil {
|
if s == nil {
|
||||||
return fmt.Errorf("配置不存在: %s", c.Name)
|
return fmt.Errorf("配置不存在: %s", c.Name)
|
||||||
}
|
}
|
||||||
branch := c.Branch
|
|
||||||
if branch == "" {
|
|
||||||
branch = "main"
|
|
||||||
}
|
|
||||||
if err := ensureRepo(c.RepoPath, branch); err != nil {
|
|
||||||
return fmt.Errorf("初始化仓库失败: %w", err)
|
|
||||||
}
|
|
||||||
if err := validateRepoConfig(e.cfgDir, c, false); err != nil {
|
if err := validateRepoConfig(e.cfgDir, c, false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1209,6 +968,7 @@ 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 = ""
|
||||||
@@ -1238,6 +998,7 @@ 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"`
|
||||||
@@ -1317,6 +1078,7 @@ 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 {
|
||||||
@@ -1369,385 +1131,3 @@ func (e *Engine) ReadCommitDiff(name, hash string) (string, error) {
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const fileEditMaxBytes = 1 << 20 // 1MB,网页编辑单文件上限
|
|
||||||
|
|
||||||
// safeRepoPath 将仓库内相对路径解析为绝对路径,并严格校验不越界、不触及 .git
|
|
||||||
func (e *Engine) safeRepoPath(s *repoState, rel string) (string, error) {
|
|
||||||
rel = filepath.ToSlash(strings.TrimSpace(rel))
|
|
||||||
rel = strings.TrimPrefix(rel, "/")
|
|
||||||
if rel == "" || rel == "." {
|
|
||||||
return "", fmt.Errorf("无效路径")
|
|
||||||
}
|
|
||||||
for _, seg := range strings.Split(rel, "/") {
|
|
||||||
if seg == ".." {
|
|
||||||
return "", fmt.Errorf("路径不允许包含 ..")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
root := filepath.Clean(s.cfg.RepoPath)
|
|
||||||
full := filepath.Clean(filepath.Join(root, filepath.FromSlash(rel)))
|
|
||||||
if full != root && !strings.HasPrefix(full, root+string(os.PathSeparator)) {
|
|
||||||
return "", fmt.Errorf("路径越界: %s", rel)
|
|
||||||
}
|
|
||||||
relClean := strings.TrimPrefix(full, root+string(os.PathSeparator))
|
|
||||||
if relClean == ".git" || strings.HasPrefix(relClean, ".git"+string(os.PathSeparator)) {
|
|
||||||
return "", fmt.Errorf("禁止访问 .git 内部")
|
|
||||||
}
|
|
||||||
return full, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileNode struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
Status string `json:"status"` // M 已修改 / A 新增 / D 删除 / ? 未跟踪 / 空 未改动
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileContent struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Binary bool `json:"binary"`
|
|
||||||
Size int `json:"size"`
|
|
||||||
Changed []int `json:"changed,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) ReadFileContent(name, rel string) (*FileContent, error) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
content := string(data)
|
|
||||||
changed, _ := fileChangeLines(s.cfg.RepoPath, rel, content)
|
|
||||||
return &FileContent{Path: rel, Content: content, Size: int(info.Size()), Changed: changed}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isUntracked(repoPath, rel string) bool {
|
|
||||||
out, err := gitRun(repoPath, 10*time.Second, "status", "--porcelain", "--", rel)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(out, "\n") {
|
|
||||||
if strings.HasPrefix(strings.TrimSpace(line), "??") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileChangeLines(repoPath, rel, content string) ([]int, error) {
|
|
||||||
if _, err := gitRun(repoPath, 10*time.Second, "rev-parse", "--verify", "HEAD"); err != nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if isUntracked(repoPath, rel) {
|
|
||||||
return allLineNumbers(content), nil
|
|
||||||
}
|
|
||||||
out, err := gitRun(repoPath, 30*time.Second, "diff", "--no-color", "HEAD", "--", rel)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return parseDiffAddedLines(out), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func allLineNumbers(content string) []int {
|
|
||||||
n := strings.Count(content, "\n")
|
|
||||||
if content == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
n++
|
|
||||||
lines := make([]int, 0, n)
|
|
||||||
for i := 1; i <= n; i++ {
|
|
||||||
lines = append(lines, i)
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDiffAddedLines(diff string) []int {
|
|
||||||
set := map[int]struct{}{}
|
|
||||||
newLine := 0
|
|
||||||
for _, raw := range strings.Split(diff, "\n") {
|
|
||||||
line := raw
|
|
||||||
if strings.HasPrefix(line, "@@") {
|
|
||||||
if i := strings.Index(line, " +"); i >= 0 {
|
|
||||||
rest := line[i+2:]
|
|
||||||
if c, ok := parseHunkNewStart(rest); ok {
|
|
||||||
newLine = c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if newLine == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(line, "+"):
|
|
||||||
if strings.HasPrefix(line, "+++") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
set[newLine] = struct{}{}
|
|
||||||
newLine++
|
|
||||||
case strings.HasPrefix(line, " "):
|
|
||||||
newLine++
|
|
||||||
case strings.HasPrefix(line, "-"):
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := make([]int, 0, len(set))
|
|
||||||
for k := range set {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Ints(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseHunkNewStart(rest string) (int, bool) {
|
|
||||||
end := strings.IndexAny(rest, " @")
|
|
||||||
if end >= 0 {
|
|
||||||
rest = rest[:end]
|
|
||||||
}
|
|
||||||
if i := strings.Index(rest, ","); i >= 0 {
|
|
||||||
rest = rest[:i]
|
|
||||||
}
|
|
||||||
n := strings.TrimSpace(rest)
|
|
||||||
if n == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
v, err := strconv.Atoi(n)
|
|
||||||
if err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return v, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) WriteFileContent(name, rel, content string) error {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if sha, serr := gitRun(s.cfg.RepoPath, 10*time.Second, "rev-parse", "--short", "HEAD"); serr == nil {
|
|
||||||
e.RecordCommit(name, sha, message)
|
|
||||||
}
|
|
||||||
e.logf(s, "info", "网页端提交 %s 成功", rel)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
type DiffFile struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
Add int `json:"add"`
|
|
||||||
Del int `json:"del"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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+} 标记行内改动
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,11 @@ 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()
|
||||||
@@ -25,19 +30,16 @@ 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
|
||||||
var outBuf, errBuf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.Stdout = &outBuf
|
cmd.Stdout = &buf
|
||||||
cmd.Stderr = &errBuf
|
cmd.Stderr = &buf
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
out := strings.TrimRight(outBuf.String(), "\r\n\t ")
|
out := strings.TrimSpace(buf.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
return out, fmt.Errorf("命令超时: git %s", strings.Join(args, " "))
|
return out, fmt.Errorf("命令超时: git %s", strings.Join(args, " "))
|
||||||
}
|
}
|
||||||
msg := strings.TrimSpace(errBuf.String())
|
msg := out
|
||||||
if msg == "" {
|
|
||||||
msg = out
|
|
||||||
}
|
|
||||||
if msg == "" {
|
if msg == "" {
|
||||||
msg = err.Error()
|
msg = err.Error()
|
||||||
}
|
}
|
||||||
@@ -54,33 +56,11 @@ func isRepo(path string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureRepo(path, branch string) error {
|
|
||||||
if path == "" {
|
|
||||||
return fmt.Errorf("仓库路径为空")
|
|
||||||
}
|
|
||||||
path = normalizeWinPath(path)
|
|
||||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if isRepo(path) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if branch == "" {
|
|
||||||
branch = "main"
|
|
||||||
}
|
|
||||||
if _, err := gitRun(path, 30*time.Second, "init", "-b", branch); err != nil {
|
|
||||||
if _, e2 := gitRun(path, 30*time.Second, "init"); e2 != nil {
|
|
||||||
return e2
|
|
||||||
}
|
|
||||||
_, _ = gitRun(path, 15*time.Second, "checkout", "-b", branch)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentBranch(path string) (string, error) {
|
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 {
|
||||||
@@ -95,16 +75,21 @@ 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)
|
||||||
@@ -123,6 +108,7 @@ 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 {
|
||||||
@@ -188,6 +174,7 @@ 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
|
||||||
@@ -202,54 +189,3 @@ func resolveDone(path string, rebase bool) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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:])
|
|
||||||
}
|
|
||||||
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 冲突状态,统一显示为已修改
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for p, st := range m {
|
|
||||||
if st == "?" && strings.HasSuffix(p, "/") {
|
|
||||||
expandUntracked(path, p, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func expandUntracked(repoPath, dir string, m map[string]string) {
|
|
||||||
out, err := gitRun(repoPath, 15*time.Second, "ls-files", "--others", "--exclude-standard", "--", dir)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, ln := range strings.Split(out, "\n") {
|
|
||||||
ln = strings.TrimSpace(ln)
|
|
||||||
if ln != "" {
|
|
||||||
m[ln] = "?"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
2026/09/09 15:59:43 Git Sync Web 已启动: http://127.0.0.1:5799(鉴权:false)
|
|
||||||
@@ -50,21 +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}/stats", s.wrapRepo(s.statsRepo))
|
||||||
mux.HandleFunc("GET /api/repos/{name}/commits", s.wrapRepo(s.commitsRepo))
|
mux.HandleFunc("GET /api/repos/{name}/commits", s.wrapRepo(s.commitsRepo))
|
||||||
mux.HandleFunc("GET /api/repos/{name}/commits/{hash}", s.wrapRepo(s.commitDiffRepo))
|
mux.HandleFunc("GET /api/repos/{name}/commits/{hash}", s.wrapRepo(s.commitDiffRepo))
|
||||||
mux.HandleFunc("GET /api/repos/{name}/tree", s.wrapRepo(s.treeRepo))
|
|
||||||
mux.HandleFunc("GET /api/repos/{name}/file", s.wrapRepo(s.fileRepo))
|
|
||||||
mux.HandleFunc("PUT /api/repos/{name}/file", s.wrapRepo(s.fileWriteRepo))
|
|
||||||
mux.HandleFunc("GET /api/repos/{name}/summary", s.wrapRepo(s.summaryGetRepo))
|
|
||||||
mux.HandleFunc("POST /api/repos/{name}/summary", s.wrapRepo(s.summarySaveRepo))
|
|
||||||
mux.HandleFunc("POST /api/repos/{name}/resolve-file", s.wrapRepo(s.resolveFileRepo))
|
|
||||||
mux.HandleFunc("POST /api/repos/{name}/commit", s.wrapRepo(s.commitFileRepo))
|
|
||||||
mux.HandleFunc("GET /api/repos/{name}/diff", s.wrapRepo(s.diffRepo))
|
|
||||||
mux.HandleFunc("GET /api/cron-next", s.handleCronNext)
|
|
||||||
mux.Handle("/", http.FileServer(http.FS(static)))
|
mux.Handle("/", http.FileServer(http.FS(static)))
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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,133 +330,3 @@ 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) {
|
|
||||||
writeJSON(w, 200, map[string]any{"files": s.engine.FileTree(name)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) fileRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
rel := r.URL.Query().Get("path")
|
|
||||||
if rel == "" {
|
|
||||||
writeErr(w, 400, "缺少 path 参数")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fc, err := s.engine.ReadFileContent(name, rel)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, 200, fc)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) fileWriteRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
var req struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeErr(w, 400, "请求体格式错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Path == "" {
|
|
||||||
writeErr(w, 400, "缺少 path")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.engine.WriteFileContent(name, req.Path, req.Content); err != nil {
|
|
||||||
writeErr(w, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, 200, map[string]any{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) summaryGetRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
writeJSON(w, 200, map[string]any{
|
|
||||||
"pending": s.engine.GetPendingSummary(name),
|
|
||||||
"log": s.engine.GetSummaryLog(name),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) summarySaveRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
var req struct {
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeErr(w, 400, "请求体格式错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.engine.SavePendingSummary(name, req.Summary); err != nil {
|
|
||||||
writeErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, 200, map[string]any{"ok": true, "pending": s.engine.GetPendingSummary(name)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) {
|
|
||||||
var req struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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})
|
|
||||||
}
|
|
||||||
+37
-778
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
|||||||
(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;
|
|
||||||
cm.on('change', function () { if (window.Editor._suspend) return; if (window.Editor.onChange) window.Editor.onChange(); });
|
|
||||||
var cur = window.Editor.value;
|
|
||||||
} catch (e) {
|
|
||||||
if (window.console) console.warn('CodeMirror 初始化失败,已回退原生编辑器:', e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
+103
-220
@@ -1,228 +1,130 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN" data-page-node-id="nRvVfOmQzU2twwSAvqUwfl">
|
<html lang="zh-CN">
|
||||||
<head data-page-node-id="BuWdwjwKPwwSCpow9JSC3n">
|
<head>
|
||||||
<meta charset="utf-8" data-page-node-id="tfHCUSqYqPzGPxp06Gd1tM">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" data-page-node-id="jb078uU5LM9D2ifPdaUWAq">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Git Sync 控制台</title>
|
<title>Git Sync 控制台</title>
|
||||||
<link rel="stylesheet" href="/style.css" data-page-node-id="ORLXZi9XzxrEfwCcuWSocY">
|
<link rel="stylesheet" href="/style.css">
|
||||||
<!-- 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>
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body data-page-node-id="pZxw2WnQAZycSEktQenh8f">
|
<body>
|
||||||
<header data-page-node-id="XYY4eaCzyeDqRPq55yjUTY">
|
<header>
|
||||||
<div class="brand" data-page-node-id="xYRyIDur3LHCyDOfcx2CSX"><span class="brand-dot" data-page-node-id="GP41MDt2LXjJaYhsp8InyW"></span>Git Sync 控制台</div>
|
<div class="brand"><span class="brand-dot"></span>Git Sync 控制台</div>
|
||||||
<div class="header-actions" data-page-node-id="XruBG1DkVAqNNsyOl4J41n">
|
<div class="header-actions">
|
||||||
<span id="conn" class="conn" data-page-node-id="aGv9c7D5e6Izpi92PjfxBz">连接中…</span>
|
<span id="conn" class="conn">连接中…</span>
|
||||||
<button class="btn btn-icon" id="btn-theme" title="切换深色 / 浅色模式" data-page-node-id="AP0nSHePHnQDDMylbBKMkA">🌙</button>
|
<button class="btn" id="btn-sync-all">全部同步</button>
|
||||||
<button class="btn" id="btn-start-all" title="启动所有仓库的自动同步" data-page-node-id="QThBRVKpwwHv4WTWoGl0XA">全部启动</button>
|
<button class="btn btn-primary" id="btn-new">+ 新建配置</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="layout" data-page-node-id="9QmwKKoxuxwUQhg1t97g3S">
|
<main class="layout">
|
||||||
<section class="main-col" data-page-node-id="sKKLeDH2PGoGGIHbEhFid9">
|
<section class="main-col">
|
||||||
<div id="grid" class="grid" data-page-node-id="xoQF8aiwcrnknxwxd80TI1"></div>
|
<div id="grid" class="grid"></div>
|
||||||
<div id="empty" class="empty" hidden data-page-node-id="C97b1VODLutQfNM13MXmAQ">
|
<div id="empty" class="empty" hidden>
|
||||||
<p data-page-node-id="5nTKGjtKBDEFtovt25xVkQ">还没有任何仓库配置。</p>
|
<p>还没有任何仓库配置。</p>
|
||||||
<button class="btn btn-primary" id="btn-empty-new" data-page-node-id="SOs5r2CqrkMlyKyIZCGjkc">创建第一个配置</button>
|
<button class="btn btn-primary" id="btn-empty-new">创建第一个配置</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<aside id="live" class="live" data-page-node-id="sgJJOcfDk9vPJs2DHcddtg">
|
<aside id="live" class="live">
|
||||||
<div class="live-head" data-page-node-id="znZimXC9iQsawFUMoA0lCu">
|
<div class="live-head">
|
||||||
<h2 data-page-node-id="nGI3EfHmGzvRvuwP0oie2v">实时活动</h2>
|
<h2>实时活动</h2>
|
||||||
<span id="live-conn" class="conn" data-page-node-id="Kg4dQkALsqywtSpd37Zz3B">连接中…</span>
|
<span id="live-conn" class="conn">连接中…</span>
|
||||||
<button class="btn btn-sm" id="btn-live-clear" title="清空本面板" data-page-node-id="KmUck5XGik1k7kP9zyfV2B">清空</button>
|
<button class="btn btn-sm" id="btn-live-clear" title="清空本面板">清空</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="live-hint" data-page-node-id="camBOmhyguvcYnuUJmA2E7">所有仓库的同步(手动与自动)实时显示在此,无需点进详情</div>
|
<div class="live-hint">所有仓库的同步(手动与自动)实时显示在此,无需点进详情</div>
|
||||||
<div id="live-log" class="live-log" data-page-node-id="VBl8S1HvgHrCW0882nIFt2"></div>
|
<div id="live-log" class="live-log"></div>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="modal" class="modal" hidden data-page-node-id="txBcrv6ZrmZhFBqb699zeN">
|
<div id="modal" class="modal" hidden>
|
||||||
<div class="modal-box" data-page-node-id="KdrABdnmnNUg8UQdCa7Nqv">
|
<div class="modal-box">
|
||||||
<div class="modal-head" data-page-node-id="QdJ8BjaSvq7DxthECgP16z">
|
<div class="modal-head">
|
||||||
<h2 id="m-title" data-page-node-id="5kDCaJqI0QPGtb0QllYLBm">详情</h2>
|
<h2 id="m-title">详情</h2>
|
||||||
<button class="btn-icon" id="m-close" title="关闭" data-page-node-id="cEUcru162HY4bHMBMNTEfI">✕</button>
|
<button class="btn-icon" id="m-close" title="关闭">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tabs" id="m-tabs" data-page-node-id="CuT5FO4bado4BoZKGRXLGA">
|
<div class="tabs" id="m-tabs">
|
||||||
<button data-tab="log" class="tab active" data-page-node-id="UirzBTtYe9llMyNeM4Q7cS">日志</button>
|
<button data-tab="log" class="tab active">日志</button>
|
||||||
<button data-tab="history" class="tab" data-page-node-id="CPGPmAmMmERkIKBuv5Dw20">历史</button>
|
<button data-tab="history" class="tab">历史</button>
|
||||||
<button data-tab="diff" class="tab" data-page-node-id="hxChwjabjboAjspxC9VQyT">差异</button>
|
<button data-tab="conflict" class="tab">冲突</button>
|
||||||
<button data-tab="file" class="tab" data-page-node-id="TotQXXPG6HhUSfeSm8XHz2">文件</button>
|
<button data-tab="stats" class="tab">统计</button>
|
||||||
<button data-tab="conflict" class="tab" data-page-node-id="ujZu5YIEdpocOLfXLYhPv8">冲突</button>
|
<button data-tab="config" class="tab">配置</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>
|
||||||
<div class="tab-body" data-page-node-id="BrgjnFG38oxkdJfBGHBRIX">
|
<div class="tab-body">
|
||||||
<div id="tab-log" class="tab-pane" data-page-node-id="LeZ5xD39VJIgWzOgMDLmji">
|
<div id="tab-log" class="tab-pane">
|
||||||
<div class="log-tools" data-page-node-id="VeZ2yA8bXiB5QkTnVxho88">
|
<pre id="logbox" class="logbox">加载中…</pre>
|
||||||
<label data-page-node-id="mu1MsfnHFce2PblMBUHuBz">级别
|
</div>
|
||||||
<select id="log-level" data-page-node-id="qRmCfGUzlRUbqzPfRd3GMb">
|
<div id="tab-history" class="tab-pane" hidden>
|
||||||
<option value="all" data-page-node-id="MBqUhLyyEFwF5KlLC2KzZq">全部</option>
|
<div id="commit-list" class="commit-list">加载中…</div>
|
||||||
<option value="error" data-page-node-id="PAJzGYoj7CRpKDjFKGgRF9">仅错误</option>
|
</div>
|
||||||
<option value="warn" data-page-node-id="m58wjzleIydsdSo49RQDUn">仅警告</option>
|
<div id="tab-conflict" class="tab-pane" hidden>
|
||||||
<option value="info" data-page-node-id="AGIjFdNqVxkFMzLjdbM76q">仅信息</option>
|
<div class="conflict-actions">
|
||||||
</select>
|
<button class="btn btn-danger" id="btn-abort">放弃合并</button>
|
||||||
|
<button class="btn btn-primary" id="btn-resolved">我已在本地解决,继续</button>
|
||||||
|
</div>
|
||||||
|
<div id="conflict-list"></div>
|
||||||
|
</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>
|
||||||
|
<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="仅字母、数字、下划线、连字符">
|
||||||
</label>
|
</label>
|
||||||
<input id="log-search" class="log-search" placeholder="搜索日志内容(关键词)" data-page-node-id="diPbIGZMP5CvkxPAGMsEnR">
|
<label>分支
|
||||||
<button class="btn btn-sm" id="btn-log-clear" title="清除过滤条件" data-page-node-id="WTCY4f4B9KEUbcx59P7S1K">清除</button>
|
<input name="branch" id="f-branch" value="main">
|
||||||
</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-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>
|
||||||
<label data-page-node-id="HM7TUuaIIeWw55c6H6p8et">视图
|
<label class="full">仓库路径
|
||||||
<select id="f-diff-view" data-page-node-id="pPFl4XiZHHV6OW02F6izDl">
|
<input name="repo_path" id="f-path" placeholder="C:\path\to\repo 或 /c/path/to/repo" required>
|
||||||
<option value="unified" data-page-node-id="UBjVfLe9r8IoW6n4Br722d">统一</option>
|
|
||||||
<option value="split" data-page-node-id="52oaT6kkwDaWCwPJgQesYV">并排</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="check-inline" title="按单词而非整行显示改动" data-page-node-id="Tn8hpGEewmZoD4cEfS4KUV"><input type="checkbox" id="f-diff-word" data-page-node-id="lxIM4byqutKFfAsBwujha6"> 词级</label>
|
<label class="full">远程仓库(每行一个,格式 名称=URL)
|
||||||
<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="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-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">
|
|
||||||
<div class="file-chg-hint" id="file-chg-hint" hidden>● 黄色高亮 = 相对最近一次提交仍未提交的修改行</div>
|
|
||||||
<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" id="btn-file-commit-only" title="提交当前文件在磁盘上的改动(不含编辑器里尚未保存的修改)">提交</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-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-sm btn-danger" id="btn-abort" data-page-node-id="iFRU44FOA3NXieww4PmbAR">放弃合并</button>
|
|
||||||
<button class="btn btn-sm 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-sm btn-danger" id="btn-force-push" data-page-node-id="20FtTVRLlB3HrObTPUgS7I">强制推送(--force-with-lease)</button>
|
|
||||||
<button class="btn btn-sm" 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 data-page-node-id="QC3jvGyY6uBpIUe0dtLRyy">分支
|
|
||||||
<input name="branch" id="f-branch" value="main" data-page-node-id="uIDYoqF9rtqJ0kqijuojOd">
|
|
||||||
</label>
|
|
||||||
<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" data-page-node-id="SZ5RWmnnD0vgIfUoyNcCAP">远程仓库(每行一个,格式 名称=URL)
|
|
||||||
<textarea name="remotes" id="f-remotes" rows="3" placeholder="origin=https://github.com/user/repo.git"></textarea>
|
<textarea name="remotes" id="f-remotes" rows="3" placeholder="origin=https://github.com/user/repo.git"></textarea>
|
||||||
</label>
|
</label>
|
||||||
<label data-page-node-id="QUBANIDAtqHXOLlp2iHfUw">操作模式
|
<label>操作模式
|
||||||
<select name="mode" id="f-mode" data-page-node-id="PYGTzl4KFnOy5QLTnl9bil">
|
<select name="mode" id="f-mode">
|
||||||
<option value="both" data-page-node-id="4uWdIiNUcKH1klnQC7Yyfk">both(拉取并推送)</option>
|
<option value="both">both(拉取并推送)</option>
|
||||||
<option value="push" data-page-node-id="Fx5PMaVHG9UGFzmyKtNoVC">push(仅推送)</option>
|
<option value="push">push(仅推送)</option>
|
||||||
<option value="pull" data-page-node-id="g0oRcyHrhBbgOpG3htuVkZ">pull(仅拉取)</option>
|
<option value="pull">pull(仅拉取)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label data-page-node-id="SR3Eyg3vhv2DEOD4ux6HbU">检查间隔(秒)
|
<label>检查间隔(秒)
|
||||||
<input name="interval" id="f-interval" type="number" min="5" value="60" data-page-node-id="t4WQ8TOUFADFvrxWDqDLOO">
|
<input name="interval" id="f-interval" type="number" min="5" value="60">
|
||||||
</label>
|
</label>
|
||||||
<label data-page-node-id="pVjnoOnEBz06wQUDFuGnSG">认证方式
|
<label>认证方式
|
||||||
<select name="auth_method" id="f-auth" data-page-node-id="fsjBIeHacR0Y68FPjYrlIE">
|
<select name="auth_method" id="f-auth">
|
||||||
<option value="ssh" data-page-node-id="D1rhiol1YcEJjSQptztATh">SSH</option>
|
<option value="ssh">SSH</option>
|
||||||
<option value="https" data-page-node-id="SC5nYAdz2JBiHIks7TFSaN">HTTPS(支持重试)</option>
|
<option value="https">HTTPS(支持重试)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label data-page-node-id="HzJHn4COhFnLGwn6aIiBKP">冲突策略
|
<label>冲突策略
|
||||||
<select name="conflict_strategy" id="f-conflict" data-page-node-id="tJK7z7t0ojAK0xkHaM80nd">
|
<select name="conflict_strategy" id="f-conflict">
|
||||||
<option value="merge" data-page-node-id="BdiprVQi0oHS9aTDD4LQ2l">merge(冲突后等待人工解决)</option>
|
<option value="merge">merge(冲突后等待人工解决)</option>
|
||||||
<option value="rebase" data-page-node-id="jCngOAxfELckp4A3mrk1qI">rebase(变基)</option>
|
<option value="rebase">rebase(变基)</option>
|
||||||
<option value="abort" data-page-node-id="aGj68eybADJcGx5G1g85UB">abort(冲突后自动放弃)</option>
|
<option value="abort">abort(冲突后自动放弃)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label data-page-node-id="gwR8fc4feWQMTOGVirMjCO">推送策略(non-fast-forward)
|
<label>重试次数(HTTPS)
|
||||||
<select name="push_strategy" id="f-push" data-page-node-id="HCS9CZyBErr1VeYSJxKStW">
|
<input name="retry_count" id="f-retry" type="number" min="0" value="3">
|
||||||
<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>
|
||||||
<label data-page-node-id="Nv2HjS10oANWojhUmKVQE4">重试次数(HTTPS)
|
<label>重试间隔(秒,HTTPS)
|
||||||
<input name="retry_count" id="f-retry" type="number" min="0" value="3" data-page-node-id="UEgQOQBxHxqIfR5kYyZctp">
|
<input name="retry_interval" id="f-retry-interval" type="number" min="0" value="5">
|
||||||
</label>
|
|
||||||
<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 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>
|
||||||
<label class="full check-row">
|
<label class="full check-row">
|
||||||
<input type="checkbox" id="f-autosync">
|
<input type="checkbox" id="f-autocommit">
|
||||||
<span>自动同步:按间隔 / Cron 自动拉取推送。关闭后仅响应「立即同步」手动触发,不再自动运行</span>
|
<span>自动提交模式:检测到未提交改动时自动 commit,并随同步推送到远程</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="full" data-page-node-id="tei066TFL8N9sqInjysFvD">Cron 表达式(可选,5 段:分 时 日 月 周;填写后优先于固定间隔)
|
<label class="full">Cron 表达式(可选,5 段:分 时 日 月 周;填写后优先于固定间隔)
|
||||||
<input name="cron" id="f-cron" placeholder="*/30 * * * * 表示每 30 分钟,留空使用固定间隔" data-page-node-id="6W3Ua8pPoGLuaNh7rGcDOw">
|
<input name="cron" id="f-cron" placeholder="*/30 * * * * 表示每 30 分钟,留空使用固定间隔">
|
||||||
<span id="cron-next" class="cron-next" data-page-node-id="WgXuBnZPqGWvOBGRxzIVCn"></span>
|
|
||||||
</label>
|
</label>
|
||||||
<label class="full" data-page-node-id="fsjZ6wLid5NUBV0yAHKdII">日志文件路径(留空使用默认 logs/配置名.log)
|
<label class="full">日志文件路径(留空使用默认 logs/配置名.log)
|
||||||
<input name="log_file" id="f-log" data-page-node-id="KJpv7jEWYnOQZ1PtQMetfP">
|
<input name="log_file" id="f-log">
|
||||||
</label>
|
</label>
|
||||||
<div class="summary-box" id="summary-box" hidden>
|
|
||||||
<div class="summary-head">
|
|
||||||
<span>变更摘要(本地,不同步)</span>
|
|
||||||
<button type="button" class="btn btn-sm" id="btn-summary-save">保存摘要</button>
|
|
||||||
</div>
|
</div>
|
||||||
<textarea id="f-summary" class="file-editor" style="min-height:84px" placeholder="填写后将作为【自动提交】的下一次 commit 信息;留空则由工具自动生成一句话并在本地记录。此摘要与映射仅存于本地 summaries/,不会上传。"></textarea>
|
<div class="form-actions">
|
||||||
<div class="summary-log" id="summary-log"></div>
|
<button type="submit" class="btn btn-primary">保存</button>
|
||||||
</div>
|
<button type="button" class="btn btn-danger" id="btn-delete" hidden>删除配置</button>
|
||||||
</div>
|
|
||||||
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,42 +132,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="login" class="modal" hidden data-page-node-id="bALcA8zunmLBixpK2bI55B">
|
<div id="login" class="modal" hidden>
|
||||||
<div class="modal-box login-box" data-page-node-id="kcGN8ZqtOQx7KUIgGpoGCN">
|
<div class="modal-box login-box">
|
||||||
<div class="modal-head" data-page-node-id="TPAEpzey0DyD3tbzQcX6AD">
|
<div class="modal-head">
|
||||||
<h2 data-page-node-id="SxFYrLjYGUJGks4LhmWFGa">Git Sync 控制台</h2>
|
<h2>Git Sync 控制台</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="login-body" data-page-node-id="loM04avwKu3ehaXbaPV1dc">
|
<div class="login-body">
|
||||||
<label data-page-node-id="PQlrAv5lABcwv1V9A2kAZf">访问密码
|
<label>访问密码
|
||||||
<input type="password" id="login-password" data-page-node-id="BQQm1tLuMFSt6HhCgtUSzi">
|
<input type="password" id="login-password">
|
||||||
</label>
|
</label>
|
||||||
<p id="login-err" class="login-err" hidden data-page-node-id="c5KlyY0GNpRJlp2ZCDLC2P"></p>
|
<p id="login-err" class="login-err" hidden></p>
|
||||||
<div class="form-actions" data-page-node-id="dKo7nNYzQBz8MQZa7x51Bf">
|
<div class="form-actions">
|
||||||
<button class="btn btn-primary" id="btn-login" data-page-node-id="EOo505vSrX2QIGGOnpcDxp">登录</button>
|
<button class="btn btn-primary" id="btn-login">登录</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toast 轻提示容器(替代原生 alert,固定在右下角,不打断操作) -->
|
|
||||||
<div id="toasts" class="toasts" data-page-node-id="sGCNypYh5YHNDe7nZ7TUeA"></div>
|
|
||||||
|
|
||||||
<script src="/app.js"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+7
-155
@@ -23,21 +23,21 @@ header { display: flex; justify-content: space-between; align-items: center; pad
|
|||||||
.btn-danger:hover { background: var(--red-bg); }
|
.btn-danger:hover { background: var(--red-bg); }
|
||||||
.btn-icon { border: none; background: none; font-size: 18px; cursor: pointer; color: var(--muted); }
|
.btn-icon { border: none; background: none; font-size: 18px; cursor: pointer; color: var(--muted); }
|
||||||
|
|
||||||
main { max-width: 1320px; margin: 18px auto; padding: 0 18px; }
|
main { max-width: 1320px; margin: 24px auto; padding: 0 24px; }
|
||||||
.layout { display: flex; gap: 16px; align-items: flex-start; }
|
.layout { display: flex; gap: 20px; align-items: flex-start; }
|
||||||
.main-col { flex: 1; min-width: 0; }
|
.main-col { flex: 1; min-width: 0; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||||
.empty { text-align: center; color: var(--muted); padding: 80px 0; }
|
.empty { text-align: center; color: var(--muted); padding: 80px 0; }
|
||||||
|
|
||||||
/* 全局实时活动面板 */
|
/* 全局实时活动面板 */
|
||||||
.live { width: 460px; flex: none; position: sticky; top: 78px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); display: flex; flex-direction: column; max-height: calc(100vh - 100px); }
|
.live { width: 360px; flex: none; position: sticky; top: 80px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); display: flex; flex-direction: column; max-height: calc(100vh - 104px); }
|
||||||
.live-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
.live-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
||||||
.live-head h2 { margin: 0; font-size: 15px; font-weight: 500; flex: 1; }
|
.live-head h2 { margin: 0; font-size: 15px; font-weight: 500; flex: 1; }
|
||||||
.live-head .conn { margin: 0; }
|
.live-head .conn { margin: 0; }
|
||||||
.btn-sm { border: 1px solid var(--border); background: #fff; border-radius: 7px; padding: 3px 10px; font-size: 12px; cursor: pointer; color: var(--muted); }
|
.btn-sm { border: 1px solid var(--border); background: #fff; border-radius: 7px; padding: 3px 10px; font-size: 12px; cursor: pointer; color: var(--muted); }
|
||||||
.btn-sm:hover { border-color: #b6bcc4; background: #f3f4f6; }
|
.btn-sm:hover { border-color: #b6bcc4; background: #f3f4f6; }
|
||||||
.live-hint { font-size: 11px; color: var(--muted); padding: 8px 14px; border-bottom: 1px solid var(--border); }
|
.live-hint { font-size: 11px; color: var(--muted); padding: 8px 14px; border-bottom: 1px solid var(--border); }
|
||||||
.live-log { flex: 1; overflow-y: auto; padding: 8px 10px; font-family: Consolas, "Cascadia Code", monospace; font-size: 12px; line-height: 1.45; }
|
.live-log { flex: 1; overflow-y: auto; padding: 8px 10px; font-family: Consolas, "Cascadia Code", monospace; font-size: 11.5px; line-height: 1.5; }
|
||||||
.live-line { display: flex; gap: 6px; padding: 2px 0; border-bottom: 1px solid #f2f2f2; }
|
.live-line { display: flex; gap: 6px; padding: 2px 0; border-bottom: 1px solid #f2f2f2; }
|
||||||
.live-line .ll-time { color: #9aa0a6; flex: none; }
|
.live-line .ll-time { color: #9aa0a6; flex: none; }
|
||||||
.live-line .ll-repo { color: var(--blue); flex: none; font-weight: 600; max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.live-line .ll-repo { color: var(--blue); flex: none; font-weight: 600; max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
@@ -53,7 +53,7 @@ main { max-width: 1320px; margin: 18px auto; padding: 0 18px; }
|
|||||||
.live { width: 100%; position: static; max-height: 360px; }
|
.live { width: 100%; position: static; max-height: 360px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; display: flex; flex-direction: column; gap: 6px; }
|
.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
.card.st-conflict { border-color: #e8c98a; background: #fffdf6; }
|
.card.st-conflict { border-color: #e8c98a; background: #fffdf6; }
|
||||||
.card.st-error { border-color: #eec9c9; }
|
.card.st-error { border-color: #eec9c9; }
|
||||||
.card-head { display: flex; align-items: center; gap: 8px; }
|
.card-head { display: flex; align-items: center; gap: 8px; }
|
||||||
@@ -89,8 +89,7 @@ main { max-width: 1320px; margin: 18px auto; padding: 0 18px; }
|
|||||||
.tab-pane[hidden] { display: none; }
|
.tab-pane[hidden] { display: none; }
|
||||||
|
|
||||||
.logbox { margin: 0; background: #f6f7f9; border: 1px solid var(--border); padding: 12px; border-radius: 8px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 55vh; overflow: auto; }
|
.logbox { margin: 0; background: #f6f7f9; border: 1px solid var(--border); padding: 12px; border-radius: 8px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 55vh; overflow: auto; }
|
||||||
.conflict-actions { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
.conflict-actions { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||||
.manual-actions { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
||||||
.conflict-file { margin-bottom: 14px; }
|
.conflict-file { margin-bottom: 14px; }
|
||||||
.conflict-file h4 { margin: 0 0 6px; font-size: 13px; font-family: Consolas, monospace; }
|
.conflict-file h4 { margin: 0 0 6px; font-size: 13px; font-family: Consolas, monospace; }
|
||||||
.conflict-file pre { margin: 0; background: #f6f7f9; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow: auto; }
|
.conflict-file pre { margin: 0; background: #f6f7f9; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow: auto; }
|
||||||
@@ -122,150 +121,3 @@ textarea { font-family: Consolas, monospace; resize: vertical; }
|
|||||||
.form-actions { display: flex; gap: 8px; margin-top: 16px; }
|
.form-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
|
|
||||||
@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } }
|
@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
/* ---------- 差异显示(网页端) ---------- */
|
|
||||||
.diff-head { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
|
|
||||||
.diff-head label { font-size: 12px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
|
|
||||||
.diff-stat { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; font-size: 12px; }
|
|
||||||
.diff-stat .df { background: var(--gray-bg); border-radius: 6px; padding: 1px 8px; font-family: Consolas, monospace; }
|
|
||||||
.diff-stat .df.df-add { background: var(--green-bg); }
|
|
||||||
.diff-stat .df.df-del { background: var(--red-bg); }
|
|
||||||
.diffbox { margin: 0; background: #f6f7f9; border: 1px solid var(--border); padding: 10px; border-radius: 8px; font: 12px/1.5 Consolas, monospace; white-space: pre; word-break: break-all; max-height: 56vh; overflow: auto; }
|
|
||||||
.diff-line { white-space: pre; }
|
|
||||||
.diff-line.diff-add { background: var(--green-bg); color: var(--green); }
|
|
||||||
.diff-line.diff-del { background: var(--red-bg); color: var(--red); }
|
|
||||||
.diff-line.diff-hunk { color: var(--blue); background: #eef3f8; }
|
|
||||||
|
|
||||||
/* ---------- 文件编辑(网页端) ---------- */
|
|
||||||
.file-head { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
|
|
||||||
.file-current { font-size: 12px; color: var(--muted); font-family: Consolas, monospace; }
|
|
||||||
.file-body { display: flex; gap: 14px; align-items: stretch; min-height: 420px; }
|
|
||||||
.file-list { flex: none; width: 280px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 6px; background: #fff; max-height: 56vh; }
|
|
||||||
.file-list:empty::after { content: '(无文件)'; color: var(--muted); font-size: 12px; padding: 4px; }
|
|
||||||
.file-item { padding: 4px 8px; border-radius: 6px; font-size: 12.5px; font-family: Consolas, monospace; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.file-item:hover { background: #f0f3f6; }
|
|
||||||
.file-edit-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.file-chg-hint { font-size: 12px; color: #9a6b00; background: rgba(255, 196, 0, 0.12); border: 1px solid rgba(224, 160, 32, 0.4); border-radius: 6px; padding: 4px 8px; }
|
|
||||||
html.dark .file-chg-hint { color: #d9a93a; background: rgba(255, 196, 0, 0.1); border-color: rgba(201, 143, 28, 0.4); }
|
|
||||||
.file-editor { flex: 1; min-height: 360px; width: 100%; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12.5px/1.55 Consolas, monospace; color: var(--text); background: #fff; white-space: pre; overflow: auto; }
|
|
||||||
.file-editor:disabled { background: #f6f7f9; color: var(--muted); }
|
|
||||||
.file-actions { display: flex; gap: 8px; align-items: center; }
|
|
||||||
.file-msg { flex: 1; }
|
|
||||||
|
|
||||||
.summary-box { margin-top: 16px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--card); }
|
|
||||||
.summary-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 13px; color: var(--muted); }
|
|
||||||
.summary-head .btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
||||||
.summary-log { margin-top: 10px; padding: 10px 12px; border: 1px dashed var(--border); border-radius: 8px; background: #fafbfc; font: 12px/1.6 Consolas, "Microsoft YaHei", monospace; color: var(--text); white-space: pre-wrap; max-height: 220px; overflow: auto; }
|
|
||||||
html.dark .summary-log { background: #12151a; }
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.file-body { flex-direction: column; }
|
|
||||||
.file-list { width: 100%; max-height: 200px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 暗色模式 ---------- */
|
|
||||||
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); }
|
|
||||||
.badge.b-paused { background: var(--gray-bg); color: var(--muted); }
|
|
||||||
|
|
||||||
/* ---------- 配置: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; }
|
|
||||||
|
|
||||||
/* 未提交修改行高亮 */
|
|
||||||
.CodeMirror-linebackground.cm-chg {
|
|
||||||
background-color: rgba(255, 196, 0, 0.16);
|
|
||||||
box-shadow: inset 3px 0 0 0 #e0a020;
|
|
||||||
}
|
|
||||||
html.dark .CodeMirror-linebackground.cm-chg {
|
|
||||||
background-color: rgba(255, 196, 0, 0.13);
|
|
||||||
box-shadow: inset 3px 0 0 0 #c98f1c;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 冲突文件内联编辑 ---------- */
|
|
||||||
.cf-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
|
||||||
.cf-head h4 { margin: 0; flex: 1; }
|
|
||||||
.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; flex-wrap: wrap; }
|
|
||||||
.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; }
|
|
||||||
|
|||||||
Vendored
-8
File diff suppressed because one or more lines are too long
Vendored
-8
File diff suppressed because one or more lines are too long
Vendored
-23
@@ -1,23 +0,0 @@
|
|||||||
.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; }
|
|
||||||
Vendored
-135
@@ -1,135 +0,0 @@
|
|||||||
/*
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
Vendored
-8
File diff suppressed because one or more lines are too long
Vendored
-8
File diff suppressed because one or more lines are too long
Vendored
-8
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
Vendored
-8
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
-8
File diff suppressed because one or more lines are too long
-8
File diff suppressed because one or more lines are too long
-8
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* Minified by jsDelivr using Terser v5.39.0.
|
|
||||||
* Original file: /npm/[email protected]/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
|
|
||||||
Vendored
-8
File diff suppressed because one or more lines are too long
Vendored
-8
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* Minified by jsDelivr using Terser v5.39.0.
|
|
||||||
* Original file: /npm/[email protected]/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
|
|
||||||
Vendored
-8
File diff suppressed because one or more lines are too long
Vendored
-8
File diff suppressed because one or more lines are too long
Vendored
-8
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* Minified by jsDelivr using Terser v5.39.0.
|
|
||||||
* Original file: /npm/[email protected]/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
|
|
||||||
Reference in New Issue
Block a user