diff --git a/_smoke/base/configs/demo.conf b/_smoke/base/configs/demo.conf
new file mode 100644
index 0000000..0ecf185
--- /dev/null
+++ b/_smoke/base/configs/demo.conf
@@ -0,0 +1,14 @@
+# Git 自动同步配置 - demo
+REPO_PATH="C:/OtherFiles/Github/git-auto-sync/git-sync-web/_smoke/repo"
+BRANCH="main"
+REMOTES="origin=https://example.invalid/x.git"
+MODE="both"
+INTERVAL="60"
+LOG_FILE=""
+AUTH_METHOD="https"
+RETRY_COUNT="0"
+RETRY_INTERVAL="0"
+CONFLICT_STRATEGY="merge"
+PUSH_STRATEGY="manual"
+AUTO_COMMIT="0"
+CRON=""
diff --git a/_smoke/base/logs/demo.log b/_smoke/base/logs/demo.log
new file mode 100644
index 0000000..f0e389e
--- /dev/null
+++ b/_smoke/base/logs/demo.log
@@ -0,0 +1,5 @@
+[2026-09-09 13:51:38] [info] 配置已创建(路径 C:/OtherFiles/Github/git-auto-sync/git-sync-web/_smoke/repo)
+[2026-09-09 13:51:43] [info] 监控已启动
+[2026-09-09 13:51:43] [info] 已添加远程 origin → https://example.invalid/x.git
+[2026-09-09 13:51:44] [info] === 开始同步(启动检查,模式 both)===
+[2026-09-09 13:51:45] [error] fetch 失败: fatal: unable to access 'https://example.invalid/x.git/': CONNECT tunnel failed, response 502
diff --git a/_smoke/repo b/_smoke/repo
new file mode 160000
index 0000000..39a01d8
--- /dev/null
+++ b/_smoke/repo
@@ -0,0 +1 @@
+Subproject commit 39a01d89da349508c5f3fdf60ae480745f41efa0
diff --git a/_smoke/server.log b/_smoke/server.log
new file mode 100644
index 0000000..5085d0b
--- /dev/null
+++ b/_smoke/server.log
@@ -0,0 +1 @@
+2026/09/09 13:50:57 Git Sync Web 已启动: http://127.0.0.1:5831(鉴权:false)
diff --git a/_smoke2/base/configs/demo.conf b/_smoke2/base/configs/demo.conf
new file mode 100644
index 0000000..330b0cd
--- /dev/null
+++ b/_smoke2/base/configs/demo.conf
@@ -0,0 +1,14 @@
+# Git 自动同步配置 - demo
+REPO_PATH="C:/OtherFiles/Github/git-auto-sync/git-sync-web/_smoke2/repo"
+BRANCH="main"
+REMOTES="origin=https://example.invalid/x.git"
+MODE="both"
+INTERVAL="60"
+LOG_FILE=""
+AUTH_METHOD="https"
+RETRY_COUNT="0"
+RETRY_INTERVAL="0"
+CONFLICT_STRATEGY="merge"
+PUSH_STRATEGY="manual"
+AUTO_COMMIT="0"
+CRON=""
diff --git a/_smoke2/base/logs/demo.log b/_smoke2/base/logs/demo.log
new file mode 100644
index 0000000..bdeb821
--- /dev/null
+++ b/_smoke2/base/logs/demo.log
@@ -0,0 +1 @@
+[2026-09-09 13:55:33] [info] 配置已创建(路径 C:/OtherFiles/Github/git-auto-sync/git-sync-web/_smoke2/repo)
diff --git a/_smoke2/repo b/_smoke2/repo
new file mode 160000
index 0000000..e7bbd5c
--- /dev/null
+++ b/_smoke2/repo
@@ -0,0 +1 @@
+Subproject commit e7bbd5cce2e657817cedfa2bbf0eecd81ed8b450
diff --git a/_smoke2/server.log b/_smoke2/server.log
new file mode 100644
index 0000000..01375f7
--- /dev/null
+++ b/_smoke2/server.log
@@ -0,0 +1 @@
+2026/09/09 13:54:59 Git Sync Web 已启动: http://127.0.0.1:5832(鉴权:false)
diff --git a/git.go b/git.go
index 4d41869..9fb8dc0 100644
--- a/git.go
+++ b/git.go
@@ -30,16 +30,22 @@ func gitRun(repoPath string, timeout time.Duration, args ...string) (string, err
"GIT_SSH_COMMAND=ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new",
)
cmd.WaitDelay = 10 * time.Second
- var buf bytes.Buffer
- cmd.Stdout = &buf
- cmd.Stderr = &buf
+ // stdout 与 stderr 分开收集:成功时只返回干净的 stdout 供解析,
+ // 避免 git 的环境告警(如 autocrlf、进度信息)污染解析结果;
+ // 失败时优先用 stderr 作为错误信息,保证诊断信息不丢失。
+ var outBuf, errBuf bytes.Buffer
+ cmd.Stdout = &outBuf
+ cmd.Stderr = &errBuf
err := cmd.Run()
- out := strings.TrimSpace(buf.String())
+ out := strings.TrimSpace(outBuf.String())
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return out, fmt.Errorf("命令超时: git %s", strings.Join(args, " "))
}
- msg := out
+ msg := strings.TrimSpace(errBuf.String())
+ if msg == "" {
+ msg = out
+ }
if msg == "" {
msg = err.Error()
}
diff --git a/static/app.js b/static/app.js
index 431f490..fc9e534 100644
--- a/static/app.js
+++ b/static/app.js
@@ -434,14 +434,38 @@ function updateConflictActions() {
}
// ---------- 差异显示(网页端) ----------
+// diffPath 非空时只显示单个文件的差异(编辑器「对比此文件」进入)
+let diffPath = '';
+// diffText 保存当前差异原文,供「复制 / 下载 .patch」使用
+let diffText = '';
+
+function updateDiffFilterNote() {
+ const el = $('#diff-filter');
+ if (!el) return;
+ if (!diffPath) { el.hidden = true; el.textContent = ''; return; }
+ el.hidden = false;
+ el.innerHTML = `仅显示:${esc(diffPath)} ✕ 显示全部`;
+ const a = $('#diff-filter-clear');
+ if (a) a.addEventListener('click', (e) => {
+ e.preventDefault();
+ diffPath = '';
+ updateDiffFilterNote();
+ if (current) loadDiff(current);
+ });
+}
+
function loadDiff(name) {
const scope = $('#f-diff-scope').value;
const box = $('#diffbox');
const stat = $('#diff-stat');
box.textContent = '加载中…';
stat.innerHTML = '';
- api(`/api/repos/${name}/diff?scope=${encodeURIComponent(scope)}`).then(d => {
- renderDiff(box, d.text || '(无差异)');
+ updateDiffFilterNote();
+ let url = `/api/repos/${name}/diff?scope=${encodeURIComponent(scope)}`;
+ if (diffPath) url += `&path=${encodeURIComponent(diffPath)}`;
+ api(url).then(d => {
+ diffText = d.text || '';
+ renderDiff(box, diffText || '(无差异)');
if (d.files && d.files.length) {
stat.innerHTML = d.files.map(f =>
`${esc(f.path)} +${f.add} -${f.del}`
@@ -470,33 +494,81 @@ function renderDiff(box, text) {
// ---------- 文件编辑(网页端) ----------
let fileCurrent = null;
+const STATUS_LABEL = { M: '已修改', A: '已新增', D: '已删除', '?': '未跟踪' };
function loadTree(name) {
const list = $('#file-list');
list.textContent = '加载中…';
api(`/api/repos/${name}/tree`).then(d => {
const files = d.files || [];
if (!files.length) { list.textContent = '(无文件)'; return; }
- list.innerHTML = files.map(f =>
- `
${esc(f.path)}
`
- ).join('');
+ list.innerHTML = files.map(f => {
+ const st = f.status || '';
+ const cls = st === '?' ? 'Q' : st;
+ const badge = st
+ ? `${esc(st)}`
+ : '';
+ return `${esc(f.path)}${badge}
`;
+ }).join('');
}).catch(err => { list.textContent = '文件列表加载失败:' + err.message; });
}
+// ---------- 草稿缓存(localStorage,防刷新丢稿) ----------
+const draftKey = (name, path) => `gitautosync:draft:${name}:${path}`;
+let draftServer = ''; // 当前文件在磁盘上的原文,用于「放弃草稿」回滚
+let draftTimer = null;
+
+function showDraftNote(on) {
+ const el = $('#file-draft');
+ if (!el) return;
+ if (!on) { el.hidden = true; el.innerHTML = ''; return; }
+ el.hidden = false;
+ el.innerHTML = '已恢复未保存的草稿 放弃草稿';
+ const a = $('#draft-discard');
+ if (a) a.addEventListener('click', (e) => {
+ e.preventDefault();
+ localStorage.removeItem(draftKey(current, fileCurrent));
+ $('#file-editor').value = draftServer;
+ showDraftNote(false);
+ });
+}
+
+// 编辑时防抖缓存草稿;内容与磁盘一致时自动清除
+function scheduleDraft() {
+ if (!current || !fileCurrent) return;
+ if (draftTimer) clearTimeout(draftTimer);
+ draftTimer = setTimeout(() => {
+ const editor = $('#file-editor');
+ if (!editor || editor.disabled) return;
+ const v = editor.value;
+ if (v === draftServer) localStorage.removeItem(draftKey(current, fileCurrent));
+ else localStorage.setItem(draftKey(current, fileCurrent), v);
+ }, 600);
+}
+
async function loadFile(name, path) {
const editor = $('#file-editor');
editor.value = '加载中…';
editor.disabled = false;
$('#file-current').textContent = path;
fileCurrent = path;
+ showDraftNote(false);
try {
const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`);
if (d.binary) {
editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
editor.disabled = true;
- } else {
- editor.value = d.content || '';
- editor.disabled = false;
+ draftServer = '';
+ return;
}
+ draftServer = d.content || '';
+ const saved = localStorage.getItem(draftKey(name, path));
+ if (saved !== null && saved !== draftServer) {
+ editor.value = saved;
+ showDraftNote(true);
+ } else {
+ editor.value = draftServer;
+ }
+ editor.disabled = false;
} catch (err) {
editor.value = '读取失败:' + err.message;
editor.disabled = true;
@@ -517,6 +589,10 @@ async function saveFile(commit) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, content })
});
+ // 写盘成功 → 草稿作废
+ localStorage.removeItem(draftKey(name, path));
+ showDraftNote(false);
+ draftServer = content;
if (commit) {
await api(`/api/repos/${name}/commit`, {
method: 'POST',
@@ -535,7 +611,40 @@ async function saveFile(commit) {
$('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
$('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(current); });
+
+// 差异:复制 / 下载 .patch
+$('#btn-diff-copy').addEventListener('click', async () => {
+ if (!diffText) { alert('当前没有可复制的差异内容'); return; }
+ try {
+ await navigator.clipboard.writeText(diffText);
+ alert('差异内容已复制到剪贴板');
+ } catch (err) {
+ alert('复制失败(浏览器可能限制了剪贴板权限):' + err.message);
+ }
+});
+$('#btn-diff-download').addEventListener('click', () => {
+ if (!diffText) { alert('当前没有可下载的差异内容'); return; }
+ const scope = $('#f-diff-scope').value;
+ const blob = new Blob([diffText], { type: 'text/plain;charset=utf-8' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = `${current || 'repo'}-${scope}.patch`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(a.href);
+});
+
$('#btn-file-refresh').addEventListener('click', () => { if (current) loadTree(current); });
+
+// 编辑器「对比此文件」→ 跳到差异 tab 并只看该文件
+$('#btn-file-diff').addEventListener('click', () => {
+ if (!current || !fileCurrent) { alert('请先在左侧选择一个文件'); return; }
+ diffPath = fileCurrent;
+ $('#f-diff-scope').value = 'working';
+ showTab('diff');
+});
+
$('#file-list').addEventListener('click', (e) => {
const item = e.target.closest('.file-item');
if (!item || !current) return;
@@ -544,6 +653,15 @@ $('#file-list').addEventListener('click', (e) => {
$('#btn-file-save').addEventListener('click', () => saveFile(false));
$('#btn-file-commit').addEventListener('click', () => saveFile(true));
+// 编辑器:输入时缓存草稿,Ctrl/Cmd + S 直接保存
+$('#file-editor').addEventListener('input', scheduleDraft);
+$('#file-editor').addEventListener('keydown', (e) => {
+ if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
+ e.preventDefault();
+ saveFile(false);
+ }
+});
+
// ---------- 统计 ----------
function dateStr(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
@@ -607,6 +725,7 @@ function fillForm(r) {
$('#f-autocommit').checked = r ? !!r.auto_commit : false;
$('#f-cron').value = r ? (r.cron || '') : '';
$('#f-log').value = r ? (r.log_file || '') : '';
+ previewCron();
}
function readForm() {
@@ -674,6 +793,43 @@ $('#btn-sync-all').addEventListener('click', () => {
repos.filter(r => r.running).forEach(r => post(`/api/repos/${r.name}/sync`).catch(() => {}));
});
+// ---------- 暗色模式 ----------
+const THEME_KEY = 'gitautosync:theme';
+function applyTheme(dark) {
+ document.documentElement.classList.toggle('dark', !!dark);
+ const btn = $('#btn-theme');
+ if (btn) btn.textContent = dark ? '☀️' : '🌙';
+}
+function initTheme() {
+ applyTheme(localStorage.getItem(THEME_KEY) === 'dark');
+}
+$('#btn-theme').addEventListener('click', () => {
+ const dark = !document.documentElement.classList.contains('dark');
+ localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light');
+ applyTheme(dark);
+});
+
+// ---------- Cron 下次执行预览 ----------
+let cronTimer = null;
+async function previewCron() {
+ const el = $('#cron-next');
+ if (!el) return;
+ const expr = $('#f-cron').value.trim();
+ if (!expr) { el.textContent = ''; el.className = 'cron-next'; return; }
+ try {
+ const d = await api(`/api/cron-next?expr=${encodeURIComponent(expr)}`);
+ el.textContent = '下次执行:' + d.next;
+ el.className = 'cron-next good';
+ } catch (err) {
+ el.textContent = '表达式无效:' + err.message;
+ el.className = 'cron-next bad';
+ }
+}
+$('#f-cron').addEventListener('input', () => {
+ if (cronTimer) clearTimeout(cronTimer);
+ cronTimer = setTimeout(previewCron, 400);
+});
+
// ---------- 初始化 ----------
async function refresh() {
try {
@@ -684,6 +840,7 @@ async function refresh() {
} catch (e) { /* 忽略,SSE 会重试 */ }
}
+initTheme();
refresh();
connect();
setInterval(refresh, 20000);