feat: advance local-first conflict and search indexing

- 补齐本地 markdown 冲突处理与合并写回路径\n- 增加本地搜索索引路由、刷新与 browser smoke\n- 同步更新 current-priority checklist 的阶段进度
This commit is contained in:
lix-2026
2026-05-19 09:38:57 +08:00
parent 8ed594f1c2
commit 1b5d6a2a2d
12 changed files with 1622 additions and 49 deletions
+99 -5
View File
@@ -1571,6 +1571,27 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return flattenText(toTiptapDocument(body.content)).replace(/\n{3,}/g, '\n\n').trim();
};
const conflictEnvelopeFromResponse = (payload) => (
payload?.error?.details?.conflict
|| payload?.details?.conflict
|| null
);
const conflictSourceLabel = (session) => {
if (!session) return '';
if (session.lastExternalWriteSource === 'mnote-hermes-tool') {
const runId = String(session.lastExternalWriteRunId || '').trim();
return runId ? `agent run ${runId}` : 'agent run';
}
return '本地文件变更';
};
const conflictMessageFromEnvelope = (session, envelope) => {
const baseMessage = envelope?.message || externalConflictMessage;
const sourceLabel = conflictSourceLabel(session);
return sourceLabel ? `${baseMessage}(来源:${sourceLabel}` : baseMessage;
};
const clearSessionConflictSurface = (session) => {
sessionViews(session).forEach((view) => {
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
@@ -1633,7 +1654,42 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
currentBox.append(currentTitle, current);
const diskBox = document.createElement('section');
diskBox.append(diskTitle, disk);
diffPanel.append(currentBox, diskBox);
const mergeTitle = document.createElement('h3');
mergeTitle.textContent = '合并结果';
const mergeText = document.createElement('textarea');
mergeText.setAttribute('data-testid', 'mnote-conflict-merge-text');
mergeText.value = sessionPlainText(session) || aggregatePlainText(latest) || '';
const mergeActions = document.createElement('div');
mergeActions.className = 'mnote-conflict-actions';
const useCurrent = document.createElement('button');
useCurrent.type = 'button';
useCurrent.textContent = '使用当前版本';
useCurrent.setAttribute('data-testid', 'mnote-conflict-merge-use-current');
const useDisk = document.createElement('button');
useDisk.type = 'button';
useDisk.textContent = '使用磁盘版本';
useDisk.setAttribute('data-testid', 'mnote-conflict-merge-use-disk');
const saveMerge = document.createElement('button');
saveMerge.type = 'button';
saveMerge.textContent = '写回合并结果';
saveMerge.setAttribute('data-testid', 'mnote-conflict-merge-save');
mergeActions.append(useCurrent, useDisk, saveMerge);
const mergeBox = document.createElement('section');
mergeBox.className = 'mnote-conflict-merge-box';
mergeBox.append(mergeTitle, mergeText, mergeActions);
diffPanel.append(currentBox, diskBox, mergeBox);
useCurrent.addEventListener('click', () => {
mergeText.value = sessionPlainText(session) || '';
});
useDisk.addEventListener('click', () => {
mergeText.value = aggregatePlainText(latest) || '';
});
saveMerge.addEventListener('click', () => {
writeMergedConflictResult(session, panel, mergeText.value).catch((error) => {
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
});
});
} catch (error) {
loading.textContent = error instanceof Error ? error.message : String(error);
diffPanel.replaceChildren(loading);
@@ -1673,6 +1729,24 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
await persistSession(session);
};
const writeMergedConflictResult = async (session, panel, mergedText) => {
setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...');
const latest = await fetchLatestSessionAggregate(session);
const nextKey = conflictDetectionKeyFromBody(latest.body || {});
if (nextKey) {
session.conflictDetectionKey = nextKey;
session.lastExternalConflictDetectionKey = nextKey;
}
session.currentTiptapDocument = textToTiptapDocument(String(mergedText || ''));
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
session.hasExternalConflict = false;
session.externalChangePending = false;
session.saving = false;
session.dirty = true;
if (panel && typeof panel.remove === 'function') panel.remove();
await persistSession(session);
};
const renderSessionConflictSurface = (session, message) => {
clearSessionConflictSurface(session);
sessionViews(session).forEach((view) => {
@@ -1690,7 +1764,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
text.textContent = message || externalConflictMessage;
const meta = document.createElement('div');
meta.className = 'mnote-conflict-meta';
meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:本地文件变更`;
meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`;
const actions = document.createElement('div');
actions.className = 'mnote-conflict-actions';
const acceptDisk = document.createElement('button');
@@ -1744,8 +1818,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
window.clearTimeout(session.saveTimer);
session.saveTimer = 0;
}
setSessionStatus(session, 'external-change-conflict', message || externalConflictMessage);
renderSessionConflictSurface(session, message || externalConflictMessage);
const nextMessage = message || externalConflictMessage;
setSessionStatus(session, 'external-change-conflict', nextMessage);
renderSessionConflictSurface(session, nextMessage);
};
const queueSessionSave = (session) => {
@@ -1801,6 +1876,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const result = await response.json().catch(() => null);
if (!response.ok || !result || result.ok !== true) {
const message = result?.error?.message || result?.message || `save_failed_${response.status}`;
const conflictEnvelope = conflictEnvelopeFromResponse(result);
if (response.status === 409 && conflictEnvelope && session.sourceKind === 'local_folder') {
session.saving = false;
session.lastExternalConflictEnvelope = conflictEnvelope;
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope));
return;
}
throw new Error(message);
}
const saved = result.result || {};
@@ -1879,6 +1961,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return;
}
const payload = await response.json();
const conflictEnvelope = conflictEnvelopeFromResponse(payload);
if (response.status === 409 && conflictEnvelope) {
session.lastExternalConflictEnvelope = conflictEnvelope;
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope));
return;
}
const nextAggregate = payload?.result;
const nextBody = nextAggregate?.body || {};
const nextPermissions = nextAggregate?.head?.permissions || {};
@@ -2137,8 +2225,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return;
session.lastExternalChangeSignalAt = Date.now();
session.externalChangePending = true;
session.lastExternalWriteSource = source || session.lastExternalWriteSource || '';
session.lastExternalWriteRunId = String(detail?.runId || detail?.traceId || detail?.toolCallId || '').trim();
session.lastExternalWriteDocumentId = documentId;
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
markSessionExternalConflict(session, treeExternalConflictMessage);
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, session.lastExternalConflictEnvelope) || treeExternalConflictMessage);
return;
}
scheduled += 1;
@@ -3676,6 +3767,9 @@ mod tests {
assert!(html.contains("mnote-conflict-accept-disk"));
assert!(html.contains("mnote-conflict-keep-current"));
assert!(html.contains("mnote-conflict-open-diff"));
assert!(html.contains("mnote-conflict-merge-text"));
assert!(html.contains("mnote-conflict-merge-save"));
assert!(html.contains("agent run"));
assert!(!html.contains(
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
));