diff --git a/src/extension/panelSession.ts b/src/extension/panelSession.ts index a17c15a..30df4cf 100644 --- a/src/extension/panelSession.ts +++ b/src/extension/panelSession.ts @@ -328,25 +328,56 @@ export function createPanelSessionController(params: PanelSessionControllerParam return applyQueue; }; - const applyPendingDraftIfNeeded = async (): Promise => { + const reportBackgroundError = (contextLabel: string, error: unknown): void => { + if (disposed) { + return; + } + console.error(`[MEO panelSession] ${contextLabel}`, error); + }; + + const runBackground = (promise: Promise, contextLabel: string): void => { + void promise.catch((error) => { + reportBackgroundError(contextLabel, error); + }); + }; + + const postToWebview = async (message: Record): Promise => { + if (disposed) { + return false; + } + try { + return await panel.webview.postMessage(message); + } catch { + return false; + } + }; + + const saveDocumentIfAutoSaveEnabled = async (): Promise => { + if (!getAutoSaveEnabled(context) || !document.isDirty) { + return; + } + await document.save(); + }; + + const applyPendingDraftIfNeeded = async (): Promise => { 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 => { @@ -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 => { @@ -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 => { @@ -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 => { @@ -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 => { - 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 => { await ensureInitDelivered(); const message: FocusEditorMessage = { type: 'focusEditor' }; - await panel.webview.postMessage(message); + await postToWebview(message); }; const sendRevealSelectionForEditor = async (textEditor: vscode.TextEditor | undefined): Promise => { @@ -627,6 +661,9 @@ export function createPanelSessionController(params: PanelSessionControllerParam }; const handleMessage = async (raw: WebviewMessage): Promise => { + 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(); diff --git a/webview/src/index.ts b/webview/src/index.ts index 7b99fcc..5215530 100644 --- a/webview/src/index.ts +++ b/webview/src/index.ts @@ -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 | 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', () => { diff --git a/webview/src/styles.css b/webview/src/styles.css index ffd53ab..15a1f7c 100644 --- a/webview/src/styles.css +++ b/webview/src/styles.css @@ -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;