git-sync-web 自动提交 2026-09-09 15:06:45

This commit is contained in:
tamakyi
2026-09-09 15:06:45 +08:00
parent a1ccd4fe6a
commit 86c11864f0
10 changed files with 204 additions and 7 deletions
+146 -7
View File
@@ -126,6 +126,8 @@ const Editor = {
onSave: null, // 由 cm-boot.js 的快捷键触发
onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存)
};
// 顶层 const 不会挂到 window,显式暴露给 cm-boot.js 使用
window.Editor = Editor;
// ---------- 渲染 ----------
function render() {
@@ -329,6 +331,7 @@ function openDetail(name) {
$('#m-title').textContent = name;
$('#f-name').disabled = true;
$('#btn-delete').hidden = false;
$('#btn-clone').hidden = false;
fillForm(r);
showTab('log');
$('#modal').hidden = false;
@@ -340,6 +343,7 @@ function openCreate() {
$('#m-title').textContent = '新建配置';
$('#f-name').disabled = false;
$('#btn-delete').hidden = true;
$('#btn-clone').hidden = true;
fillForm(null);
showTab('config');
$('#modal').hidden = false;
@@ -445,12 +449,86 @@ function loadConflicts(name) {
list.innerHTML = '<p style="color:var(--muted)">当前没有冲突。</p>';
return;
}
list.innerHTML = files.map(f =>
`<div class="conflict-file"><h4>${esc(f.file)}</h4><pre>${esc(f.diff) || '(无差异内容)'}</pre></div>`
).join('');
list.innerHTML = files.map(f => `
<div class="conflict-file" data-path="${esc(f.file)}">
<div class="cf-head">
<h4 title="${esc(f.file)}">${esc(f.file)}</h4>
<button class="btn btn-sm" data-cf="edit">编辑解决</button>
<button class="btn btn-sm" data-cf="close" hidden>收起</button>
</div>
<div class="cf-edit" hidden>
<textarea class="cf-ta" spellcheck="false" placeholder="加载中…"></textarea>
<div class="cf-actions">
<input class="file-msg cf-msg" placeholder="提交说明(可选)">
<button class="btn btn-sm" data-cf="save">仅保存</button>
<button class="btn btn-sm btn-primary" data-cf="resolve">保存并标记已解决</button>
</div>
<div class="cf-hint">「仅保存」只写回磁盘;「保存并标记已解决」会暂存该文件,并在已无其它冲突文件时结束合并/变基。</div>
</div>
<pre>${esc(f.diff) || '(无差异内容)'}</pre>
</div>`).join('');
}).catch(err => { $('#conflict-list').innerHTML = `<p style="color:var(--red)">${esc(err.message)}</p>`; });
}
// 冲突文件内联编辑:展开编辑区 → 读取原文 → 保存 / 保存并标记已解决
$('#conflict-list').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-cf]');
if (!btn || !current) return;
const item = btn.closest('.conflict-file');
const path = item.dataset.path;
const act = btn.dataset.cf;
const wrap = item.querySelector('.cf-edit');
const ta = item.querySelector('.cf-ta');
const editBtn = item.querySelector('[data-cf="edit"]');
const closeBtn = item.querySelector('[data-cf="close"]');
if (act === 'edit') {
wrap.hidden = false;
editBtn.hidden = true;
closeBtn.hidden = false;
ta.disabled = false;
ta.value = '加载中…';
try {
const d = await api(`/api/repos/${current}/file?path=${encodeURIComponent(path)}`);
if (d.binary) {
ta.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
ta.disabled = true;
} else {
ta.value = d.content || '';
}
} catch (err) {
ta.value = '读取失败:' + err.message;
ta.disabled = true;
}
return;
}
if (act === 'close') {
wrap.hidden = true;
editBtn.hidden = false;
closeBtn.hidden = true;
return;
}
// save / resolve
if (ta.disabled) { toast('该文件当前不可编辑', 'error'); return; }
try {
await api(`/api/repos/${current}/resolve-file`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path,
content: ta.value,
finish: act === 'resolve',
})
});
toast(act === 'resolve' ? '已保存并结束合并/变基' : '已保存到磁盘(尚未标记解决)', 'success');
loadConflicts(current);
updateConflictActions();
refresh();
} catch (err) { toast(err.message, 'error'); }
});
async function doAction(action, okMsg) {
try {
await api(`/api/repos/${current}/action`, {
@@ -562,6 +640,7 @@ function parseUnifiedDiff(text) {
if (line.startsWith('@@')) {
const m = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
if (m) { oldNo = parseInt(m[1], 10); newNo = parseInt(m[2], 10); }
inHunk = true;
cur.rows.push({ type: 'hunk', text: line });
continue;
}
@@ -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: '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 === '') { cur.rows.push({ type: 'ctx', text: '', no: oldNo++ }); newNo++; continue; }
cur.rows.push({ type: 'meta', text: line }); // \ No newline 等
if (line.startsWith(' ')) { cur.rows.push({ type: 'ctx', text: line.slice(1), no: oldNo++, nNo: newNo++ }); continue; }
if (line === '') { cur.rows.push({ type: 'ctx', text: '', no: oldNo++, nNo: newNo++ }); continue; }
// 词级模式(--word-diff=plain)下 git 的上下文行不带前导空格,
// 因此 hunk 内未识别的行一律按上下文处理;hunk 外的(如 "\ No newline")按元信息处理。
if (inHunk && !line.startsWith('\\')) {
cur.rows.push({ type: 'ctx', text: line, no: oldNo++, nNo: newNo++ });
continue;
}
cur.rows.push({ type: 'meta', text: line });
}
return files;
}
@@ -605,7 +690,12 @@ function buildSplitRows(rows) {
while (i < rows.length) {
const r = rows[i];
if (r.type === 'hunk' || r.type === 'meta') { out.push({ kind: r.type, text: r.text }); i++; continue; }
if (r.type === 'ctx') { out.push({ kind: 'ctx', left: r, right: 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 = [];
while (i < rows.length && rows[i].type === 'del') dels.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); });
// 并排/统一纯前端重渲染(同一份 diff 原文);词级需后端重新生成,故重新拉取
$('#f-diff-view').addEventListener('change', () => { renderDiff($('#diffbox'), diffText || '(无差异)'); });
$('#f-diff-word').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
$('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(current); });
// 差异:复制 / 下载 .patch
@@ -993,19 +1086,65 @@ $('#btn-delete').addEventListener('click', async () => {
} 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-empty-new').addEventListener('click', openCreate);
$('#btn-sync-all').addEventListener('click', () => {
repos.filter(r => r.running).forEach(r => post(`/api/repos/${r.name}/sync`).catch(() => {}));
toast(`已向 ${repos.filter(r => r.running).length} 个运行中的仓库发起同步`);
});
// 批量启停:并发调用,汇总成功/失败
async function batchToggle(act) {
const isStart = act === 'start';
const targets = repos.filter(r => isStart ? !r.running : r.running);
if (!targets.length) {
toast(isStart ? '没有处于暂停状态的仓库' : '没有正在运行的仓库');
return;
}
let ok = 0, fail = 0;
await Promise.all(targets.map(async (r) => {
try { await post(`/api/repos/${r.name}/${act}`); ok++; }
catch (_) { fail++; }
}));
toast(`${isStart ? '启动' : '暂停'}完成:成功 ${ok}${fail ? `,失败 ${fail}` : ''}`, fail ? 'warn' : 'success');
refresh();
}
$('#btn-start-all').addEventListener('click', () => batchToggle('start'));
$('#btn-stop-all').addEventListener('click', () => batchToggle('stop'));
// ---------- 暗色模式 ----------
const THEME_KEY = 'gitautosync:theme';
function applyTheme(dark) {
document.documentElement.classList.toggle('dark', !!dark);
const btn = $('#btn-theme');
if (btn) btn.textContent = dark ? '☀️' : '🌙';
Editor.setTheme(!!dark); // CodeMirror 主题跟随(未启用 CM 时为空操作)
}
function initTheme() {
applyTheme(localStorage.getItem(THEME_KEY) === 'dark');
+29
View File
@@ -0,0 +1,29 @@
// CodeMirror 引导:在 codemirror.min.js 及其 mode 加载完成后接管 #file-editor。
// 若库未加载或初始化失败,window.Editor.cm 保持 null,自动回退原生 textarea,功能不受影响。
(function () {
if (typeof window.CodeMirror === 'undefined' || typeof window.Editor === 'undefined') return;
var ta = document.getElementById('file-editor');
if (!ta) return;
try {
var dark = document.documentElement.classList.contains('dark');
var cm = window.CodeMirror.fromTextArea(ta, {
lineNumbers: true,
lineWrapping: true,
indentUnit: 2,
tabSize: 2,
theme: dark ? 'material-darker' : 'eclipse',
readOnly: ta.disabled ? 'nocursor' : false,
extraKeys: {
'Ctrl-S': function () { if (window.Editor.onSave) window.Editor.onSave(); },
'Cmd-S': function () { if (window.Editor.onSave) window.Editor.onSave(); }
}
});
window.Editor.cm = cm;
// CodeMirror 的 change 事件桥接到草稿缓存
cm.on('change', function () { if (window.Editor.onChange) window.Editor.onChange(); });
// 模式跟随打开文件时的 setMode
var cur = window.Editor.value;
} catch (e) {
if (window.console) console.warn('CodeMirror 初始化失败,已回退原生编辑器:', e);
}
})();
+4
View File
@@ -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; }
html.dark .cf-ta { background: #12151a; }
.cf-actions { display: flex; gap: 8px; margin-top: 6px; }
.cf-hint { font-size: 12px; color: var(--muted); margin-top: 6px; line-height: 1.5; }
.cf-edit { margin-bottom: 10px; }
.conflict-file { border: 1px solid var(--border); border-radius: 8px; padding: 10px; margin-bottom: 10px; }
.conflict-file pre { margin: 0; }