fix: improve auto save stability

This commit is contained in:
Vadim Melnicuk
2026-03-08 16:18:19 +00:00
parent 04720ce387
commit fca129a725
3 changed files with 141 additions and 58 deletions
+76 -33
View File
@@ -328,25 +328,56 @@ export function createPanelSessionController(params: PanelSessionControllerParam
return applyQueue;
};
const applyPendingDraftIfNeeded = async (): Promise<void> => {
const reportBackgroundError = (contextLabel: string, error: unknown): void => {
if (disposed) {
return;
}
console.error(`[MEO panelSession] ${contextLabel}`, error);
};
const runBackground = (promise: Promise<unknown>, contextLabel: string): void => {
void promise.catch((error) => {
reportBackgroundError(contextLabel, error);
});
};
const postToWebview = async (message: Record<string, unknown>): Promise<boolean> => {
if (disposed) {
return false;
}
try {
return await panel.webview.postMessage(message);
} catch {
return false;
}
};
const saveDocumentIfAutoSaveEnabled = async (): Promise<void> => {
if (!getAutoSaveEnabled(context) || !document.isDirty) {
return;
}
await document.save();
};
const applyPendingDraftIfNeeded = async (): Promise<boolean> => {
const draftText = pendingDraftText;
pendingDraftText = null;
if (draftText === null) {
return;
return false;
}
const currentText = document.getText();
const normalizedCurrent = currentText.replace(/\r\n/g, '\n');
const normalizedDraft = draftText.replace(/\r\n/g, '\n');
if (normalizedCurrent === normalizedDraft) {
return;
return false;
}
const edit = new vscode.WorkspaceEdit();
const fullRange = new vscode.Range(document.positionAt(0), document.positionAt(currentText.length));
agentReviewHandoff.noteRecentMEOOwnedFileChangeForUri(document.uri);
edit.replace(document.uri, fullRange, draftText);
await vscode.workspace.applyEdit(edit);
return vscode.workspace.applyEdit(edit);
};
const sendInit = async (): Promise<boolean> => {
@@ -365,7 +396,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam
outlineVisible: getOutlineVisible(context),
theme: getThemeSettings()
};
return panel.webview.postMessage(message);
return postToWebview(message);
};
const sendDocChanged = async (): Promise<boolean> => {
@@ -374,7 +405,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam
text: document.getText(),
version: document.version
};
return panel.webview.postMessage(message);
return postToWebview(message);
};
const sendApplied = async (version: number): Promise<boolean> => {
@@ -382,7 +413,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam
type: 'applied',
version
};
return panel.webview.postMessage(message);
return postToWebview(message);
};
const sendGitBaselineChanged = async (options: RefreshGitBaselineOptions = {}): Promise<boolean> => {
@@ -405,7 +436,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam
version: document.version,
payload
};
const posted = await panel.webview.postMessage(message);
const posted = await postToWebview(message);
if (posted) {
gitDocumentState.setLastSentBaselineHash(payloadHash);
}
@@ -437,7 +468,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam
};
const ensureInitDelivered = async (): Promise<void> => {
if (initDelivered) {
if (disposed || initDelivered) {
return;
}
const posted = await sendInit();
@@ -448,6 +479,9 @@ export function createPanelSessionController(params: PanelSessionControllerParam
const requestExportSnapshot = async (): Promise<{ text: string; environment?: ExportStyleEnvironment }> => {
await ensureInitDelivered();
if (disposed) {
throw new Error('The editor was closed before export completed.');
}
const requestId = `export-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const response = new Promise<{ text: string; environment?: ExportStyleEnvironment }>((resolve, reject) => {
@@ -463,7 +497,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam
type: 'requestExportSnapshot',
requestId
};
const posted = await panel.webview.postMessage(message);
const posted = await postToWebview(message);
if (!posted) {
rejectPendingExportSnapshot(requestId, new Error('The editor webview is not ready to export.'));
}
@@ -524,7 +558,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam
} finally {
gitRefreshRunning = false;
if (gitRefreshPending) {
void runPendingGitRefreshes();
runBackground(runPendingGitRefreshes(), 'runPendingGitRefreshes.retry');
}
}
};
@@ -536,7 +570,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam
gitRefreshPending = true;
gitRefreshPendingForcePost = gitRefreshPendingForcePost || options.forcePost === true;
gitRefreshPendingForceReload = gitRefreshPendingForceReload || options.forceReload === true;
void runPendingGitRefreshes();
runBackground(runPendingGitRefreshes(), 'runPendingGitRefreshes');
};
const isTextEditorForDocument = (textEditor: vscode.TextEditor | undefined): textEditor is vscode.TextEditor => {
@@ -563,7 +597,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam
anchor: selection.anchor,
head: selection.head
};
const posted = await panel.webview.postMessage(message);
const posted = await postToWebview(message);
if (posted) {
lastSentRevealSelectionKey = getRevealSelectionKey(selection);
pendingRevealSelection = null;
@@ -582,7 +616,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam
const postFocusEditor = async (): Promise<void> => {
await ensureInitDelivered();
const message: FocusEditorMessage = { type: 'focusEditor' };
await panel.webview.postMessage(message);
await postToWebview(message);
};
const sendRevealSelectionForEditor = async (textEditor: vscode.TextEditor | undefined): Promise<void> => {
@@ -627,6 +661,9 @@ export function createPanelSessionController(params: PanelSessionControllerParam
};
const handleMessage = async (raw: WebviewMessage): Promise<void> => {
if (disposed) {
return;
}
switch (raw.type) {
case 'ready':
await ensureInitDelivered();
@@ -685,7 +722,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam
requestId: raw.requestId,
resolvedUrl: resolveWebviewImageSrc(raw.url, documentUri, panel.webview)
};
await panel.webview.postMessage(response);
await postToWebview(response);
return;
}
case 'resolveWikiLinks': {
@@ -694,7 +731,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam
requestId: raw.requestId,
results: await resolveWikiLinkTargets(raw.targets, documentUri)
};
await panel.webview.postMessage(response);
await postToWebview(response);
return;
}
case 'exportSnapshot':
@@ -715,7 +752,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam
localEditGeneration: raw.localEditGeneration,
result: resolved.result
};
await panel.webview.postMessage(response);
await postToWebview(response);
return;
}
case 'openGitRevisionForLine':
@@ -728,7 +765,10 @@ export function createPanelSessionController(params: PanelSessionControllerParam
agentReviewHandoff.noteRecentMEOOwnedFileChangeForUri(document.uri);
isApplyingOwnChange = true;
try {
await enqueue(() => applyDocumentChanges(document, raw, sendDocChanged, sendApplied));
await enqueue(async () => {
await applyDocumentChanges(document, raw, sendDocChanged, sendApplied);
await saveDocumentIfAutoSaveEnabled();
});
} finally {
isApplyingOwnChange = false;
}
@@ -748,14 +788,14 @@ export function createPanelSessionController(params: PanelSessionControllerParam
return;
case 'saveImageFromClipboard': {
const response = await handleSaveImageFromClipboard(raw, documentUri);
await panel.webview.postMessage(response);
await postToWebview(response);
return;
}
}
};
const messageSubscription = panel.webview.onDidReceiveMessage((raw: WebviewMessage) => {
void handleMessage(raw);
runBackground(handleMessage(raw), 'handleMessage');
});
const documentChangeSubscription = vscode.workspace.onDidChangeTextDocument((event) => {
@@ -772,9 +812,9 @@ export function createPanelSessionController(params: PanelSessionControllerParam
return;
}
void enqueue(async () => {
runBackground(enqueue(async () => {
await sendDocChanged();
});
}), 'sendDocChanged');
});
const documentSaveSubscription = vscode.workspace.onDidSaveTextDocument((savedDocument) => {
@@ -788,27 +828,27 @@ export function createPanelSessionController(params: PanelSessionControllerParam
if (!isTextEditorForDocument(event.textEditor)) {
return;
}
void sendRevealSelectionForEditor(event.textEditor);
runBackground(sendRevealSelectionForEditor(event.textEditor), 'sendRevealSelectionForEditor.selection');
});
const activeTextEditorSubscription = vscode.window.onDidChangeActiveTextEditor((textEditor) => {
if (!isTextEditorForDocument(textEditor)) {
return;
}
void sendRevealSelectionForEditor(textEditor);
runBackground(sendRevealSelectionForEditor(textEditor), 'sendRevealSelectionForEditor.activeEditor');
});
const visibleTextEditorsSubscription = vscode.window.onDidChangeVisibleTextEditors(() => {
void sendRevealSelectionForEditor(findEditorForDocumentReveal());
runBackground(sendRevealSelectionForEditor(findEditorForDocumentReveal()), 'sendRevealSelectionForEditor.visibleEditors');
});
const viewStateSubscription = panel.onDidChangeViewState((event) => {
if (event.webviewPanel.active) {
onPanelActivated(event.webviewPanel);
refreshGitBaseline({ forcePost: true });
void flushPendingRevealSelection();
void sendRevealSelectionForEditor(findEditorForDocumentReveal());
void postFocusEditor();
runBackground(flushPendingRevealSelection(), 'flushPendingRevealSelection');
runBackground(sendRevealSelectionForEditor(findEditorForDocumentReveal()), 'sendRevealSelectionForEditor.viewState');
runBackground(postFocusEditor(), 'postFocusEditor');
}
onPanelViewStateChanged();
});
@@ -817,9 +857,9 @@ export function createPanelSessionController(params: PanelSessionControllerParam
dispose();
});
void ensureInitDelivered();
runBackground(ensureInitDelivered(), 'ensureInitDelivered.startup');
refreshGitBaseline({ forcePost: true });
void sendRevealSelectionForEditor(findEditorForDocumentReveal());
runBackground(sendRevealSelectionForEditor(findEditorForDocumentReveal()), 'sendRevealSelectionForEditor.startup');
const dispose = (): void => {
if (disposed) {
@@ -827,14 +867,17 @@ export function createPanelSessionController(params: PanelSessionControllerParam
}
disposed = true;
void enqueue(async () => {
runBackground(enqueue(async () => {
try {
// Best-effort recovery for edits that never made it through the debounce/apply round-trip.
await applyPendingDraftIfNeeded();
const recovered = await applyPendingDraftIfNeeded();
if (recovered) {
await saveDocumentIfAutoSaveEnabled();
}
} catch {
// Ignore dispose-time recovery failures to avoid surfacing noisy teardown errors.
}
});
}), 'disposeDraftRecovery');
rejectPendingExportSnapshots(new Error('The editor was closed before export completed.'));
messageSubscription.dispose();
+29 -25
View File
@@ -417,7 +417,13 @@ const findPanelController = createFindPanelController(findPanelElements, () => e
const selectionMenuElements = createSelectionMenu();
const selectionMenuController = createSelectionMenuController(selectionMenuElements, () => editor);
toolbar.replaceChildren(formatGroup, rightGroup, modeGroup, findPanelElements.panel);
const editorNoticeBanner = document.createElement('div');
editorNoticeBanner.className = 'editor-notice';
editorNoticeBanner.setAttribute('role', 'status');
editorNoticeBanner.setAttribute('aria-live', 'polite');
editorNoticeBanner.hidden = true;
toolbar.replaceChildren(formatGroup, rightGroup, modeGroup, findPanelElements.panel, editorNoticeBanner);
const existingEditorWrapper = root.querySelector('.editor-wrapper');
const editorWrapper = existingEditorWrapper instanceof HTMLElement ? existingEditorWrapper : document.createElement('div');
@@ -461,9 +467,28 @@ let hasSentDraftText = false;
let initialEditorMountInFlight = false;
let createEditorFactoryPromise: Promise<CreateEditorFactory> | null = null;
const setEditorNotice = (message: string, kind = 'info') => {
const normalizedMessage = `${message ?? ''}`.trim();
if (!normalizedMessage) {
clearEditorNotice();
return;
}
editorNoticeBanner.textContent = normalizedMessage;
editorNoticeBanner.dataset.kind = kind;
editorNoticeBanner.hidden = false;
editorNoticeBanner.classList.add('is-visible');
};
const clearEditorNotice = () => {
editorNoticeBanner.textContent = '';
delete editorNoticeBanner.dataset.kind;
editorNoticeBanner.hidden = true;
editorNoticeBanner.classList.remove('is-visible');
};
const editorNotice: EditorNotice = {
setEditorNotice: (_message, _kind = 'info') => {},
clearEditorNotice: () => {}
setEditorNotice,
clearEditorNotice
};
const failureNotice = createFailureNoticeManager(editorNotice);
@@ -1127,9 +1152,6 @@ window.addEventListener('message', (event) => {
flushChanges();
maybeSaveAfterSync();
syncPendingDraftState();
if (autoSaveEnabled && !inFlight && pendingText !== null && normalizeEol(pendingText) === syncedText) {
vscode.postMessage({ type: 'saveDocument' });
}
return;
}
@@ -1245,25 +1267,7 @@ window.addEventListener('visibilitychange', () => {
});
const forceFlushChanges = () => {
if (!editor || pendingText === null) {
return;
}
const nextText = pendingText;
const message: WebviewMessage = {
type: 'applyChanges',
baseVersion: documentVersion,
changes: [
{
from: 0,
to: syncedText.length,
insert: nextText
}
]
};
documentVersion++;
vscode.postMessage(message);
flushChanges();
};
window.addEventListener('beforeunload', () => {
+36
View File
@@ -107,6 +107,42 @@ body {
background-color: var(--vscode-sideBar-background);
}
.editor-notice {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 14px;
right: 14px;
padding: 6px 10px;
border: 1px solid var(--vscode-inputValidation-infoBorder, var(--vscode-panel-border));
border-radius: 6px;
color: var(--vscode-inputValidation-infoForeground, var(--vscode-editor-foreground));
background: var(
--vscode-inputValidation-infoBackground,
var(--vscode-editorWidget-background, var(--vscode-sideBar-background))
);
font-size: 12px;
line-height: 1.4;
z-index: 275;
pointer-events: none;
}
.editor-notice.is-visible {
display: block;
}
.editor-notice[data-kind='warning'] {
border-color: var(--vscode-inputValidation-warningBorder, #b89500);
color: var(--vscode-inputValidation-warningForeground, var(--vscode-editor-foreground));
background: var(--vscode-inputValidation-warningBackground, rgba(184, 149, 0, 0.18));
}
.editor-notice[data-kind='error'] {
border-color: var(--vscode-inputValidation-errorBorder, #be1100);
color: var(--vscode-inputValidation-errorForeground, #ffffff);
background: var(--vscode-inputValidation-errorBackground, rgba(190, 17, 0, 0.3));
}
.find-panel {
display: none;
position: absolute;