Skip to content

Commit 15e68cf

Browse files
ntottenclaude
andcommitted
Use formatWithCursor() to preserve cursor position after formatting
Previously, the extension used Prettier's format() API which caused the cursor to jump to an arbitrary position after formatting. This change uses Prettier's formatWithCursor() API to preserve the cursor position. Changes: - Add formatWithCursor to PrettierInstance and PrettierModule interfaces - Implement formatWithCursor in PrettierDynamicInstance - Add formatWithCursor to ModuleResolverWeb for browser support - Update PrettierEditService to use formatWithCursor when cursor position is available, and schedule cursor repositioning after VS Code applies the edit Fixes #3939 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 61b4780 commit 15e68cf

File tree

5 files changed

+130
-8
lines changed

5 files changed

+130
-8
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ All notable changes to the "prettier-vscode" extension will be documented in thi
66

77
## [Unreleased]
88

9+
- Preserve cursor position after formatting using Prettier's `formatWithCursor()` API (#3939)
10+
911
## [12.2.0]
1012

1113
- Fixed `source.fixAll.prettier` code action running even when `editor.defaultFormatter` was set to a different extension (#3908). The code action now respects the user's formatter choice and only runs when Prettier is the default formatter or when `source.fixAll.prettier` is explicitly enabled.

src/ModuleResolverWeb.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
PrettierCursorOptions,
3+
PrettierCursorResult,
24
PrettierFileInfoOptions,
35
PrettierFileInfoResult,
46
PrettierSupportLanguage,
@@ -70,6 +72,15 @@ export class ModuleResolver implements ModuleResolverInterface {
7072
format: async (source: string, options: PrettierOptions) => {
7173
return prettierStandalone.format(source, { ...options, plugins });
7274
},
75+
formatWithCursor: async (
76+
source: string,
77+
options: PrettierCursorOptions,
78+
): Promise<PrettierCursorResult> => {
79+
return prettierStandalone.formatWithCursor(source, {
80+
...options,
81+
plugins,
82+
});
83+
},
7384
resolveConfigFile: async (): Promise<string | null> => {
7485
// Config file resolution not supported in browser
7586
return null;

src/PrettierDynamicInstance.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import * as path from "path";
22
import { pathToFileURL } from "url";
3-
import type { FileInfoOptions, Options, ResolveConfigOptions } from "prettier";
3+
import type {
4+
CursorOptions,
5+
CursorResult,
6+
FileInfoOptions,
7+
Options,
8+
ResolveConfigOptions,
9+
} from "prettier";
410
import type {
511
PrettierFileInfoResult,
612
PrettierPlugin,
@@ -62,6 +68,16 @@ export const PrettierDynamicInstance: PrettierInstanceConstructor = class Pretti
6268
return this.prettierModule!.format(source, options);
6369
}
6470

71+
public async formatWithCursor(
72+
source: string,
73+
options: CursorOptions,
74+
): Promise<CursorResult> {
75+
if (!this.prettierModule) {
76+
await this.import();
77+
}
78+
return this.prettierModule!.formatWithCursor(source, options);
79+
}
80+
6581
public async getFileInfo(
6682
filePath: string,
6783
fileInfoOptions?: FileInfoOptions | undefined,

src/PrettierEditService.ts

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
DocumentFilter,
66
languages,
77
Range,
8+
Selection,
89
TextDocument,
910
TextEdit,
1011
TextEditor,
@@ -22,6 +23,8 @@ import {
2223
ExtensionFormattingOptions,
2324
ModuleResolverInterface,
2425
PrettierBuiltInParserName,
26+
PrettierCursorOptions,
27+
PrettierCursorResult,
2528
PrettierFileInfoResult,
2629
PrettierInstance,
2730
PrettierModule,
@@ -104,6 +107,11 @@ interface ISelectors {
104107
languageSelector: ReadonlyArray<DocumentFilter>;
105108
}
106109

110+
interface FormatResult {
111+
formatted: string;
112+
cursorOffset: number;
113+
}
114+
107115
export default class PrettierEditService implements Disposable {
108116
private formatterHandler: undefined | Disposable;
109117
private rangeFormatterHandler: undefined | Disposable;
@@ -448,21 +456,58 @@ export default class PrettierEditService implements Disposable {
448456
options: ExtensionFormattingOptions,
449457
): Promise<TextEdit[]> => {
450458
const startTime = new Date().getTime();
451-
const result = await this.format(document.getText(), document, options);
459+
460+
// Get cursor offset from active editor if available and not doing range formatting
461+
const editor = window.activeTextEditor;
462+
const isRangeFormatting =
463+
options.rangeStart !== undefined && options.rangeEnd !== undefined;
464+
let cursorOffset: number | undefined;
465+
466+
if (editor && editor.document === document && !isRangeFormatting) {
467+
cursorOffset = document.offsetAt(editor.selection.active);
468+
}
469+
470+
const result = await this.format(
471+
document.getText(),
472+
document,
473+
options,
474+
cursorOffset,
475+
);
452476
if (!result) {
453477
// No edits happened, return never so VS Code can try other formatters
454478
return [];
455479
}
456480
const duration = new Date().getTime() - startTime;
457481
this.loggingService.logInfo(`Formatting completed in ${duration}ms.`);
458-
const edit = this.minimalEdit(document, result);
482+
const edit = this.minimalEdit(document, result.formatted);
459483
if (!edit) {
460484
// Document is already formatted, no changes needed
461485
this.loggingService.logDebug(
462486
"Document is already formatted, no changes needed.",
463487
);
464488
return [];
465489
}
490+
491+
// Schedule cursor repositioning after VS Code applies the edit
492+
// We use setImmediate to run after the current event loop completes
493+
if (
494+
editor &&
495+
editor.document === document &&
496+
!isRangeFormatting &&
497+
result.cursorOffset >= 0
498+
) {
499+
setImmediate(() => {
500+
// Verify the editor is still active and document hasn't changed
501+
if (
502+
window.activeTextEditor === editor &&
503+
editor.document === document
504+
) {
505+
const newPosition = document.positionAt(result.cursorOffset);
506+
editor.selection = new Selection(newPosition, newPosition);
507+
}
508+
});
509+
}
510+
466511
return [edit];
467512
};
468513

@@ -505,14 +550,17 @@ export default class PrettierEditService implements Disposable {
505550
/**
506551
* Format the given text with user's configuration.
507552
* @param text Text to format
508-
* @param path formatting file's path
509-
* @returns {string} formatted text
553+
* @param doc TextDocument being formatted
554+
* @param options Formatting options
555+
* @param cursorOffset Optional cursor offset for cursor preservation
556+
* @returns FormatResult with formatted text and new cursor offset, or undefined if formatting failed
510557
*/
511558
private async format(
512559
text: string,
513560
doc: TextDocument,
514561
options: ExtensionFormattingOptions,
515-
): Promise<string | undefined> {
562+
cursorOffset?: number,
563+
): Promise<FormatResult | undefined> {
516564
const { fileName, uri, languageId } = doc;
517565

518566
this.loggingService.logInfo(`Formatting ${uri}`);
@@ -631,18 +679,53 @@ export default class PrettierEditService implements Disposable {
631679
this.loggingService.logInfo("Prettier Options:", prettierOptions);
632680

633681
try {
682+
// Use formatWithCursor if we have a cursor offset to preserve cursor position
683+
if (cursorOffset !== undefined) {
684+
const cursorOptions: PrettierCursorOptions = {
685+
...prettierOptions,
686+
cursorOffset,
687+
};
688+
689+
// Check if formatWithCursor is available (it should be for all modern Prettier versions)
690+
if ("formatWithCursor" in prettierInstance) {
691+
try {
692+
const result: PrettierCursorResult =
693+
await prettierInstance.formatWithCursor(text, cursorOptions);
694+
this.statusBar.update(FormatterStatus.Success);
695+
return {
696+
formatted: result.formatted,
697+
cursorOffset: result.cursorOffset,
698+
};
699+
} catch (cursorError) {
700+
// formatWithCursor can fail with some plugins that don't implement locStart/locEnd
701+
// Fall back to regular format() in this case
702+
this.loggingService.logDebug(
703+
"formatWithCursor failed, falling back to format()",
704+
cursorError,
705+
);
706+
}
707+
}
708+
}
709+
710+
// Fallback to regular format() if no cursor offset or formatWithCursor not available/failed
634711
const formattedText = await prettierInstance.format(
635712
text,
636713
prettierOptions,
637714
);
638715
this.statusBar.update(FormatterStatus.Success);
639716

640-
return formattedText;
717+
return {
718+
formatted: formattedText,
719+
cursorOffset: -1, // Indicate that cursor position is unknown
720+
};
641721
} catch (error) {
642722
this.loggingService.logError("Error formatting document.", error);
643723
this.statusBar.update(FormatterStatus.Error);
644724

645-
return text;
725+
return {
726+
formatted: text,
727+
cursorOffset: cursorOffset ?? -1,
728+
};
646729
}
647730
}
648731

src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,19 @@ export type PrettierBuiltInParserName = prettier.BuiltInParserName;
88
export type PrettierResolveConfigOptions = prettier.ResolveConfigOptions;
99
export type PrettierOptions = prettier.Options;
1010
export type PrettierFileInfoOptions = prettier.FileInfoOptions;
11+
export type PrettierCursorOptions = prettier.CursorOptions;
12+
export type PrettierCursorResult = prettier.CursorResult;
1113

1214
export type PrettierPlugin = prettier.Plugin<any> | string | URL;
1315

1416
export interface PrettierInstance {
1517
version: string | null;
1618
import(): Promise<string>;
1719
format(source: string, options?: PrettierOptions): Promise<string>;
20+
formatWithCursor(
21+
source: string,
22+
options: PrettierCursorOptions,
23+
): Promise<PrettierCursorResult>;
1824
getFileInfo(
1925
filePath: string,
2026
fileInfoOptions?: PrettierFileInfoOptions,
@@ -41,6 +47,10 @@ export interface PrettierInstanceConstructor {
4147
export type PrettierModule = {
4248
version: string;
4349
format(source: string, options?: PrettierOptions): Promise<string>;
50+
formatWithCursor(
51+
source: string,
52+
options: PrettierCursorOptions,
53+
): Promise<PrettierCursorResult>;
4454
getSupportInfo(options?: {
4555
plugins?: Array<string | PrettierPlugin>;
4656
}): Promise<{ languages: PrettierSupportLanguage[] }>;

0 commit comments

Comments
 (0)