const $ = (sel) => document.querySelector(sel);
const STATUS = {
ok: { text: '正常', cls: 'st-ok' },
error: { text: '异常', cls: 'st-error' },
conflict: { text: '冲突', cls: 'st-conflict' },
running: { text: '同步中', cls: 'st-running' },
idle: { text: '未启动', cls: 'st-idle' },
};
const RESULT_TEXT = { ok: '成功', error: '失败', conflict: '冲突' };
const STALE_HOURS = 24; // 超过该小时数且仍在运行 → 标记「久未同步」
function parseTs(s) {
if (!s) return null;
const t = new Date(String(s).replace(' ', 'T'));
return isNaN(t.getTime()) ? null : t;
}
function relTime(s) {
const t = parseTs(s);
if (!t) return String(s || '');
const min = Math.floor((Date.now() - t.getTime()) / 60000);
if (min < 1) return '刚刚';
if (min < 60) return min + ' 分钟前';
const hr = Math.floor(min / 60);
if (hr < 24) return hr + ' 小时前';
const day = Math.floor(hr / 24);
if (day < 30) return day + ' 天前';
return String(s).slice(0, 10);
}
function isStale(s) {
const t = parseTs(s);
return !!t && (Date.now() - t.getTime()) > STALE_HOURS * 3600 * 1000;
}
let repos = [];
let current = null;
let creating = false;
let chart = null;
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
async function api(url, opts) {
const res = await fetch(url, opts);
if (res.status === 401) {
showLogin();
throw new Error('需要登录');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
const post = (url) => api(url, { method: 'POST' });
function toast(msg, type) {
const box = $('#toasts');
if (!box) { console.warn('toast 容器缺失:', msg); return; }
const el = document.createElement('div');
el.className = 'toast t-' + (type || 'info');
el.textContent = msg;
el.title = '点击关闭';
el.addEventListener('click', () => el.remove());
box.appendChild(el);
while (box.childElementCount > 5) box.removeChild(box.firstChild); // 最多堆叠 5 条
const life = type === 'error' ? 6000 : 3200;
setTimeout(() => {
el.classList.add('out');
setTimeout(() => el.remove(), 320);
}, life);
}
const Editor = {
cm: null,
get el() { return $('#file-editor'); },
get value() {
if (this.cm) return this.cm.getValue();
return this.el ? this.el.value : '';
},
set value(v) {
const s = v == null ? '' : String(v);
if (this.cm) this.cm.setValue(s);
else if (this.el) this.el.value = s;
},
get disabled() {
if (this.cm) return this.cm.getOption('readOnly') === 'nocursor';
return !!(this.el && this.el.disabled);
},
set disabled(b) {
if (this.cm) this.cm.setOption('readOnly', b ? 'nocursor' : false);
if (this.el) this.el.disabled = !!b;
},
setMode(path) {
if (!this.cm) return;
const ext = String(path || '').split('.').pop().toLowerCase();
const map = {
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
json: 'application/json', ts: 'text/typescript', tsx: 'text/typescript',
html: 'htmlmixed', htm: 'htmlmixed', vue: 'htmlmixed', xml: 'xml', svg: 'xml',
css: 'css', scss: 'css', less: 'css',
go: 'go', py: 'python', sh: 'shell', bash: 'shell', zsh: 'shell',
c: 'clike', h: 'clike', cpp: 'clike', cc: 'clike', hpp: 'clike',
java: 'clike', kt: 'clike', cs: 'clike', scala: 'clike',
yml: 'yaml', yaml: 'yaml', sql: 'sql',
md: 'markdown', markdown: 'markdown',
ini: 'properties', properties: 'properties', env: 'properties',
cfg: 'properties', conf: 'properties', toml: 'properties',
diff: 'diff', patch: 'diff',
};
this.cm.setOption('mode', map[ext] || 'text/plain');
},
setTheme(dark) {
if (this.cm) this.cm.setOption('theme', dark ? 'material-darker' : 'eclipse');
},
refresh() { if (this.cm) setTimeout(() => this.cm.refresh(), 30); },
setChangedLines(lines) {
this._changed = lines && lines.length ? lines : null;
if (!this.cm) return;
const n = this.cm.lineCount();
for (let i = 0; i < n; i++) this.cm.removeLineClass(i, 'background', 'cm-chg');
if (!this._changed) return;
for (const ln of this._changed) {
const idx = ln - 1;
if (idx >= 0 && idx < n) this.cm.addLineClass(idx, 'background', 'cm-chg');
}
},
onSave: null, // 由 cm-boot.js 的快捷键触发
onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存)
};
window.Editor = Editor;
function render() {
const grid = $('#grid');
grid.innerHTML = '';
repos.forEach(r => grid.appendChild(card(r)));
$('#empty').hidden = repos.length > 0;
}
function card(r) {
const st = STATUS[r.status] || STATUS.idle;
const el = document.createElement('div');
el.className = 'card ' + st.cls;
el.dataset.name = r.name;
const last = r.last_sync
? `${relTime(r.last_sync.time)} · ${RESULT_TEXT[r.last_sync.result] || r.last_sync.result}`
: '从未同步';
el.innerHTML = `
${esc(r.name)}
${esc(r.mode)}
${esc(r.repo_path)}
${esc(r.branch)}
领先 ↑${r.ahead}
落后 ↓${r.behind}
${r.dirty ? `改动 ${r.dirty}` : ''}
${r.auto_commit ? '自动提交' : ''}
${r.cron ? `cron` : ''}
${r.conflicts && r.conflicts.length ? `冲突 ${r.conflicts.length}` : ''}
${r.push_conflict ? `推送冲突` : ''}
${r.running && r.auto_sync === false ? `手动模式` : ''}
${r.running && r.last_sync && isStale(r.last_sync.time) ? '久未同步' : ''}
上次同步:${esc(last)}
${r.last_sync && r.last_sync.detail ? `${esc(r.last_sync.detail)}
` : ''}
${r.status === 'error' && r.last_error ? `${esc(r.last_error)}
` : ''}
`;
return el;
}
$('#grid').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-act]');
if (!btn) return;
const name = btn.closest('.card').dataset.name;
const act = btn.dataset.act;
try {
if (act === 'sync') {
await post(`/api/repos/${name}/sync`);
} else if (act === 'toggle') {
const r = repos.find(x => x.name === name);
await post(`/api/repos/${name}/${r.running ? 'stop' : 'start'}`);
} else if (act === 'detail') {
openDetail(name);
}
} catch (err) { toast(err.message, 'error'); }
});
function connect() {
const es = new EventSource('/api/events');
es.onopen = () => setConn(true);
es.onerror = () => setConn(false); // EventSource 会自动重连
es.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.type === 'snapshot') {
repos = msg.data || [];
render();
} else if (msg.type === 'status') {
const i = repos.findIndex(x => x.name === msg.repo);
if (i >= 0) repos[i] = msg.data; else repos.push(msg.data);
render();
updateConflictActions();
if (current === msg.repo && !$('#tab-diff').hidden) loadDiff(msg.repo);
} else if (msg.type === 'log') {
appendLiveLine(msg);
if (current === msg.repo && !$('#tab-log').hidden) appendLogLine(`[${msg.time}] [${msg.level || 'info'}] ${msg.msg}`);
}
};
}
function setConn(ok) {
const el = $('#conn');
el.textContent = ok ? '● 已连接' : '● 未连接';
el.className = 'conn ' + (ok ? 'on' : 'off');
const live = $('#live-conn');
if (live) {
live.textContent = ok ? '● 已连接' : '● 未连接';
live.className = 'conn ' + (ok ? 'on' : 'off');
}
}
const LIVE_MAX = 400;
function appendLiveLine(msg) {
const box = $('#live-log');
if (!box) return;
let level = 'info';
if (msg.level === 'error') level = 'err';
else if (msg.level === 'warn') level = 'warn';
const line = document.createElement('div');
line.className = 'live-line lv-' + level;
line.innerHTML = `${esc(msg.time)}${esc(msg.repo)}${esc(msg.msg)}`;
box.appendChild(line);
while (box.childElementCount > LIVE_MAX) box.removeChild(box.firstChild);
box.scrollTop = box.scrollHeight;
}
function clearLive() {
const box = $('#live-log');
if (box) box.innerHTML = '';
}
let liveSeeded = false;
async function seedLive() {
if (liveSeeded) return;
liveSeeded = true;
const names = repos.map(r => r.name);
await Promise.all(names.map(async (name) => {
try {
const d = await api(`/api/repos/${name}/log?lines=8`);
if (d.lines && d.lines.length) {
d.lines.forEach(t => {
const m = t.match(/^\[(.+?)\]\s*(?:\[(info|warn|error)\]\s*)?(.*)$/i);
appendLiveLine({
time: m ? m[1] : '',
repo: name,
msg: m ? m[3] : t,
level: m && m[2] ? m[2].toLowerCase() : 'info',
seed: true,
});
});
}
} catch (_) { /* 忽略 */ }
}));
}
$('#btn-live-clear').addEventListener('click', clearLive);
function showLogin() {
const el = $('#login');
if (el.hidden) {
el.hidden = false;
setTimeout(() => $('#login-password').focus(), 50);
}
}
async function doLogin() {
const errEl = $('#login-err');
errEl.hidden = true;
try {
await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: $('#login-password').value })
}).then(async res => {
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.error || '登录失败');
}
});
location.reload();
} catch (err) {
errEl.textContent = err.message;
errEl.hidden = false;
}
}
$('#btn-login').addEventListener('click', doLogin);
$('#login-password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
function showTab(t) {
document.querySelectorAll('#m-tabs .tab').forEach(b => b.classList.toggle('active', b.dataset.tab === t));
document.querySelectorAll('.tab-pane').forEach(p => { p.hidden = p.id !== 'tab-' + t; });
if (t === 'log' && current) loadLog(current);
if (t === 'history' && current) loadHistory(current);
if (t === 'diff' && current) loadDiff(current);
if (t === 'file' && current) { loadTree(current); Editor.refresh(); }
if (t === 'conflict' && current) { loadConflicts(current); updateConflictActions(); }
if (t === 'stats' && current) loadStats(current);
}
$('#m-tabs').addEventListener('click', (e) => {
const b = e.target.closest('.tab');
if (b) showTab(b.dataset.tab);
});
function openDetail(name) {
creating = false;
current = name;
const r = repos.find(x => x.name === name);
$('#m-title').textContent = name;
$('#f-name').disabled = true;
$('#btn-delete').hidden = false;
$('#btn-clone').hidden = false;
fillForm(r);
$('#summary-box').hidden = false;
loadSummary(name);
showTab('log');
$('#modal').hidden = false;
}
function openCreate() {
creating = true;
current = null;
$('#m-title').textContent = '新建配置';
$('#f-name').disabled = false;
$('#btn-delete').hidden = true;
$('#btn-clone').hidden = true;
fillForm(null);
$('#summary-box').hidden = true;
showTab('config');
$('#modal').hidden = false;
}
async function loadSummary(name) {
if (!name) return;
try {
const d = await api(`/api/repos/${name}/summary`);
$('#f-summary').value = d.pending || '';
$('#summary-log').textContent = d.log ? d.log.trim() : '(暂无本地变更摘要记录)';
} catch (err) {
$('#summary-log').textContent = '读取摘要失败:' + err.message;
}
}
function closeModal() {
$('#modal').hidden = true;
current = null;
}
$('#m-close').addEventListener('click', closeModal);
$('#modal').addEventListener('click', (e) => { if (e.target === $('#modal')) closeModal(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
let logLines = [];
let logLevel = 'all';
let logQuery = '';
function logLevelOf(line) {
const m = /^\[[^\]]+\]\s*\[(info|warn|error)\]/i.exec(line);
return m ? m[1].toLowerCase() : 'info';
}
function renderLog() {
const box = $('#logbox');
const q = logQuery.trim().toLowerCase();
const out = logLines.filter(l =>
(logLevel === 'all' || logLevelOf(l) === logLevel) &&
(!q || l.toLowerCase().includes(q))
);
if (!logLines.length) box.textContent = '(暂无日志)';
else if (!out.length) box.textContent = '(当前过滤条件下无匹配行)';
else box.textContent = out.join('\n');
box.scrollTop = box.scrollHeight;
}
function loadLog(name) {
$('#logbox').textContent = '加载中…';
api(`/api/repos/${name}/log?lines=500`).then(d => {
logLines = d.lines && d.lines.length ? d.lines : [];
renderLog();
}).catch(err => { logLines = []; $('#logbox').textContent = '日志加载失败:' + err.message; });
}
function appendLogLine(line) {
logLines.push(line);
if (logLines.length > 2000) logLines.shift();
renderLog();
}
$('#log-level').addEventListener('change', (e) => { logLevel = e.target.value; renderLog(); });
$('#log-search').addEventListener('input', (e) => { logQuery = e.target.value; renderLog(); });
$('#btn-log-clear').addEventListener('click', () => {
logLevel = 'all';
logQuery = '';
$('#log-level').value = 'all';
$('#log-search').value = '';
renderLog();
});
function loadHistory(name) {
const list = $('#commit-list');
list.textContent = '加载中…';
api(`/api/repos/${name}/commits?limit=50`).then(commits => {
if (!commits.length) {
list.textContent = '暂无提交记录。';
return;
}
list.innerHTML = commits.map(c => `
${esc(c.hash.slice(0, 8))}
${esc(c.subject)}
${esc(c.author)} · ${esc(c.date)}
`).join('');
}).catch(err => { list.textContent = '提交历史加载失败:' + err.message; });
}
$('#commit-list').addEventListener('click', async (e) => {
const head = e.target.closest('.commit-head');
if (!head || !current) return;
const item = head.closest('.commit');
const pre = item.querySelector('.commit-diff');
if (!pre.hidden) { pre.hidden = true; return; }
pre.hidden = false;
pre.textContent = '加载中…';
try {
const d = await api(`/api/repos/${current}/commits/${item.dataset.hash}`);
pre.textContent = d.diff || '(无差异内容)';
} catch (err) {
pre.textContent = '加载失败:' + err.message;
}
});
function loadConflicts(name) {
api(`/api/repos/${name}/conflicts`).then(files => {
const list = $('#conflict-list');
if (!files.length) {
list.innerHTML = '当前没有冲突。
';
return;
}
list.innerHTML = files.map(f => `
${esc(f.file)}
${esc(f.diff) || '(无差异内容)'}
`).join('');
}).catch(err => { $('#conflict-list').innerHTML = `${esc(err.message)}
`; });
}
$('#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;
}
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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
});
toast(okMsg);
loadConflicts(current);
} catch (err) { toast(err.message, 'error'); }
}
$('#btn-abort').addEventListener('click', () => {
if (confirm('确定放弃本次合并/变基?仓库将回到冲突前状态。')) doAction('abort', '已放弃本次合并');
});
$('#btn-resolved').addEventListener('click', () => doAction('resolve', '冲突处理流程完成'));
$('#btn-fetch').addEventListener('click', () => doAction('fetch', '获取完成'));
$('#btn-pull').addEventListener('click', () => doAction('pull', '拉取完成'));
$('#btn-push').addEventListener('click', () => doAction('push', '推送完成'));
$('#btn-force-push').addEventListener('click', async () => {
if (!current) return;
try {
await post(`/api/repos/${current}/force-push`);
toast('已发起强制推送(--force-with-lease)');
updateConflictActions();
} catch (err) { toast(err.message, 'error'); }
});
$('#btn-abort-push').addEventListener('click', async () => {
if (!current) return;
try {
await post(`/api/repos/${current}/abort-push`);
toast('已放弃强制推送');
updateConflictActions();
} catch (err) { toast(err.message, 'error'); }
});
function updateConflictActions() {
const r = repos.find(x => x.name === current);
const box = $('#push-conflict-actions');
const hint = $('#push-conflict-hint');
if (!box) return;
if (r && r.push_conflict) {
box.hidden = false;
if (hint) hint.textContent = `远程 ${r.push_conflict_remote || ''} 分支 ${r.push_conflict_branch || ''} 有新提交,推送被拒绝`;
} else {
box.hidden = true;
}
}
let diffPath = '';
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 = '';
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}`
).join('');
} else if (d.stat) {
stat.textContent = d.stat.trim();
}
}).catch(err => { box.textContent = '差异加载失败:' + err.message; });
}
function parseUnifiedDiff(text) {
const lines = String(text).split('\n');
const files = [];
let cur = null;
let oldNo = 0, newNo = 0;
let inHunk = false; // 词级模式(--word-diff=plain)下 git 的上下文行不带前导空格,需据此兜底
const push = (header) => { cur = { header, rows: [] }; files.push(cur); return cur; };
for (const line of lines) {
if (line.startsWith('diff --git') || line.startsWith('Index: ')) { push(line); continue; }
if (!cur && line === '') continue; // 开头空行忽略
if (!cur) push('');
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;
}
if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('index ') ||
line.startsWith('new file') || line.startsWith('deleted file') || line.startsWith('similarity ') ||
line.startsWith('rename ') || line.startsWith('old mode') || line.startsWith('new mode')) {
cur.rows.push({ type: 'meta', text: line });
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: '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 的上下文行不带前导空格,
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;
}
function fillWord(el, text) {
const re = /\[-([\s\S]*?)-\]|\{\+([\s\S]*?)\+\}/g;
let last = 0, m;
el.textContent = '';
while ((m = re.exec(text)) !== null) {
if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index)));
const span = document.createElement('span');
const isDel = m[1] !== undefined;
span.className = isDel ? 'wd-del' : 'wd-add';
span.textContent = isDel ? m[1] : m[2];
el.appendChild(span);
last = re.lastIndex;
}
if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
if (!el.childNodes.length) el.appendChild(document.createTextNode(text));
}
function buildSplitRows(rows) {
const out = [];
let i = 0;
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: { 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++]);
const n = Math.max(dels.length, adds.length);
for (let k = 0; k < n; k++) {
const l = dels[k] || null, a = adds[k] || null;
out.push({ kind: l && a ? 'chg' : (l ? 'del' : 'add'), left: l, right: a });
}
}
return out;
}
function fileHeaderOf(h) {
const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(h);
return m ? m[2] : h;
}
function sideCell(cls, r, word) {
const c = document.createElement('div');
c.className = 'scol ' + cls;
const ln = document.createElement('span');
ln.className = 'ln';
ln.textContent = r ? (r.no == null ? '' : r.no) : '';
const t = document.createElement('span');
t.className = 'stxt';
if (r) { if (word) fillWord(t, r.text); else t.textContent = r.text; }
c.appendChild(ln);
c.appendChild(t);
return c;
}
function buildSplit(f, word) {
const frag = document.createDocumentFragment();
buildSplitRows(f.rows).forEach(r => {
if (r.kind === 'hunk' || r.kind === 'meta') {
const d = document.createElement('div');
d.className = 'diff-line diff-hunk';
d.textContent = r.text === '' ? ' ' : r.text;
frag.appendChild(d);
return;
}
const row = document.createElement('div');
row.className = 'srow s-' + r.kind;
row.appendChild(sideCell('sl', r.left, word));
row.appendChild(sideCell('sr', r.right, word));
frag.appendChild(row);
});
return frag;
}
function buildUnified(f, word) {
const frag = document.createDocumentFragment();
f.rows.forEach(r => {
const d = document.createElement('div');
d.className = 'diff-line';
if (r.type === 'add') d.classList.add('diff-add');
else if (r.type === 'del') d.classList.add('diff-del');
else if (r.type === 'hunk') d.classList.add('diff-hunk');
const prefix = r.type === 'add' ? '+' : r.type === 'del' ? '-' : r.type === 'ctx' ? ' ' : '';
const txt = prefix + r.text;
if (word) fillWord(d, txt); else d.textContent = txt === '' ? ' ' : txt;
frag.appendChild(d);
});
return frag;
}
function renderDiff(box, text) {
box.innerHTML = '';
const viewEl = $('#f-diff-view');
const wordEl = $('#f-diff-word');
const split = !!viewEl && viewEl.value === 'split';
const word = !!wordEl && wordEl.checked;
const files = parseUnifiedDiff(text);
const frag = document.createDocumentFragment();
if (!files.length) {
const d = document.createElement('div');
d.className = 'diff-line';
d.textContent = String(text) || '(无差异)';
frag.appendChild(d);
box.appendChild(frag);
return;
}
files.forEach(f => {
if (f.header) {
const h = document.createElement('div');
h.className = 'diff-file-head';
h.textContent = fileHeaderOf(f.header);
h.title = f.header;
frag.appendChild(h);
}
frag.appendChild(split ? buildSplit(f, word) : buildUnified(f, word));
});
box.appendChild(frag);
}
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 => {
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; });
}
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));
Editor.value = draftServer;
showDraftNote(false);
});
}
function scheduleDraft() {
if (!current || !fileCurrent) return;
if (draftTimer) clearTimeout(draftTimer);
draftTimer = setTimeout(() => {
if (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) {
Editor._suspend = true;
try {
Editor.value = '加载中…';
Editor.disabled = false;
Editor.setMode(path);
$('#file-current').textContent = path;
fileCurrent = path;
showDraftNote(false);
const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`);
if (d.binary) {
Editor.setChangedLines(null);
const bh = $('#file-chg-hint'); if (bh) bh.hidden = true;
Editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
Editor.disabled = true;
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.setChangedLines(d.changed);
const hint = $('#file-chg-hint');
if (hint) hint.hidden = !(d.changed && d.changed.length);
Editor.disabled = false;
} catch (err) {
Editor.setChangedLines(null);
const eh = $('#file-chg-hint'); if (eh) eh.hidden = true;
Editor.value = '读取失败:' + err.message;
Editor.disabled = true;
} finally {
Editor._suspend = false;
}
}
async function saveFile(commit) {
const name = current;
const path = fileCurrent;
if (!name || !path) { toast('请先在左侧选择一个文件'); return; }
if (Editor.disabled) { toast('当前文件不可编辑(二进制或读取失败)'); return; }
const content = Editor.value;
try {
await api(`/api/repos/${name}/file`, {
method: 'PUT',
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, message: $('#file-msg').value })
});
toast('已保存并提交');
} else {
$('#f-diff-scope').value = 'working';
showTab('diff');
toast('已保存到磁盘(未提交)。可在「差异」tab 查看改动,或稍后由自动同步提交。');
}
} catch (err) { toast('保存失败:' + err.message); }
}
$('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
$('#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-copy').addEventListener('click', async () => {
if (!diffText) { toast('当前没有可复制的差异内容'); return; }
try {
await navigator.clipboard.writeText(diffText);
toast('差异内容已复制到剪贴板');
} catch (err) {
toast('复制失败(浏览器可能限制了剪贴板权限):' + err.message);
}
});
$('#btn-diff-download').addEventListener('click', () => {
if (!diffText) { toast('当前没有可下载的差异内容'); 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); });
$('#btn-file-diff').addEventListener('click', () => {
if (!current || !fileCurrent) { toast('请先在左侧选择一个文件'); 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;
loadFile(current, item.dataset.path);
});
$('#btn-file-save').addEventListener('click', () => saveFile(false));
$('#btn-file-commit').addEventListener('click', () => saveFile(true));
$('#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);
}
});
Editor.onChange = scheduleDraft;
Editor.onSave = () => saveFile(false);
function dateStr(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function loadStats(name) {
api(`/api/repos/${name}/stats`).then(rows => {
rows = rows.filter(r => r.action !== 'sync');
const days = [];
for (let i = 29; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
days.push(dateStr(d));
}
const normResult = r => r.result === 'success' ? 'ok' : r.result === 'failed' ? 'error' : r.result;
const mk = (key) => days.map(d => rows.filter(r => normResult(r) === key && r.time.slice(0, 10) === d).length);
const total = { ok: 0, error: 0, conflict: 0 };
rows.forEach(r => { const k = normResult(r); if (total[k] !== undefined) total[k]++; });
$('#stat-summary').innerHTML =
`近 30 天共 ${rows.length} 次同步记录` +
`成功 ${total.ok}` +
`失败 ${total.error}` +
`冲突 ${total.conflict}`;
if (chart) { chart.destroy(); chart = null; }
if (!window.Chart) return;
chart = new Chart($('#stat-chart'), {
type: 'bar',
data: {
labels: days.map(d => d.slice(5)),
datasets: [
{ label: '成功', data: mk('ok'), backgroundColor: '#97c459' },
{ label: '失败', data: mk('error'), backgroundColor: '#f09595' },
{ label: '冲突', data: mk('conflict'), backgroundColor: '#ef9f27' },
]
},
options: {
responsive: true,
scales: {
x: { stacked: true },
y: { stacked: true, ticks: { precision: 0 } }
},
plugins: { legend: { position: 'bottom' } }
}
});
}).catch(err => { $('#stat-summary').textContent = '统计加载失败:' + err.message; });
}
function fillForm(r) {
$('#f-name').value = r ? r.name : '';
$('#f-path').value = r ? r.repo_path : '';
$('#f-branch').value = r ? r.branch : 'main';
$('#f-mode').value = r ? r.mode : 'both';
$('#f-remotes').value = r && r.remotes ? r.remotes.map(x => x.name + '=' + x.url).join('\n') : '';
$('#f-interval').value = r ? r.interval : 60;
$('#f-auth').value = r ? r.auth_method : 'ssh';
$('#f-conflict').value = r ? r.conflict_strategy : 'merge';
$('#f-push').value = r ? r.push_strategy : 'manual';
$('#f-retry').value = r ? r.retry_count : 3;
$('#f-retry-interval').value = r ? r.retry_interval : 5;
$('#f-autocommit').checked = r ? !!r.auto_commit : false;
$('#f-autosync').checked = r ? (r.auto_sync !== false) : true;
$('#f-cron').value = r ? (r.cron || '') : '';
$('#f-log').value = r ? (r.log_file || '') : '';
previewCron();
}
function readForm() {
const remotes = [];
$('#f-remotes').value.split('\n').forEach(line => {
line = line.trim();
if (!line) return;
const i = line.indexOf('=');
if (i > 0) remotes.push({ name: line.slice(0, i).trim(), url: line.slice(i + 1).trim() });
});
return {
name: $('#f-name').value.trim(),
repo_path: $('#f-path').value.trim(),
branch: $('#f-branch').value.trim() || 'main',
remotes,
mode: $('#f-mode').value,
interval: parseInt($('#f-interval').value, 10) || 60,
auth_method: $('#f-auth').value,
retry_count: parseInt($('#f-retry').value, 10) || 0,
retry_interval: parseInt($('#f-retry-interval').value, 10) || 0,
conflict_strategy: $('#f-conflict').value,
push_strategy: $('#f-push').value,
auto_commit: $('#f-autocommit').checked,
auto_sync: $('#f-autosync').checked,
cron: $('#f-cron').value.trim(),
log_file: $('#f-log').value.trim(),
};
}
$('#cfg-form').addEventListener('submit', async (e) => {
e.preventDefault();
const cfg = readForm();
try {
if (creating) {
await api('/api/repos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg)
});
} else {
await api(`/api/repos/${cfg.name}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg)
});
}
closeModal();
refresh();
} catch (err) { toast('保存失败:' + err.message); }
});
$('#btn-delete').addEventListener('click', async () => {
if (!current) return;
if (!confirm(`确定删除配置 "${current}" ?(不影响日志和统计文件)`)) return;
try {
await api(`/api/repos/${current}`, { method: 'DELETE' });
closeModal();
refresh();
} catch (err) { toast(err.message, 'error'); }
});
$('#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-summary-save').addEventListener('click', async () => {
if (!current) return;
try {
const res = await api(`/api/repos/${current}/summary`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ summary: $('#f-summary').value })
});
toast(res.ok ? '摘要已保存(本地,下次自动提交将使用此信息)' : '摘要保存失败', res.ok ? 'info' : 'error');
await loadSummary(current);
} catch (err) { toast('摘要保存失败:' + err.message, 'error'); }
});
$('#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');
}
$('#btn-theme').addEventListener('click', () => {
const dark = !document.documentElement.classList.contains('dark');
localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light');
applyTheme(dark);
});
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 {
const d = await api('/api/status');
repos = d.repos || [];
render();
seedLive();
} catch (e) { /* 忽略,SSE 会重试 */ }
}
initTheme();
refresh();
connect();
setInterval(refresh, 20000);