git-sync-web 自动提交 2026-09-09 14:55:18
This commit is contained in:
+241
-34
@@ -56,6 +56,77 @@ async function api(url, opts) {
|
||||
}
|
||||
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 事件触发(草稿缓存)
|
||||
};
|
||||
|
||||
// ---------- 渲染 ----------
|
||||
function render() {
|
||||
const grid = $('#grid');
|
||||
@@ -115,7 +186,7 @@ $('#grid').addEventListener('click', async (e) => {
|
||||
} else if (act === 'detail') {
|
||||
openDetail(name);
|
||||
}
|
||||
} catch (err) { alert(err.message); }
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
});
|
||||
|
||||
// ---------- SSE 实时事件 ----------
|
||||
@@ -241,7 +312,7 @@ function showTab(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);
|
||||
if (t === 'file' && current) { loadTree(current); Editor.refresh(); }
|
||||
if (t === 'conflict' && current) { loadConflicts(current); updateConflictActions(); }
|
||||
if (t === 'stats' && current) loadStats(current);
|
||||
}
|
||||
@@ -387,9 +458,9 @@ async function doAction(action, okMsg) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action })
|
||||
});
|
||||
alert(okMsg);
|
||||
toast(okMsg);
|
||||
loadConflicts(current);
|
||||
} catch (err) { alert(err.message); }
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
}
|
||||
$('#btn-abort').addEventListener('click', () => {
|
||||
if (confirm('确定放弃本次合并/变基?仓库将回到冲突前状态。')) doAction('abort', '已放弃本次合并');
|
||||
@@ -406,17 +477,17 @@ $('#btn-force-push').addEventListener('click', async () => {
|
||||
if (!current) return;
|
||||
try {
|
||||
await post(`/api/repos/${current}/force-push`);
|
||||
alert('已发起强制推送(--force-with-lease)');
|
||||
toast('已发起强制推送(--force-with-lease)');
|
||||
updateConflictActions();
|
||||
} catch (err) { alert(err.message); }
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
});
|
||||
$('#btn-abort-push').addEventListener('click', async () => {
|
||||
if (!current) return;
|
||||
try {
|
||||
await post(`/api/repos/${current}/abort-push`);
|
||||
alert('已放弃强制推送');
|
||||
toast('已放弃强制推送');
|
||||
updateConflictActions();
|
||||
} catch (err) { alert(err.message); }
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
});
|
||||
|
||||
// 根据当前仓库快照切换推送冲突操作区显隐(B3)
|
||||
@@ -476,18 +547,156 @@ function loadDiff(name) {
|
||||
}).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); }
|
||||
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++ }); newNo++; continue; }
|
||||
if (line === '') { cur.rows.push({ type: 'ctx', text: '', no: oldNo++ }); newNo++; continue; }
|
||||
cur.rows.push({ type: 'meta', text: line }); // \ No newline 等
|
||||
}
|
||||
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: r }); 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 lines = String(text).split('\n');
|
||||
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();
|
||||
lines.forEach(line => {
|
||||
const pre = document.createElement('div');
|
||||
pre.className = 'diff-line';
|
||||
if (line.startsWith('+')) pre.classList.add('diff-add');
|
||||
else if (line.startsWith('-')) pre.classList.add('diff-del');
|
||||
else if (line.startsWith('@@')) pre.classList.add('diff-hunk');
|
||||
pre.textContent = line === '' ? ' ' : line;
|
||||
frag.appendChild(pre);
|
||||
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);
|
||||
}
|
||||
@@ -537,9 +746,8 @@ function scheduleDraft() {
|
||||
if (!current || !fileCurrent) return;
|
||||
if (draftTimer) clearTimeout(draftTimer);
|
||||
draftTimer = setTimeout(() => {
|
||||
const editor = $('#file-editor');
|
||||
if (!editor || editor.disabled) return;
|
||||
const v = editor.value;
|
||||
if (Editor.disabled) return;
|
||||
const v = Editor.value;
|
||||
if (v === draftServer) localStorage.removeItem(draftKey(current, fileCurrent));
|
||||
else localStorage.setItem(draftKey(current, fileCurrent), v);
|
||||
}, 600);
|
||||
@@ -578,10 +786,9 @@ async function loadFile(name, path) {
|
||||
async function saveFile(commit) {
|
||||
const name = current;
|
||||
const path = fileCurrent;
|
||||
if (!name || !path) { alert('请先在左侧选择一个文件'); return; }
|
||||
const editor = $('#file-editor');
|
||||
if (editor.disabled) { alert('当前文件不可编辑(二进制或读取失败)'); return; }
|
||||
const content = editor.value;
|
||||
if (!name || !path) { toast('请先在左侧选择一个文件'); return; }
|
||||
if (Editor.disabled) { toast('当前文件不可编辑(二进制或读取失败)'); return; }
|
||||
const content = Editor.value;
|
||||
try {
|
||||
// 先写盘
|
||||
await api(`/api/repos/${name}/file`, {
|
||||
@@ -599,14 +806,14 @@ async function saveFile(commit) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, message: $('#file-msg').value })
|
||||
});
|
||||
alert('已保存并提交');
|
||||
toast('已保存并提交');
|
||||
} else {
|
||||
// 保存后自动切到差异预览
|
||||
$('#f-diff-scope').value = 'working';
|
||||
showTab('diff');
|
||||
alert('已保存到磁盘(未提交)。可在「差异」tab 查看改动,或稍后由自动同步提交。');
|
||||
toast('已保存到磁盘(未提交)。可在「差异」tab 查看改动,或稍后由自动同步提交。');
|
||||
}
|
||||
} catch (err) { alert('保存失败:' + err.message); }
|
||||
} catch (err) { toast('保存失败:' + err.message); }
|
||||
}
|
||||
|
||||
$('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
|
||||
@@ -614,16 +821,16 @@ $('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(c
|
||||
|
||||
// 差异:复制 / 下载 .patch
|
||||
$('#btn-diff-copy').addEventListener('click', async () => {
|
||||
if (!diffText) { alert('当前没有可复制的差异内容'); return; }
|
||||
if (!diffText) { toast('当前没有可复制的差异内容'); return; }
|
||||
try {
|
||||
await navigator.clipboard.writeText(diffText);
|
||||
alert('差异内容已复制到剪贴板');
|
||||
toast('差异内容已复制到剪贴板');
|
||||
} catch (err) {
|
||||
alert('复制失败(浏览器可能限制了剪贴板权限):' + err.message);
|
||||
toast('复制失败(浏览器可能限制了剪贴板权限):' + err.message);
|
||||
}
|
||||
});
|
||||
$('#btn-diff-download').addEventListener('click', () => {
|
||||
if (!diffText) { alert('当前没有可下载的差异内容'); return; }
|
||||
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');
|
||||
@@ -639,7 +846,7 @@ $('#btn-file-refresh').addEventListener('click', () => { if (current) loadTree(c
|
||||
|
||||
// 编辑器「对比此文件」→ 跳到差异 tab 并只看该文件
|
||||
$('#btn-file-diff').addEventListener('click', () => {
|
||||
if (!current || !fileCurrent) { alert('请先在左侧选择一个文件'); return; }
|
||||
if (!current || !fileCurrent) { toast('请先在左侧选择一个文件'); return; }
|
||||
diffPath = fileCurrent;
|
||||
$('#f-diff-scope').value = 'working';
|
||||
showTab('diff');
|
||||
@@ -773,7 +980,7 @@ $('#cfg-form').addEventListener('submit', async (e) => {
|
||||
}
|
||||
closeModal();
|
||||
refresh();
|
||||
} catch (err) { alert('保存失败:' + err.message); }
|
||||
} catch (err) { toast('保存失败:' + err.message); }
|
||||
});
|
||||
|
||||
$('#btn-delete').addEventListener('click', async () => {
|
||||
@@ -783,7 +990,7 @@ $('#btn-delete').addEventListener('click', async () => {
|
||||
await api(`/api/repos/${current}`, { method: 'DELETE' });
|
||||
closeModal();
|
||||
refresh();
|
||||
} catch (err) { alert(err.message); }
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
});
|
||||
|
||||
// ---------- 顶部按钮 ----------
|
||||
|
||||
Reference in New Issue
Block a user