Files

1208 lines
43 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = `
<div class="card-head">
<span class="dot"></span>
<h3>${esc(r.name)}</h3>
<span class="mode">${esc(r.mode)}</span>
</div>
<div class="path" title="${esc(r.repo_path)}">${esc(r.repo_path)}</div>
<div class="badges">
<span class="badge">${esc(r.branch)}</span>
<span class="badge ${r.ahead ? 'b-up' : ''}">领先 ↑${r.ahead}</span>
<span class="badge ${r.behind ? 'b-down' : ''}">落后 ↓${r.behind}</span>
${r.dirty ? `<span class="badge b-dirty">改动 ${r.dirty}</span>` : ''}
${r.auto_commit ? '<span class="badge">自动提交</span>' : ''}
${r.cron ? `<span class="badge" title="${esc(r.cron)}">cron</span>` : ''}
${r.conflicts && r.conflicts.length ? `<span class="badge b-conflict">冲突 ${r.conflicts.length}</span>` : ''}
${r.push_conflict ? `<span class="badge b-conflict">推送冲突</span>` : ''}
${r.running && r.auto_sync === false ? `<span class="badge b-paused">手动模式</span>` : ''}
${r.running && r.last_sync && isStale(r.last_sync.time) ? '<span class="badge b-stale">久未同步</span>' : ''}
</div>
<div class="last" title="${r.last_sync ? esc(r.last_sync.time) : ''}">上次同步:${esc(last)}</div>
${r.last_sync && r.last_sync.detail ? `<div class="last-detail" title="${esc(r.last_sync.detail)}">${esc(r.last_sync.detail)}</div>` : ''}
${r.status === 'error' && r.last_error ? `<div class="err" title="${esc(r.last_error)}">${esc(r.last_error)}</div>` : ''}
<div class="card-btns">
<button class="btn" data-act="sync" ${r.running ? '' : 'disabled'}>立即同步</button>
<button class="btn" data-act="toggle">${r.running ? '暂停' : '启动'}</button>
<button class="btn" data-act="detail">详情</button>
</div>`;
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 = `<span class="ll-time">${esc(msg.time)}</span><span class="ll-repo">${esc(msg.repo)}</span><span class="ll-msg">${esc(msg.msg)}</span>`;
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 => `
<div class="commit" data-hash="${esc(c.hash)}">
<div class="commit-head">
<span class="commit-hash">${esc(c.hash.slice(0, 8))}</span>
<span class="commit-subject" title="${esc(c.subject)}">${esc(c.subject)}</span>
<span class="commit-meta">${esc(c.author)} · ${esc(c.date)}</span>
</div>
<pre class="commit-diff" hidden></pre>
</div>`).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 = '<p style="color:var(--muted)">当前没有冲突。</p>';
return;
}
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;
}
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)} <a href="#" id="diff-filter-clear" style="color:inherit">✕ 显示全部</a>`;
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 =>
`<span class="df ${f.add ? 'df-add' : ''} ${f.del ? 'df-del' : ''}">${esc(f.path)} <b style="color:var(--green)">+${f.add}</b> <b style="color:var(--red)">-${f.del}</b></span>`
).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
? `<span class="fi-status s-${esc(cls)}" title="${esc(STATUS_LABEL[st] || st)}">${esc(st)}</span>`
: '';
return `<div class="file-item" data-path="${esc(f.path)}" title="${esc(f.path)}"><span class="fi-name">${esc(f.path)}</span>${badge}</div>`;
}).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 = '已恢复未保存的草稿 <a href="#" id="draft-discard" style="color:inherit">放弃草稿</a>';
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 commitCurrentFile() {
const name = current;
const path = fileCurrent;
if (!name || !path) throw new Error('请先在左侧选择一个文件');
await api(`/api/repos/${name}/commit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, message: $('#file-msg').value })
});
}
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 commitCurrentFile();
toast('已保存并提交:' + path);
} 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));
$('#btn-file-commit-only').addEventListener('click', async () => {
if (!current || !fileCurrent) { toast('请先在左侧选择一个文件'); return; }
try {
await commitCurrentFile();
toast('已提交:' + fileCurrent);
if (Editor.value === draftServer) await loadFile(current, fileCurrent);
refresh();
} catch (err) { toast('提交失败:' + err.message, 'error'); }
});
$('#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 =
`<span>近 30 天共 <b>${rows.length}</b> 次同步记录</span>` +
`<span style="color:var(--green)">成功 <b>${total.ok}</b></span>` +
`<span style="color:var(--red)">失败 <b>${total.error}</b></span>` +
`<span style="color:var(--amber)">冲突 <b>${total.conflict}</b></span>`;
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);