fix: Cursor file editor associations on AI pending edits

This commit is contained in:
Vadim Melnicuk
2026-09-11 09:31:42 +01:00
parent 8c366e1362
commit 43343168c8
9 changed files with 726 additions and 69 deletions
+9 -1
View File
@@ -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": {
+174
View File
@@ -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<string>();
private readonly inFlight = new Map<string, Promise<void>>();
private primingDepth = 0;
constructor(private readonly deps: AgentReviewDocumentPrimerDeps) {}
get isPriming(): boolean {
return this.primingDepth > 0;
}
async ensureReady(uri: vscode.Uri, options?: { forceNative?: boolean }): Promise<void> {
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<void> {
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<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+21
View File
@@ -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<string>();
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);
+18 -9
View File
@@ -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<typeof setTimeout> | null = null;
private readonly pinnedOverrideKeys = new Set<string>();
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<string> {
const keys = new Set<string>();
const keys = new Set<string>(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<string>([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(
+4 -1
View File
@@ -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<string>(AGENT_REVIEW_MODEL_SCHEMES);
export type LikelyAgentReviewState = {
+156 -31
View File
@@ -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<MarkdownCustomDocument> {
private readonly activePanels = new Set<vscode.WebviewPanel>();
private readonly panelSessions = new Map<vscode.WebviewPanel, PanelSession>();
private readonly customDocuments = new Map<string, MarkdownCustomDocument>();
private readonly spellDiagnosticCollection = vscode.languages.createDiagnosticCollection('meo-spell');
private readonly customDocumentChangeEmitter = new vscode.EventEmitter<
vscode.CustomDocumentContentChangeEvent<MarkdownCustomDocument>
>();
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<MarkdownCustomDocument> {
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<void> {
await this.resolveMarkdownEditor(document, panel, token);
}
async saveCustomDocument(document: MarkdownCustomDocument): Promise<void> {
await document.save();
}
async saveCustomDocumentAs(document: MarkdownCustomDocument, destination: vscode.Uri): Promise<void> {
await document.save(destination);
}
async revertCustomDocument(document: MarkdownCustomDocument): Promise<void> {
await document.revert();
}
async backupCustomDocument(
document: MarkdownCustomDocument,
context: vscode.CustomDocumentBackupContext,
_token: vscode.CancellationToken
): Promise<vscode.CustomDocumentBackup> {
return document.backup(context.destination);
}
async initializeGitWatcher(): Promise<void> {
@@ -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<void> {
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<boolean> {
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<void> {
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 {}
+293
View File
@@ -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<void>();
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<MarkdownCustomDocument> {
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<void> {
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<void> {
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<vscode.CustomDocumentBackup> {
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<boolean> {
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<boolean> {
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<vscode.TextDocument | undefined> {
try {
return await vscode.workspace.openTextDocument(uri);
} catch {
return undefined;
}
}
async function readFileText(uri: vscode.Uri): Promise<string> {
const bytes = await vscode.workspace.fs.readFile(uri);
return Buffer.from(bytes).toString('utf8');
}
async function replaceWorkspaceDocumentText(textDocument: vscode.TextDocument, nextText: string): Promise<boolean> {
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;
}
+42 -26
View File
@@ -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<void> => {
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<boolean>,
sendApplied: (version: number) => Promise<boolean>,
@@ -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<number, number>();
@@ -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<DiagnosticSuggestionsResultMessage> {
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 {
+9 -1
View File
@@ -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);