git-sync-web 自动提交 2026-09-09 15:06:45
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
# Git 自动同步配置 - demo
|
||||||
|
REPO_PATH="C:/OtherFiles/Github/git-auto-sync/git-sync-web/_sm/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=""
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[2026-09-09 14:59:58] [info] 配置已创建(路径 C:/OtherFiles/Github/git-auto-sync/git-sync-web/_sm/repo)
|
||||||
|
[2026-09-09 15:00:47] [info] 监控已启动
|
||||||
|
[2026-09-09 15:00:48] [info] 已添加远程 origin → https://example.invalid/x.git
|
||||||
|
[2026-09-09 15:00:48] [info] === 开始同步(启动检查,模式 both)===
|
||||||
|
[2026-09-09 15:00:50] [error] fetch 失败: fatal: unable to access 'https://example.invalid/x.git/': CONNECT tunnel failed, response 502
|
||||||
Submodule
+1
Submodule _sm/repo added at f5c70352ee
@@ -0,0 +1 @@
|
|||||||
|
2026/09/09 14:58:40 Git Sync Web 已启动: http://127.0.0.1:5851(鉴权:false)
|
||||||
@@ -1431,8 +1431,10 @@ func (e *Engine) WorkingDiff(name, scope, rel, mode string) (*DiffResult, error)
|
|||||||
args = []string{"diff", "--no-color"}
|
args = []string{"diff", "--no-color"}
|
||||||
}
|
}
|
||||||
// 词级差异:--word-diff=plain 用 [-x-] / {+x+} 标记行内改动
|
// 词级差异:--word-diff=plain 用 [-x-] / {+x+} 标记行内改动
|
||||||
|
// 注意:该模式下 git 输出的上下文行不带前导空格,前端解析需兼容(见 parseUnifiedDiff)
|
||||||
if mode == "word" {
|
if mode == "word" {
|
||||||
args = append(args, "--word-diff=plain")
|
args = append(args, "--word-diff=plain")
|
||||||
|
res.Word = true
|
||||||
}
|
}
|
||||||
statArgs := append([]string{}, args...)
|
statArgs := append([]string{}, args...)
|
||||||
if scope == "remote" {
|
if scope == "remote" {
|
||||||
|
|||||||
+146
-7
@@ -126,6 +126,8 @@ const Editor = {
|
|||||||
onSave: null, // 由 cm-boot.js 的快捷键触发
|
onSave: null, // 由 cm-boot.js 的快捷键触发
|
||||||
onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存)
|
onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存)
|
||||||
};
|
};
|
||||||
|
// 顶层 const 不会挂到 window,显式暴露给 cm-boot.js 使用
|
||||||
|
window.Editor = Editor;
|
||||||
|
|
||||||
// ---------- 渲染 ----------
|
// ---------- 渲染 ----------
|
||||||
function render() {
|
function render() {
|
||||||
@@ -329,6 +331,7 @@ function openDetail(name) {
|
|||||||
$('#m-title').textContent = name;
|
$('#m-title').textContent = name;
|
||||||
$('#f-name').disabled = true;
|
$('#f-name').disabled = true;
|
||||||
$('#btn-delete').hidden = false;
|
$('#btn-delete').hidden = false;
|
||||||
|
$('#btn-clone').hidden = false;
|
||||||
fillForm(r);
|
fillForm(r);
|
||||||
showTab('log');
|
showTab('log');
|
||||||
$('#modal').hidden = false;
|
$('#modal').hidden = false;
|
||||||
@@ -340,6 +343,7 @@ function openCreate() {
|
|||||||
$('#m-title').textContent = '新建配置';
|
$('#m-title').textContent = '新建配置';
|
||||||
$('#f-name').disabled = false;
|
$('#f-name').disabled = false;
|
||||||
$('#btn-delete').hidden = true;
|
$('#btn-delete').hidden = true;
|
||||||
|
$('#btn-clone').hidden = true;
|
||||||
fillForm(null);
|
fillForm(null);
|
||||||
showTab('config');
|
showTab('config');
|
||||||
$('#modal').hidden = false;
|
$('#modal').hidden = false;
|
||||||
@@ -445,12 +449,86 @@ function loadConflicts(name) {
|
|||||||
list.innerHTML = '<p style="color:var(--muted)">当前没有冲突。</p>';
|
list.innerHTML = '<p style="color:var(--muted)">当前没有冲突。</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = files.map(f =>
|
list.innerHTML = files.map(f => `
|
||||||
`<div class="conflict-file"><h4>${esc(f.file)}</h4><pre>${esc(f.diff) || '(无差异内容)'}</pre></div>`
|
<div class="conflict-file" data-path="${esc(f.file)}">
|
||||||
).join('');
|
<div class="cf-head">
|
||||||
|
<h4 title="${esc(f.file)}">${esc(f.file)}</h4>
|
||||||
|
<button class="btn btn-sm" data-cf="edit">编辑解决</button>
|
||||||
|
<button class="btn btn-sm" data-cf="close" hidden>收起</button>
|
||||||
|
</div>
|
||||||
|
<div class="cf-edit" hidden>
|
||||||
|
<textarea class="cf-ta" spellcheck="false" placeholder="加载中…"></textarea>
|
||||||
|
<div class="cf-actions">
|
||||||
|
<input class="file-msg cf-msg" placeholder="提交说明(可选)">
|
||||||
|
<button class="btn btn-sm" data-cf="save">仅保存</button>
|
||||||
|
<button class="btn btn-sm btn-primary" data-cf="resolve">保存并标记已解决</button>
|
||||||
|
</div>
|
||||||
|
<div class="cf-hint">「仅保存」只写回磁盘;「保存并标记已解决」会暂存该文件,并在已无其它冲突文件时结束合并/变基。</div>
|
||||||
|
</div>
|
||||||
|
<pre>${esc(f.diff) || '(无差异内容)'}</pre>
|
||||||
|
</div>`).join('');
|
||||||
}).catch(err => { $('#conflict-list').innerHTML = `<p style="color:var(--red)">${esc(err.message)}</p>`; });
|
}).catch(err => { $('#conflict-list').innerHTML = `<p style="color:var(--red)">${esc(err.message)}</p>`; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 冲突文件内联编辑:展开编辑区 → 读取原文 → 保存 / 保存并标记已解决
|
||||||
|
$('#conflict-list').addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('button[data-cf]');
|
||||||
|
if (!btn || !current) return;
|
||||||
|
const item = btn.closest('.conflict-file');
|
||||||
|
const path = item.dataset.path;
|
||||||
|
const act = btn.dataset.cf;
|
||||||
|
const wrap = item.querySelector('.cf-edit');
|
||||||
|
const ta = item.querySelector('.cf-ta');
|
||||||
|
const editBtn = item.querySelector('[data-cf="edit"]');
|
||||||
|
const closeBtn = item.querySelector('[data-cf="close"]');
|
||||||
|
|
||||||
|
if (act === 'edit') {
|
||||||
|
wrap.hidden = false;
|
||||||
|
editBtn.hidden = true;
|
||||||
|
closeBtn.hidden = false;
|
||||||
|
ta.disabled = false;
|
||||||
|
ta.value = '加载中…';
|
||||||
|
try {
|
||||||
|
const d = await api(`/api/repos/${current}/file?path=${encodeURIComponent(path)}`);
|
||||||
|
if (d.binary) {
|
||||||
|
ta.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
|
||||||
|
ta.disabled = true;
|
||||||
|
} else {
|
||||||
|
ta.value = d.content || '';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
ta.value = '读取失败:' + err.message;
|
||||||
|
ta.disabled = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (act === 'close') {
|
||||||
|
wrap.hidden = true;
|
||||||
|
editBtn.hidden = false;
|
||||||
|
closeBtn.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// save / resolve
|
||||||
|
if (ta.disabled) { toast('该文件当前不可编辑', 'error'); return; }
|
||||||
|
try {
|
||||||
|
await api(`/api/repos/${current}/resolve-file`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
path,
|
||||||
|
content: ta.value,
|
||||||
|
finish: act === 'resolve',
|
||||||
|
})
|
||||||
|
});
|
||||||
|
toast(act === 'resolve' ? '已保存并结束合并/变基' : '已保存到磁盘(尚未标记解决)', 'success');
|
||||||
|
loadConflicts(current);
|
||||||
|
updateConflictActions();
|
||||||
|
refresh();
|
||||||
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
async function doAction(action, okMsg) {
|
async function doAction(action, okMsg) {
|
||||||
try {
|
try {
|
||||||
await api(`/api/repos/${current}/action`, {
|
await api(`/api/repos/${current}/action`, {
|
||||||
@@ -562,6 +640,7 @@ function parseUnifiedDiff(text) {
|
|||||||
if (line.startsWith('@@')) {
|
if (line.startsWith('@@')) {
|
||||||
const m = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
const m = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
||||||
if (m) { oldNo = parseInt(m[1], 10); newNo = parseInt(m[2], 10); }
|
if (m) { oldNo = parseInt(m[1], 10); newNo = parseInt(m[2], 10); }
|
||||||
|
inHunk = true;
|
||||||
cur.rows.push({ type: 'hunk', text: line });
|
cur.rows.push({ type: 'hunk', text: line });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -573,9 +652,15 @@ function parseUnifiedDiff(text) {
|
|||||||
}
|
}
|
||||||
if (line.startsWith('+')) { cur.rows.push({ type: 'add', text: line.slice(1), no: newNo++ }); continue; }
|
if (line.startsWith('+')) { cur.rows.push({ type: 'add', text: line.slice(1), no: newNo++ }); continue; }
|
||||||
if (line.startsWith('-')) { cur.rows.push({ type: 'del', text: line.slice(1), no: oldNo++ }); continue; }
|
if (line.startsWith('-')) { cur.rows.push({ type: 'del', text: line.slice(1), no: oldNo++ }); continue; }
|
||||||
if (line.startsWith(' ')) { cur.rows.push({ type: 'ctx', text: line.slice(1), no: oldNo++ }); newNo++; continue; }
|
if (line.startsWith(' ')) { cur.rows.push({ type: 'ctx', text: line.slice(1), no: oldNo++, nNo: newNo++ }); continue; }
|
||||||
if (line === '') { cur.rows.push({ type: 'ctx', text: '', no: oldNo++ }); newNo++; continue; }
|
if (line === '') { cur.rows.push({ type: 'ctx', text: '', no: oldNo++, nNo: newNo++ }); continue; }
|
||||||
cur.rows.push({ type: 'meta', text: line }); // \ No newline 等
|
// 词级模式(--word-diff=plain)下 git 的上下文行不带前导空格,
|
||||||
|
// 因此 hunk 内未识别的行一律按上下文处理;hunk 外的(如 "\ No newline")按元信息处理。
|
||||||
|
if (inHunk && !line.startsWith('\\')) {
|
||||||
|
cur.rows.push({ type: 'ctx', text: line, no: oldNo++, nNo: newNo++ });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cur.rows.push({ type: 'meta', text: line });
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
@@ -605,7 +690,12 @@ function buildSplitRows(rows) {
|
|||||||
while (i < rows.length) {
|
while (i < rows.length) {
|
||||||
const r = rows[i];
|
const r = rows[i];
|
||||||
if (r.type === 'hunk' || r.type === 'meta') { out.push({ kind: r.type, text: r.text }); i++; continue; }
|
if (r.type === 'hunk' || r.type === 'meta') { out.push({ kind: r.type, text: r.text }); i++; continue; }
|
||||||
if (r.type === 'ctx') { out.push({ kind: 'ctx', left: r, right: r }); i++; continue; }
|
if (r.type === 'ctx') {
|
||||||
|
// 左右列行号分别取旧/新行号,故右侧用副本
|
||||||
|
out.push({ kind: 'ctx', left: r, right: { text: r.text, no: r.nNo == null ? r.no : r.nNo } });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const dels = [], adds = [];
|
const dels = [], adds = [];
|
||||||
while (i < rows.length && rows[i].type === 'del') dels.push(rows[i++]);
|
while (i < rows.length && rows[i].type === 'del') dels.push(rows[i++]);
|
||||||
while (i < rows.length && rows[i].type === 'add') adds.push(rows[i++]);
|
while (i < rows.length && rows[i].type === 'add') adds.push(rows[i++]);
|
||||||
@@ -817,6 +907,9 @@ async function saveFile(commit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
|
$('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
|
||||||
|
// 并排/统一纯前端重渲染(同一份 diff 原文);词级需后端重新生成,故重新拉取
|
||||||
|
$('#f-diff-view').addEventListener('change', () => { renderDiff($('#diffbox'), diffText || '(无差异)'); });
|
||||||
|
$('#f-diff-word').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
|
||||||
$('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(current); });
|
$('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(current); });
|
||||||
|
|
||||||
// 差异:复制 / 下载 .patch
|
// 差异:复制 / 下载 .patch
|
||||||
@@ -993,19 +1086,65 @@ $('#btn-delete').addEventListener('click', async () => {
|
|||||||
} catch (err) { toast(err.message, 'error'); }
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- 配置「克隆」按钮(多仓库同构时省得重填) ----------
|
||||||
|
// 复用 readForm 取当前表单值 → 切到新建态:名称自动加 -copy(去重),仓库路径清空
|
||||||
|
$('#btn-clone').addEventListener('click', () => {
|
||||||
|
if (!current) return;
|
||||||
|
const base = readForm();
|
||||||
|
const oldName = base.name || 'repo';
|
||||||
|
creating = true;
|
||||||
|
current = null;
|
||||||
|
const stem = oldName.replace(/-copy\d*$/i, '');
|
||||||
|
let name = stem + '-copy';
|
||||||
|
const taken = new Set(repos.map(r => r.name));
|
||||||
|
let i = 2;
|
||||||
|
while (taken.has(name)) name = stem + '-copy' + (i++);
|
||||||
|
$('#m-title').textContent = `克隆配置:${oldName}`;
|
||||||
|
$('#f-name').disabled = false;
|
||||||
|
$('#btn-delete').hidden = true;
|
||||||
|
$('#btn-clone').hidden = true;
|
||||||
|
fillForm(base);
|
||||||
|
$('#f-name').value = name;
|
||||||
|
$('#f-path').value = ''; // 路径必须换,否则会与源仓库指向同一目录
|
||||||
|
showTab('config');
|
||||||
|
toast('已载入原配置内容:请修改「配置名称」与「仓库路径」后保存(仓库路径已清空,需指向新目录)', 'info');
|
||||||
|
$('#f-name').focus();
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- 顶部按钮 ----------
|
// ---------- 顶部按钮 ----------
|
||||||
$('#btn-new').addEventListener('click', openCreate);
|
$('#btn-new').addEventListener('click', openCreate);
|
||||||
$('#btn-empty-new').addEventListener('click', openCreate);
|
$('#btn-empty-new').addEventListener('click', openCreate);
|
||||||
$('#btn-sync-all').addEventListener('click', () => {
|
$('#btn-sync-all').addEventListener('click', () => {
|
||||||
repos.filter(r => r.running).forEach(r => post(`/api/repos/${r.name}/sync`).catch(() => {}));
|
repos.filter(r => r.running).forEach(r => post(`/api/repos/${r.name}/sync`).catch(() => {}));
|
||||||
|
toast(`已向 ${repos.filter(r => r.running).length} 个运行中的仓库发起同步`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 批量启停:并发调用,汇总成功/失败
|
||||||
|
async function batchToggle(act) {
|
||||||
|
const isStart = act === 'start';
|
||||||
|
const targets = repos.filter(r => isStart ? !r.running : r.running);
|
||||||
|
if (!targets.length) {
|
||||||
|
toast(isStart ? '没有处于暂停状态的仓库' : '没有正在运行的仓库');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ok = 0, fail = 0;
|
||||||
|
await Promise.all(targets.map(async (r) => {
|
||||||
|
try { await post(`/api/repos/${r.name}/${act}`); ok++; }
|
||||||
|
catch (_) { fail++; }
|
||||||
|
}));
|
||||||
|
toast(`${isStart ? '启动' : '暂停'}完成:成功 ${ok}${fail ? `,失败 ${fail}` : ''}`, fail ? 'warn' : 'success');
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
$('#btn-start-all').addEventListener('click', () => batchToggle('start'));
|
||||||
|
$('#btn-stop-all').addEventListener('click', () => batchToggle('stop'));
|
||||||
|
|
||||||
// ---------- 暗色模式 ----------
|
// ---------- 暗色模式 ----------
|
||||||
const THEME_KEY = 'gitautosync:theme';
|
const THEME_KEY = 'gitautosync:theme';
|
||||||
function applyTheme(dark) {
|
function applyTheme(dark) {
|
||||||
document.documentElement.classList.toggle('dark', !!dark);
|
document.documentElement.classList.toggle('dark', !!dark);
|
||||||
const btn = $('#btn-theme');
|
const btn = $('#btn-theme');
|
||||||
if (btn) btn.textContent = dark ? '☀️' : '🌙';
|
if (btn) btn.textContent = dark ? '☀️' : '🌙';
|
||||||
|
Editor.setTheme(!!dark); // CodeMirror 主题跟随(未启用 CM 时为空操作)
|
||||||
}
|
}
|
||||||
function initTheme() {
|
function initTheme() {
|
||||||
applyTheme(localStorage.getItem(THEME_KEY) === 'dark');
|
applyTheme(localStorage.getItem(THEME_KEY) === 'dark');
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// CodeMirror 引导:在 codemirror.min.js 及其 mode 加载完成后接管 #file-editor。
|
||||||
|
// 若库未加载或初始化失败,window.Editor.cm 保持 null,自动回退原生 textarea,功能不受影响。
|
||||||
|
(function () {
|
||||||
|
if (typeof window.CodeMirror === 'undefined' || typeof window.Editor === 'undefined') return;
|
||||||
|
var ta = document.getElementById('file-editor');
|
||||||
|
if (!ta) return;
|
||||||
|
try {
|
||||||
|
var dark = document.documentElement.classList.contains('dark');
|
||||||
|
var cm = window.CodeMirror.fromTextArea(ta, {
|
||||||
|
lineNumbers: true,
|
||||||
|
lineWrapping: true,
|
||||||
|
indentUnit: 2,
|
||||||
|
tabSize: 2,
|
||||||
|
theme: dark ? 'material-darker' : 'eclipse',
|
||||||
|
readOnly: ta.disabled ? 'nocursor' : false,
|
||||||
|
extraKeys: {
|
||||||
|
'Ctrl-S': function () { if (window.Editor.onSave) window.Editor.onSave(); },
|
||||||
|
'Cmd-S': function () { if (window.Editor.onSave) window.Editor.onSave(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.Editor.cm = cm;
|
||||||
|
// CodeMirror 的 change 事件桥接到草稿缓存
|
||||||
|
cm.on('change', function () { if (window.Editor.onChange) window.Editor.onChange(); });
|
||||||
|
// 模式跟随打开文件时的 setMode
|
||||||
|
var cur = window.Editor.value;
|
||||||
|
} catch (e) {
|
||||||
|
if (window.console) console.warn('CodeMirror 初始化失败,已回退原生编辑器:', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -245,3 +245,7 @@ html.dark .file-editor { background: #12151a; }
|
|||||||
.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; }
|
.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; }
|
html.dark .cf-ta { background: #12151a; }
|
||||||
.cf-actions { display: flex; gap: 8px; margin-top: 6px; }
|
.cf-actions { display: flex; gap: 8px; margin-top: 6px; }
|
||||||
|
.cf-hint { font-size: 12px; color: var(--muted); margin-top: 6px; line-height: 1.5; }
|
||||||
|
.cf-edit { margin-bottom: 10px; }
|
||||||
|
.conflict-file { border: 1px solid var(--border); border-radius: 8px; padding: 10px; margin-bottom: 10px; }
|
||||||
|
.conflict-file pre { margin: 0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user