Files
github-auto-sync-golang/static/app.js
T

1193 lines
42 KiB
JavaScript
Raw 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;
// 后端时间格式 "2006-01-02 15:04:05",补齐为 ISO 本地时间
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' });
// ---------- Toast 轻提示(替代原生 alert,避免打断操作) ----------
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);
}
// ---------- 编辑器抽象:统一原生 textarea 与 CodeMirror 的读写 ----------
// CodeMirror 由 cm-boot.js 在脚本加载后接管并写入 Editor.cm;
// 库缺失 / 初始化失败时 Editor.cm 保持 null,自动回退原生 textarea,功能不受影响。
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');
},
// 弹窗 / tab 切换后需要重算尺寸,否则 CodeMirror 可能不显示内容
refresh() { if (this.cm) setTimeout(() => this.cm.refresh(), 30); },
onSave: null, // 由 cm-boot.js 的快捷键触发
onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存)
};
// 顶层 const 不会挂到 window,显式暴露给 cm-boot.js 使用
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.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'); }
});
// ---------- SSE 实时事件 ----------
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);
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);
showTab('config');
$('#modal').hidden = false;
}
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 = '';
// 落盘格式:[时间] [级别] 消息;历史日志无级别前缀时按 info 处理
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;
}
// 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`, {
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', '冲突处理流程完成'));
// 手动拉取 / 推送 / 获取(B1:后端 ManualGit 已支持)
$('#btn-fetch').addEventListener('click', () => doAction('fetch', '获取完成'));
$('#btn-pull').addEventListener('click', () => doAction('pull', '拉取完成'));
$('#btn-push').addEventListener('click', () => doAction('push', '推送完成'));
// 推送冲突(non-fast-forward)的解决入口(B3)
$('#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'); }
});
// 根据当前仓库快照切换推送冲突操作区显隐(B3)
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;
}
}
// ---------- 差异显示(网页端) ----------
// diffPath 非空时只显示单个文件的差异(编辑器「对比此文件」进入)
let diffPath = '';
// diffText 保存当前差异原文,供「复制 / 下载 .patch」使用
let diffText = '';
function updateDiffFilterNote() {
const el = $('#diff-filter');
if (!el) return;
if (!diffPath) { el.hidden = true; el.textContent = ''; return; }
el.hidden = false;
el.innerHTML = `仅显示:${esc(diffPath)} <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; });
}
// ---------- 差异渲染:统一 / 并排 / 词级 ----------
// 把 unified diff 解析成「文件 → 行记录」,统一与并排两种视图共用同一份结构
function parseUnifiedDiff(text) {
const lines = String(text).split('\n');
const files = [];
let cur = null;
let oldNo = 0, newNo = 0;
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 的上下文行不带前导空格,
// 因此 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;
}
// 词级:把 [-删除-] / {+新增+} 渲染成高亮片段(textContent 拼接,天然防 XSS)
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));
}
// 并排:把连续的 del/add 段配对成行,多余的一侧留空
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; });
}
// ---------- 草稿缓存(localStorage,防刷新丢稿) ----------
const draftKey = (name, path) => `gitautosync:draft:${name}:${path}`;
let draftServer = ''; // 当前文件在磁盘上的原文,用于「放弃草稿」回滚
let draftTimer = null;
function showDraftNote(on) {
const el = $('#file-draft');
if (!el) return;
if (!on) { el.hidden = true; el.innerHTML = ''; return; }
el.hidden = false;
el.innerHTML = '已恢复未保存的草稿 <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));
$('#file-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) {
const editor = $('#file-editor');
editor.value = '加载中…';
editor.disabled = false;
$('#file-current').textContent = path;
fileCurrent = path;
showDraftNote(false);
try {
const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`);
if (d.binary) {
editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
editor.disabled = true;
draftServer = '';
return;
}
draftServer = d.content || '';
const saved = localStorage.getItem(draftKey(name, path));
if (saved !== null && saved !== draftServer) {
editor.value = saved;
showDraftNote(true);
} else {
editor.value = draftServer;
}
editor.disabled = false;
} catch (err) {
editor.value = '读取失败:' + err.message;
editor.disabled = true;
}
}
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); });
// 并排/统一纯前端重渲染(同一份 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
$('#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); });
// 编辑器「对比此文件」→ 跳到差异 tab 并只看该文件
$('#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));
// 编辑器:输入时缓存草稿,Ctrl/Cmd + S 直接保存
$('#file-editor').addEventListener('input', scheduleDraft);
$('#file-editor').addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
saveFile(false);
}
});
// ---------- 统计 ----------
function dateStr(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function loadStats(name) {
api(`/api/repos/${name}/stats`).then(rows => {
const days = [];
for (let i = 29; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
days.push(dateStr(d));
}
// 后端写入的 result 词汇是 success/failed/conflict,这里归一化到 ok/error/conflict 再统计
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-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,
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'); }
});
// ---------- 配置「克隆」按钮(多仓库同构时省得重填) ----------
// 复用 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');
}
$('#btn-theme').addEventListener('click', () => {
const dark = !document.documentElement.classList.contains('dark');
localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light');
applyTheme(dark);
});
// ---------- Cron 下次执行预览 ----------
let cronTimer = null;
async function previewCron() {
const el = $('#cron-next');
if (!el) return;
const expr = $('#f-cron').value.trim();
if (!expr) { el.textContent = ''; el.className = 'cron-next'; return; }
try {
const d = await api(`/api/cron-next?expr=${encodeURIComponent(expr)}`);
el.textContent = '下次执行:' + d.next;
el.className = 'cron-next good';
} catch (err) {
el.textContent = '表达式无效:' + err.message;
el.className = 'cron-next bad';
}
}
$('#f-cron').addEventListener('input', () => {
if (cronTimer) clearTimeout(cronTimer);
cronTimer = setTimeout(previewCron, 400);
});
// ---------- 初始化 ----------
async function refresh() {
try {
const d = await api('/api/status');
repos = d.repos || [];
render();
seedLive();
} catch (e) { /* 忽略,SSE 会重试 */ }
}
initTheme();
refresh();
connect();
setInterval(refresh, 20000);