From 43343168c8deb624a8d57c47a6afa3186e1ca77e Mon Sep 17 00:00:00 2001 From: Vadim Melnicuk Date: Fri, 11 Sep 2026 09:31:42 +0100 Subject: [PATCH] fix: Cursor file editor associations on AI pending edits --- package.json | 10 +- src/agents/documentPrimer.ts | 174 ++++++++++++++ src/agents/resourceMatching.ts | 21 ++ src/agents/reviewOverrides.ts | 27 ++- src/agents/reviewState.ts | 5 +- src/extension.ts | 187 ++++++++++++--- src/extension/markdownCustomDocument.ts | 293 ++++++++++++++++++++++++ src/extension/panelSession.ts | 68 +++--- src/shared/extensionConfig.ts | 10 +- 9 files changed, 726 insertions(+), 69 deletions(-) create mode 100644 src/agents/documentPrimer.ts create mode 100644 src/extension/markdownCustomDocument.ts diff --git a/package.json b/package.json index 6b773d4..806a0f3 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,15 @@ "chat-editing-text-model:**/*.md": "default", "chat-editing-text-model:**/*.markdown": "default", "chat-editing-text-model:**/*.mdx": "default", - "chat-editing-text-model:**/*.mdc": "default" + "chat-editing-text-model:**/*.mdc": "default", + "chat-editing-snapshot-text-model:/**/*.md": "default", + "chat-editing-snapshot-text-model:/**/*.markdown": "default", + "chat-editing-snapshot-text-model:/**/*.mdx": "default", + "chat-editing-snapshot-text-model:/**/*.mdc": "default", + "chat-editing-snapshot-text-model:**/*.md": "default", + "chat-editing-snapshot-text-model:**/*.markdown": "default", + "chat-editing-snapshot-text-model:**/*.mdx": "default", + "chat-editing-snapshot-text-model:**/*.mdc": "default" } }, "configuration": { diff --git a/src/agents/documentPrimer.ts b/src/agents/documentPrimer.ts new file mode 100644 index 0000000..0600c5b --- /dev/null +++ b/src/agents/documentPrimer.ts @@ -0,0 +1,174 @@ +import * as vscode from 'vscode'; +import { isMarkdownDocumentPath } from '../shared/extensionConfig'; +import type { AgentReviewOverrideController } from './reviewOverrides'; + +const NATIVE_INIT_SETTLE_MS = 50; + +type AgentReviewDocumentPrimerDeps = { + getComparableResourceKey: (uri: vscode.Uri) => string | undefined; + getOpenTextDocumentForUri: (uri: vscode.Uri) => vscode.TextDocument | undefined; + overrides: AgentReviewOverrideController; +}; + +export class AgentReviewDocumentPrimer { + private readonly primedKeys = new Set(); + private readonly inFlight = new Map>(); + private primingDepth = 0; + + constructor(private readonly deps: AgentReviewDocumentPrimerDeps) {} + + get isPriming(): boolean { + return this.primingDepth > 0; + } + + async ensureReady(uri: vscode.Uri, options?: { forceNative?: boolean }): Promise { + if (!isMarkdownFileUri(uri)) { + return; + } + + const key = this.getKey(uri); + if (!key) { + return; + } + + const forceNative = options?.forceNative === true; + if (!forceNative && this.primedKeys.has(key) && this.deps.getOpenTextDocumentForUri(uri)) { + return; + } + + const existing = this.inFlight.get(key); + if (existing) { + await existing; + if (!forceNative || this.primedKeys.has(key)) { + return; + } + } + + const run = this.prime(uri, key, forceNative); + this.inFlight.set(key, run); + try { + await run; + } finally { + if (this.inFlight.get(key) === run) { + this.inFlight.delete(key); + } + } + } + + private async prime(uri: vscode.Uri, key: string, forceNative: boolean): Promise { + this.primingDepth += 1; + const shouldPin = forceNative; + if (shouldPin) { + this.deps.overrides.pinFile(uri); + } + + try { + if (shouldPin) { + await this.deps.overrides.syncNow(); + } + + try { + await vscode.workspace.openTextDocument(uri); + } catch { + // Cursor may refuse to sync the document to the extension host. + } + + const needsNative = isCursorHost() && forceNative && !this.hasOpenNativeOrCustomTabForKey(key); + if (needsNative) { + await this.initializeViaDefaultEditor(uri, key); + try { + await vscode.workspace.openTextDocument(uri); + } catch { + // Native open can still leave the file unsyncable; the custom editor + // can load it from disk instead. + } + } + + this.primedKeys.add(key); + } finally { + if (shouldPin) { + this.deps.overrides.unpinFile(uri); + this.deps.overrides.scheduleSync(); + } + this.primingDepth -= 1; + } + } + + private async initializeViaDefaultEditor(uri: vscode.Uri, key: string): Promise { + const nativeBefore = this.findNativeTextTabs(key); + try { + await vscode.commands.executeCommand('vscode.openWith', uri, 'default', { + preview: true, + preserveFocus: true + }); + } catch { + return; + } + + await delay(NATIVE_INIT_SETTLE_MS); + + const opened = this.findNativeTextTabs(key).filter((tab) => !nativeBefore.includes(tab)); + if (opened.length === 0) { + return; + } + + try { + await vscode.window.tabGroups.close(opened, true); + } catch { + // Closing the priming tab is best-effort. + } + } + + private hasOpenNativeOrCustomTabForKey(key: string): boolean { + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + const input = tab.input; + if (!(input instanceof vscode.TabInputText) && !(input instanceof vscode.TabInputCustom)) { + continue; + } + if (this.deps.getComparableResourceKey(input.uri) === key) { + return true; + } + } + } + + return false; + } + + private findNativeTextTabs(key: string): vscode.Tab[] { + const matches: vscode.Tab[] = []; + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + const input = tab.input; + if (!(input instanceof vscode.TabInputText)) { + continue; + } + if (this.deps.getComparableResourceKey(input.uri) === key) { + matches.push(tab); + } + } + } + return matches; + } + + private getKey(uri: vscode.Uri): string | undefined { + return this.deps.getComparableResourceKey(uri) ?? uri.with({ fragment: '' }).toString(); + } +} + +export function isMarkdownFileUri(uri: vscode.Uri): boolean { + if (uri.scheme !== 'file') { + return false; + } + + const targetPath = (uri.path || uri.fsPath || '').toLowerCase(); + return isMarkdownDocumentPath(targetPath); +} + +function isCursorHost(): boolean { + return /cursor/i.test(vscode.env.appName); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/agents/resourceMatching.ts b/src/agents/resourceMatching.ts index d029499..154ee9d 100644 --- a/src/agents/resourceMatching.ts +++ b/src/agents/resourceMatching.ts @@ -47,6 +47,27 @@ export function getPreferredCommandUri(value: unknown): vscode.Uri | undefined { return shouldPreferActiveContextUri(commandUri, activeContextUri) ? activeContextUri : commandUri; } +export function getFileEditorAssociationGlobs(fileUri: vscode.Uri): string[] { + const globs = new Set(); + if (fileUri.scheme && fileUri.path) { + // VS Code matches path globs against `${scheme}:${path}` (e.g. file:/Users/me/note.md). + globs.add(`${fileUri.scheme}:${fileUri.path}`); + } + + const comparableKey = getComparableResourceKey(fileUri); + if (comparableKey) { + globs.add(`file:${vscode.Uri.file(comparableKey).path}`); + } + + const relativeSource = comparableKey ? vscode.Uri.file(comparableKey) : fileUri; + const relative = vscode.workspace.asRelativePath(relativeSource, false); + if (relative && !path.isAbsolute(relative)) { + globs.add(`**/${relative.split(path.sep).join(path.posix.sep)}`); + } + + return Array.from(globs); +} + export function getComparableResourceKey(uri: vscode.Uri): string | undefined { if (uri.scheme === 'file') { return path.normalize(uri.fsPath); diff --git a/src/agents/reviewOverrides.ts b/src/agents/reviewOverrides.ts index 6e458f1..69e7e9a 100644 --- a/src/agents/reviewOverrides.ts +++ b/src/agents/reviewOverrides.ts @@ -1,5 +1,5 @@ -import * as path from 'node:path'; import * as vscode from 'vscode'; +import { getFileEditorAssociationGlobs } from './resourceMatching'; import { areAgentReviewTextsEquivalent } from './reviewState'; const REVIEW_FILE_OVERRIDE_STATE_KEY = 'copilotReviewNativeFileOverrides'; @@ -12,12 +12,25 @@ type AgentReviewOverrideDeps = { export class AgentReviewOverrideController { private syncTimer: ReturnType | null = null; + private readonly pinnedOverrideKeys = new Set(); constructor( private readonly context: vscode.ExtensionContext, private readonly deps: AgentReviewOverrideDeps ) {} + pinFile(fileUri: vscode.Uri): void { + for (const key of this.getOverridePatterns(fileUri)) { + this.pinnedOverrideKeys.add(key); + } + } + + unpinFile(fileUri: vscode.Uri): void { + for (const key of this.getOverridePatterns(fileUri)) { + this.pinnedOverrideKeys.delete(key); + } + } + scheduleSync(delayMs = 50): void { if (this.syncTimer) { clearTimeout(this.syncTimer); @@ -52,7 +65,7 @@ export class AgentReviewOverrideController { } private collectOverrideKeys(): Set { - const keys = new Set(); + const keys = new Set(this.pinnedOverrideKeys); for (const document of vscode.workspace.textDocuments) { if (!this.deps.isLikelyAgentReviewUri(document.uri)) { @@ -69,7 +82,7 @@ export class AgentReviewOverrideController { continue; } - for (const key of this.getOverridePatterns(targetKey)) { + for (const key of this.getOverridePatterns(vscode.Uri.file(targetKey))) { keys.add(key); } } @@ -77,12 +90,8 @@ export class AgentReviewOverrideController { return keys; } - private getOverridePatterns(targetKey: string): string[] { - const keys = new Set([targetKey]); - const posixKey = targetKey.split(path.sep).join(path.posix.sep); - keys.add(posixKey); - - return Array.from(keys); + private getOverridePatterns(fileUri: vscode.Uri): string[] { + return getFileEditorAssociationGlobs(fileUri); } private applyOverrideAssociations( diff --git a/src/agents/reviewState.ts b/src/agents/reviewState.ts index 1424625..7546595 100644 --- a/src/agents/reviewState.ts +++ b/src/agents/reviewState.ts @@ -1,6 +1,9 @@ import * as vscode from 'vscode'; -export const AGENT_REVIEW_MODEL_SCHEMES = ['chat-editing-text-model'] as const; +export const AGENT_REVIEW_MODEL_SCHEMES = [ + 'chat-editing-text-model', + 'chat-editing-snapshot-text-model' +] as const; const agentReviewModelSchemeSet = new Set(AGENT_REVIEW_MODEL_SCHEMES); export type LikelyAgentReviewState = { diff --git a/src/extension.ts b/src/extension.ts index 531a93f..4833310 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,6 +24,7 @@ import { resolveWorktreeUriFromGitUri } from './agents/resourceMatching'; import { AgentReviewOverrideController } from './agents/reviewOverrides'; +import { AgentReviewDocumentPrimer, isMarkdownFileUri } from './agents/documentPrimer'; import { EXTENSION_CONFIG_SECTION, GIT_CHANGES_GUTTER_LEGACY_SETTING_KEY, @@ -68,6 +69,7 @@ import { resetThemeSettingsToDefault } from './shared/extensionConfig'; import { createPanelSessionController, type ExportFormat, type PanelSession } from './extension/panelSession'; +import { MarkdownCustomDocument } from './extension/markdownCustomDocument'; import { serializeThemeSettings, themePresets, type ThemeSettings, validateThemePayload } from './shared/themeDefaults'; import { runWithTimedUiTimeout, @@ -155,6 +157,11 @@ export function activate(context: vscode.ExtensionContext): void { getOpenTextDocumentForComparableKey, isLikelyAgentReviewUri }); + const agentReviewPrimer = new AgentReviewDocumentPrimer({ + getComparableResourceKey, + getOpenTextDocumentForUri, + overrides: agentReviewOverrides + }); void agentReviewOverrides.syncNow(); const provider = new MarkdownWebviewProvider(context, agentReviewHandoff); @@ -188,11 +195,41 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push( vscode.workspace.onDidOpenTextDocument((document) => { + if (isMarkdownFileUri(document.uri)) { + void agentReviewPrimer.ensureReady(document.uri); + } if (!isLikelyAgentReviewUri(document.uri)) { return; } void agentReviewOverrides.syncNow(); - void provider.redirectOpenEditorsForCopilotReview(document.uri); + if (!agentReviewPrimer.isPriming) { + void provider.redirectOpenEditorsForCopilotReview(document.uri); + } + }) + ); + + context.subscriptions.push( + vscode.workspace.onDidCreateFiles((event) => { + for (const uri of event.files) { + void agentReviewPrimer.ensureReady(uri); + } + }) + ); + + const markdownFileWatcher = vscode.workspace.createFileSystemWatcher('**/*.{md,markdown,mdx,mdc}'); + context.subscriptions.push( + markdownFileWatcher, + markdownFileWatcher.onDidCreate((uri) => { + void agentReviewPrimer.ensureReady(uri); + }), + markdownFileWatcher.onDidChange((uri) => { + if (agentReviewHandoff.hasRecentMEOOwnedFileChangeForUri(uri)) { + return; + } + if (getOpenTextDocumentForUri(uri)) { + return; + } + void agentReviewPrimer.ensureReady(uri); }) ); @@ -226,7 +263,7 @@ export function activate(context: vscode.ExtensionContext): void { if (isAgentReviewDocument) { agentReviewOverrides.scheduleSync(); } - if (!agentReviewHandoff.hasRecentMEOOwnedFileChangeForUri(event.document.uri)) { + if (!agentReviewPrimer.isPriming && !agentReviewHandoff.hasRecentMEOOwnedFileChangeForUri(event.document.uri)) { void provider.redirectOpenEditorsForCopilotReview(event.document.uri); } @@ -249,6 +286,11 @@ export function activate(context: vscode.ExtensionContext): void { vscode.window.tabGroups.onDidChangeTabs((event) => { agentReviewHandoff.noteRecentTextDiffActivity(event.opened); agentReviewHandoff.noteRecentTextDiffActivity(event.changed); + if (!agentReviewPrimer.isPriming) { + for (const tab of [...event.opened, ...event.changed]) { + void primeDiffTabResources(tab, agentReviewPrimer); + } + } void agentReviewHandoff.flushPendingMEOtabDedups(); if (!agentReviewHandoff.hasPendingDeferredReopens()) { return; @@ -275,11 +317,16 @@ export function activate(context: vscode.ExtensionContext): void { getOpenTextDocumentForUri, getOpenTextDocumentForUri(targetUri)?.getText() ); + await agentReviewPrimer.ensureReady(targetUri); + try { + await vscode.commands.executeCommand('vscode.openWith', targetUri, VIEW_TYPE); + } catch { + await agentReviewPrimer.ensureReady(targetUri, { forceNative: true }); + await vscode.commands.executeCommand('vscode.openWith', targetUri, VIEW_TYPE); + } if (pendingReview) { agentReviewHandoff.scheduleDeferredReopen(targetUri); - return; } - await vscode.commands.executeCommand('vscode.openWith', targetUri, VIEW_TYPE); }) ); @@ -461,17 +508,80 @@ export function activate(context: vscode.ExtensionContext): void { ); } -class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { +class MarkdownWebviewProvider implements vscode.CustomEditorProvider { private readonly activePanels = new Set(); private readonly panelSessions = new Map(); + private readonly customDocuments = new Map(); private readonly spellDiagnosticCollection = vscode.languages.createDiagnosticCollection('meo-spell'); + private readonly customDocumentChangeEmitter = new vscode.EventEmitter< + vscode.CustomDocumentContentChangeEvent + >(); + readonly onDidChangeCustomDocument = this.customDocumentChangeEmitter.event; private lastActivePanel: vscode.WebviewPanel | null = null; constructor( private readonly context: vscode.ExtensionContext, private readonly agentReviewHandoff: AgentReviewHandoffController ) { - this.context.subscriptions.push(this.spellDiagnosticCollection); + this.context.subscriptions.push(this.spellDiagnosticCollection, this.customDocumentChangeEmitter); + this.context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((event) => { + if (event.contentChanges.length === 0) { + return; + } + const customDocument = this.customDocuments.get(event.document.uri.toString()); + if (!customDocument) { + return; + } + this.customDocumentChangeEmitter.fire({ document: customDocument }); + }) + ); + } + + async openCustomDocument( + uri: vscode.Uri, + openContext: vscode.CustomDocumentOpenContext, + _token: vscode.CancellationToken + ): Promise { + const customDocument = await MarkdownCustomDocument.open(uri, openContext); + customDocument.bindLifecycle(() => { + if (this.customDocuments.get(uri.toString()) === customDocument) { + this.customDocuments.delete(uri.toString()); + } + }); + this.customDocuments.set(uri.toString(), customDocument); + customDocument.onDidChangeContent(() => { + this.customDocumentChangeEmitter.fire({ document: customDocument }); + }); + return customDocument; + } + + async resolveCustomEditor( + document: MarkdownCustomDocument, + panel: vscode.WebviewPanel, + token: vscode.CancellationToken + ): Promise { + await this.resolveMarkdownEditor(document, panel, token); + } + + async saveCustomDocument(document: MarkdownCustomDocument): Promise { + await document.save(); + } + + async saveCustomDocumentAs(document: MarkdownCustomDocument, destination: vscode.Uri): Promise { + await document.save(destination); + } + + async revertCustomDocument(document: MarkdownCustomDocument): Promise { + await document.revert(); + } + + async backupCustomDocument( + document: MarkdownCustomDocument, + context: vscode.CustomDocumentBackupContext, + _token: vscode.CancellationToken + ): Promise { + return document.backup(context.destination); } async initializeGitWatcher(): Promise { @@ -531,7 +641,7 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { } this.agentReviewHandoff.scheduleDeferredReopen(session.documentUri); - await this.redirectCopilotReviewToNativeEditor(session.document, session.panel); + await this.redirectCopilotReviewToNativeEditor(session.documentUri, session.panel); } } @@ -643,30 +753,32 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { await setReadOnlyEnabled(!getReadOnlyEnabled()); } - async resolveCustomTextEditor( - document: vscode.TextDocument, + async resolveMarkdownEditor( + document: MarkdownCustomDocument, panel: vscode.WebviewPanel, _token: vscode.CancellationToken ): Promise { - const pendingReview = findLikelyAgentReviewState( - document.uri, - getComparableResourceKey, - getOpenTextDocumentForUri, - document.getText() - ); + const pendingReview = document.isSynchronized + ? findLikelyAgentReviewState( + document.uri, + getComparableResourceKey, + getOpenTextDocumentForUri, + document.getText() + ) + : undefined; if (pendingReview) { this.agentReviewHandoff.scheduleDeferredReopen(document.uri); - await this.redirectCopilotReviewToNativeEditor(document, panel, true); + await this.redirectCopilotReviewToNativeEditor(document.uri, panel, true); return; } - if (await this.redirectGitResourceToNativeEditor(document, panel)) { + if (await this.redirectGitResourceToNativeEditor(document.uri, panel)) { return; } this.activePanels.add(panel); - const documentUri = resolveWorktreeUri(document); + const documentUri = resolveWorktreeUri(document.uri); const distRoot = vscode.Uri.joinPath(this.context.extensionUri, 'webview', 'dist'); panel.webview.options = { enableScripts: true, @@ -891,10 +1003,10 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { } private async redirectGitResourceToNativeEditor( - document: vscode.TextDocument, + uri: vscode.Uri, panel: vscode.WebviewPanel ): Promise { - if (document.uri.scheme !== 'git') { + if (uri.scheme !== 'git') { return false; } @@ -904,7 +1016,7 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { preview: true, override: 'default' }; - const existingDiff = findDiffContextForGitUri(document.uri); + const existingDiff = findDiffContextForGitUri(uri); if (existingDiff) { await vscode.commands.executeCommand( @@ -918,14 +1030,14 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { return true; } - const ref = getGitUriRef(document.uri); + const ref = getGitUriRef(uri); if (isWorkingTreeOrIndexRef(ref)) { - const targetUri = resolveWorktreeUri(document); - const title = getNativeWorkingTreeTitle(document.uri, targetUri); + const targetUri = resolveWorktreeUri(uri); + const title = getNativeWorkingTreeTitle(uri, targetUri); await vscode.commands.executeCommand( '_workbench.diff', - document.uri, + uri, targetUri, title, [viewColumn, editorOptions] @@ -938,14 +1050,14 @@ class MarkdownWebviewProvider implements vscode.CustomTextEditorProvider { } private async redirectCopilotReviewToNativeEditor( - document: vscode.TextDocument, + uri: vscode.Uri, panel: vscode.WebviewPanel, preserveFocus = false ): Promise { const viewColumn = panel.viewColumn ?? vscode.ViewColumn.Active; await vscode.commands.executeCommand( 'vscode.openWith', - document.uri, + uri, 'default', { viewColumn, @@ -1111,12 +1223,12 @@ function replaceFileExtension(filePath: string, ext: '.html' | '.pdf'): string { return path.join(parsed.dir, `${parsed.name}${ext}`); } -function resolveWorktreeUri(document: vscode.TextDocument): vscode.Uri { - if (document.uri.scheme === 'file') { - return document.uri; +function resolveWorktreeUri(uri: vscode.Uri): vscode.Uri { + if (uri.scheme === 'file') { + return uri; } - return resolveWorktreeUriFromGitUri(document.uri) ?? document.uri; + return resolveWorktreeUriFromGitUri(uri) ?? uri; } function findDiffContextForGitUri(uri: vscode.Uri): { original: vscode.Uri; modified: vscode.Uri; title: string } | undefined { @@ -1249,4 +1361,17 @@ function normalizeThemeId(id: string): string { return id.trim().toLowerCase(); } +function primeDiffTabResources(tab: vscode.Tab, primer: AgentReviewDocumentPrimer): void { + const input = tab.input; + if (!(input instanceof vscode.TabInputTextDiff)) { + return; + } + + for (const uri of [input.original, input.modified]) { + if (isMarkdownFileUri(uri)) { + void primer.ensureReady(uri, { forceNative: true }); + } + } +} + export function deactivate(): void {} diff --git a/src/extension/markdownCustomDocument.ts b/src/extension/markdownCustomDocument.ts new file mode 100644 index 0000000..fd47c89 --- /dev/null +++ b/src/extension/markdownCustomDocument.ts @@ -0,0 +1,293 @@ +import * as vscode from 'vscode'; + +export class MarkdownCustomDocument implements vscode.CustomDocument { + private text: string; + private localVersion = 1; + private onDisposed?: () => void; + private readonly changeEmitter = new vscode.EventEmitter(); + readonly onDidChangeContent = this.changeEmitter.event; + + constructor( + readonly uri: vscode.Uri, + text: string, + readonly textDocument: vscode.TextDocument | undefined + ) { + this.text = text; + } + + static async open( + uri: vscode.Uri, + openContext?: vscode.CustomDocumentOpenContext + ): Promise { + const textDocument = await tryOpenWorkspaceTextDocument(uri); + let text = textDocument ? textDocument.getText() : await readFileText(uri); + + if (openContext?.backupId) { + try { + const backupUri = vscode.Uri.parse(openContext.backupId); + text = Buffer.from(await vscode.workspace.fs.readFile(backupUri)).toString('utf8'); + if (textDocument && textDocument.getText() !== text) { + await replaceWorkspaceDocumentText(textDocument, text); + } + } catch { + // Keep the on-disk text if the backup cannot be restored. + } + } + + return new MarkdownCustomDocument(uri, text, textDocument); + } + + bindLifecycle(onDisposed: () => void): void { + this.onDisposed = onDisposed; + } + + get isSynchronized(): boolean { + return Boolean(this.textDocument); + } + + get version(): number { + return this.textDocument?.version ?? this.localVersion; + } + + get lineCount(): number { + return lineCountOf(this.getText()); + } + + getText(): string { + return this.textDocument?.getText() ?? this.text; + } + + positionAt(offset: number): vscode.Position { + if (this.textDocument) { + return this.textDocument.positionAt(offset); + } + return positionAtOffset(this.text, offset); + } + + offsetAt(position: vscode.Position): number { + if (this.textDocument) { + return this.textDocument.offsetAt(position); + } + return offsetAtPosition(this.text, position); + } + + lineAt(line: number): { text: string; range: vscode.Range } { + if (this.textDocument) { + const info = this.textDocument.lineAt(line); + return { text: info.text, range: info.range }; + } + const start = offsetAtPosition(this.text, new vscode.Position(line, 0)); + const text = this.getText().slice(start).split(/\r\n|\n|\r/, 1)[0] ?? ''; + const range = new vscode.Range( + new vscode.Position(line, 0), + new vscode.Position(line, text.length) + ); + return { text, range }; + } + + async save(destination?: vscode.Uri): Promise { + const target = destination ?? this.uri; + if (this.textDocument && target.toString() === this.uri.toString()) { + await this.textDocument.save(); + this.text = this.textDocument.getText(); + return; + } + + await vscode.workspace.fs.writeFile(target, Buffer.from(this.getText(), 'utf8')); + } + + async revert(): Promise { + if (this.uri.scheme !== 'file') { + return; + } + + const diskText = await readFileText(this.uri); + if (diskText === this.getText()) { + return; + } + + await this.replaceFull(diskText); + } + + async backup(destination: vscode.Uri): Promise { + await vscode.workspace.fs.writeFile(destination, Buffer.from(this.getText(), 'utf8')); + return { + id: destination.toString(), + delete: async () => { + try { + await vscode.workspace.fs.delete(destination); + } catch { + // Best-effort cleanup of a hot-exit backup. + } + } + }; + } + + async replaceFull(nextText: string): Promise { + if (nextText === this.getText()) { + return true; + } + + if (this.textDocument) { + const applied = await replaceWorkspaceDocumentText(this.textDocument, nextText); + if (applied) { + this.text = this.textDocument.getText(); + } + return applied; + } + + this.text = nextText; + this.localVersion += 1; + this.changeEmitter.fire(); + return true; + } + + async applyReplacements(replacements: Array<{ startOffset: number; endOffset: number; insert: string }>): Promise { + if (this.textDocument) { + const edit = new vscode.WorkspaceEdit(); + for (const replacement of replacements) { + edit.replace( + this.uri, + new vscode.Range( + this.textDocument.positionAt(replacement.startOffset), + this.textDocument.positionAt(replacement.endOffset) + ), + replacement.insert + ); + } + const applied = await vscode.workspace.applyEdit(edit); + if (applied) { + this.text = this.textDocument.getText(); + } + return applied; + } + + let nextText = this.text; + const sorted = [...replacements].sort((left, right) => right.startOffset - left.startOffset); + for (const replacement of sorted) { + nextText = `${nextText.slice(0, replacement.startOffset)}${replacement.insert}${nextText.slice(replacement.endOffset)}`; + } + this.text = nextText; + this.localVersion += 1; + this.changeEmitter.fire(); + return true; + } + + notifyExternalChange(): void { + if (this.textDocument) { + this.text = this.textDocument.getText(); + } + this.changeEmitter.fire(); + } + + dispose(): void { + this.changeEmitter.dispose(); + this.onDisposed?.(); + } +} + +export async function tryOpenWorkspaceTextDocument(uri: vscode.Uri): Promise { + try { + return await vscode.workspace.openTextDocument(uri); + } catch { + return undefined; + } +} + +async function readFileText(uri: vscode.Uri): Promise { + const bytes = await vscode.workspace.fs.readFile(uri); + return Buffer.from(bytes).toString('utf8'); +} + +async function replaceWorkspaceDocumentText(textDocument: vscode.TextDocument, nextText: string): Promise { + const currentText = textDocument.getText(); + if (currentText === nextText) { + return true; + } + + const edit = new vscode.WorkspaceEdit(); + edit.replace( + textDocument.uri, + new vscode.Range(textDocument.positionAt(0), textDocument.positionAt(currentText.length)), + nextText + ); + return vscode.workspace.applyEdit(edit); +} + +function lineCountOf(text: string): number { + if (!text) { + return 1; + } + let lines = 1; + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + if (code === 13) { + lines += 1; + if (text.charCodeAt(index + 1) === 10) { + index += 1; + } + continue; + } + if (code === 10) { + lines += 1; + } + } + return lines; +} + +function positionAtOffset(text: string, offset: number): vscode.Position { + const end = Math.max(0, Math.min(offset, text.length)); + let line = 0; + let character = 0; + let index = 0; + while (index < end) { + const code = text.charCodeAt(index); + if (code === 13) { + index += text.charCodeAt(index + 1) === 10 ? 2 : 1; + line += 1; + character = 0; + continue; + } + if (code === 10) { + index += 1; + line += 1; + character = 0; + continue; + } + index += 1; + character += 1; + } + return new vscode.Position(line, character); +} + +function offsetAtPosition(text: string, position: vscode.Position): number { + const targetLine = Math.max(0, position.line); + const targetCharacter = Math.max(0, position.character); + let line = 0; + let index = 0; + while (index < text.length && line < targetLine) { + const code = text.charCodeAt(index); + if (code === 13) { + index += text.charCodeAt(index + 1) === 10 ? 2 : 1; + line += 1; + continue; + } + if (code === 10) { + index += 1; + line += 1; + continue; + } + index += 1; + } + + let character = 0; + while (index < text.length && character < targetCharacter) { + const code = text.charCodeAt(index); + if (code === 10 || code === 13) { + break; + } + index += 1; + character += 1; + } + return index; +} diff --git a/src/extension/panelSession.ts b/src/extension/panelSession.ts index 7864e37..416cc09 100644 --- a/src/extension/panelSession.ts +++ b/src/extension/panelSession.ts @@ -1,6 +1,7 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; import type { AgentReviewHandoffController } from '../agents/reviewHandoff'; +import type { MarkdownCustomDocument } from './markdownCustomDocument'; import { EXTENSION_CONFIG_SECTION, LINE_NUMBERS_SETTING_KEY, @@ -385,7 +386,7 @@ const EMPTY_GIT_BASELINE_PAYLOAD: GitBaselinePayload = Object.freeze({ type PanelSessionControllerParams = { panel: vscode.WebviewPanel; - document: vscode.TextDocument; + document: MarkdownCustomDocument; documentUri: vscode.Uri; context: vscode.ExtensionContext; spellDiagnosticCollection: vscode.DiagnosticCollection; @@ -401,7 +402,7 @@ type PanelSessionControllerParams = { export type PanelSession = { panel: vscode.WebviewPanel; - document: vscode.TextDocument; + document: MarkdownCustomDocument; documentUri: vscode.Uri; gitDocumentState: GitDocumentState; getMode: () => EditorMode; @@ -507,11 +508,8 @@ export function createPanelSessionController(params: PanelSessionControllerParam 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); - const applied = await vscode.workspace.applyEdit(edit); + const applied = await document.replaceFull(draftText); if (applied) { pendingDraftText = null; } @@ -604,7 +602,9 @@ export function createPanelSessionController(params: PanelSessionControllerParam const runSpellCheck = async (generation: number): Promise => { try { - const diagnostics = await collectMeoSpellDiagnostics(document); + const diagnostics = document.textDocument + ? await collectMeoSpellDiagnostics(document.textDocument) + : []; if (disposed || generation !== spellCheckGeneration) { return; } @@ -1238,6 +1238,19 @@ export function createPanelSessionController(params: PanelSessionControllerParam }), 'sendDocChanged'); }); + const localChangeSubscription = document.onDidChangeContent(() => { + if (document.isSynchronized) { + return; + } + scheduleSpellCheck(); + if (isApplyingOwnChange) { + return; + } + runBackground(enqueue(async () => { + await sendDocChanged(); + }), 'sendDocChanged'); + }); + const documentSaveSubscription = vscode.workspace.onDidSaveTextDocument((savedDocument) => { if (savedDocument.uri.toString() !== documentKey) { return; @@ -1249,7 +1262,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam if (!event.uris.some((uri) => uri.toString() === documentKey)) { return; } - if (hasExternalSpellDiagnostics(document)) { + if (document.textDocument && hasExternalSpellDiagnostics(document.textDocument)) { spellCheckGeneration += 1; if (pendingSpellCheckTimer !== null) { clearTimeout(pendingSpellCheckTimer); @@ -1324,6 +1337,7 @@ export function createPanelSessionController(params: PanelSessionControllerParam rejectPendingExportSnapshots(new Error('The editor was closed before export completed.')); messageSubscription.dispose(); documentChangeSubscription.dispose(); + localChangeSubscription.dispose(); documentSaveSubscription.dispose(); diagnosticsSubscription.dispose(); textEditorSelectionSubscription.dispose(); @@ -1414,7 +1428,7 @@ function pruneRememberedViewPositionMap( } async function applyDocumentChanges( - document: vscode.TextDocument, + document: MarkdownCustomDocument, message: ApplyChangesMessage, sendDocChanged: () => Promise, sendApplied: (version: number) => Promise, @@ -1426,7 +1440,6 @@ async function applyDocumentChanges( return; } - const edit = new vscode.WorkspaceEdit(); const sortedChanges = [...message.changes].sort((a, b) => b.from - a.from); const documentText = document.getText(); const mappedOffsetCache = new Map(); @@ -1440,20 +1453,17 @@ async function applyDocumentChanges( return mapped; }; - for (const change of sortedChanges) { - // Webview offsets are LF-normalized; remap to real document offsets before applying edits. + const replacements = sortedChanges.map((change) => { const mappedFrom = mapOffset(change.from); const mappedTo = mapOffset(change.to); - const startOffset = Math.min(mappedFrom, mappedTo); - const endOffset = Math.max(mappedFrom, mappedTo); - const range = new vscode.Range( - document.positionAt(startOffset), - document.positionAt(endOffset) - ); - edit.replace(document.uri, range, change.insert); - } + return { + startOffset: Math.min(mappedFrom, mappedTo), + endOffset: Math.max(mappedFrom, mappedTo), + insert: change.insert + }; + }); - const applied = await vscode.workspace.applyEdit(edit); + const applied = await document.applyReplacements(replacements); if (!applied) { await sendAppliedFailed(); @@ -1546,7 +1556,7 @@ function clampDiagnosticRange(from: number, to: number, textLength: number): { f return { from: clampedFrom, to: clampedTo }; } -function serializeDiagnostics(document: vscode.TextDocument): SerializedDiagnostic[] { +function serializeDiagnostics(document: MarkdownCustomDocument): SerializedDiagnostic[] { const diagnostics = vscode.languages.getDiagnostics(document.uri); if (!diagnostics.length) { return []; @@ -1579,7 +1589,7 @@ function serializeDiagnostics(document: vscode.TextDocument): SerializedDiagnost } async function resolveDiagnosticSuggestions( - document: vscode.TextDocument, + document: MarkdownCustomDocument, request: RequestDiagnosticSuggestionsMessage ): Promise { const emptyResponse: DiagnosticSuggestionsResultMessage = { @@ -1590,6 +1600,10 @@ async function resolveDiagnosticSuggestions( suggestions: [] }; + if (!document.textDocument) { + return emptyResponse; + } + const documentText = document.getText(); const normalizedTextLength = documentText.replace(/\r\n?/g, '\n').length; const requestedRange = clampDiagnosticRange(request.from, request.to, normalizedTextLength); @@ -1633,7 +1647,9 @@ async function resolveDiagnosticSuggestions( } if (suggestions.length === 0 && request.source === MEO_SPELL_DIAGNOSTIC_SOURCE) { - const spellSuggestions = await collectMeoSpellSuggestions(document, requestedRange.from, requestedRange.to); + const spellSuggestions = document.textDocument + ? await collectMeoSpellSuggestions(document.textDocument, requestedRange.from, requestedRange.to) + : []; for (const suggestion of spellSuggestions) { if (seen.has(suggestion)) { continue; @@ -1653,7 +1669,7 @@ async function resolveDiagnosticSuggestions( } function hasMatchingDiagnostic( - document: vscode.TextDocument, + document: MarkdownCustomDocument, request: RequestDiagnosticSuggestionsMessage, requestedRange: { from: number; to: number } ): boolean { @@ -1676,7 +1692,7 @@ function hasMatchingDiagnostic( } function simpleReplacementFromCodeAction( - document: vscode.TextDocument, + document: MarkdownCustomDocument, requestedRange: { from: number; to: number }, action: vscode.Command | vscode.CodeAction ): string | null { diff --git a/src/shared/extensionConfig.ts b/src/shared/extensionConfig.ts index 92dae00..6adf7dc 100644 --- a/src/shared/extensionConfig.ts +++ b/src/shared/extensionConfig.ts @@ -399,7 +399,15 @@ async function syncEditorAssociationsForTarget( 'chat-editing-text-model:**/*.md': 'default', 'chat-editing-text-model:**/*.markdown': 'default', 'chat-editing-text-model:**/*.mdx': 'default', - 'chat-editing-text-model:**/*.mdc': 'default' + 'chat-editing-text-model:**/*.mdc': 'default', + 'chat-editing-snapshot-text-model:/**/*.md': 'default', + 'chat-editing-snapshot-text-model:/**/*.markdown': 'default', + 'chat-editing-snapshot-text-model:/**/*.mdx': 'default', + 'chat-editing-snapshot-text-model:/**/*.mdc': 'default', + 'chat-editing-snapshot-text-model:**/*.md': 'default', + 'chat-editing-snapshot-text-model:**/*.markdown': 'default', + 'chat-editing-snapshot-text-model:**/*.mdx': 'default', + 'chat-editing-snapshot-text-model:**/*.mdc': 'default' }; await config.update('editorAssociations', markdownAssociations, target);