Skip to content

Commit 85a5653

Browse files
committed
fix(editor): close save/reload races, formatter mtime, lsp format style and preset rebinding
1 parent 7b69f5d commit 85a5653

6 files changed

Lines changed: 53 additions & 24 deletions

File tree

src/app/hooks/useAppCloseGuard.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ export function useAppCloseGuard(tabsRef: RefObject<Tab[]>) {
2929
.onCloseRequested(async (event) => {
3030
if (forceClose.current) return;
3131
event.preventDefault();
32+
const busyTerminal = await anyTerminalBusy(tabsRef.current);
33+
// Count after the await so edits made during the IPC check are seen.
3234
const dirtyEditors = tabsRef.current.filter(
3335
(t) => t.kind === "editor" && t.dirty,
3436
).length;
35-
const busyTerminal = await anyTerminalBusy(tabsRef.current);
3637
if (dirtyEditors > 0 || busyTerminal) {
3738
setPendingAppClose({ dirtyEditors, busyTerminal });
3839
} else {

src/modules/editor/EditorPane.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,14 @@ export const EditorPane = memo(
185185
const formatter = resolveFormatter(languageRef.current, prefs);
186186
if (prefs.editorFormatOnSave && formatter === "lsp" && view) {
187187
if (lspActiveRef.current) {
188-
const res = await lspFormatDocument(view).catch(
189-
() => "done" as const,
190-
);
188+
let res: "done" | "unsupported" = "done";
189+
try {
190+
res = await lspFormatDocument(view);
191+
} catch (e) {
192+
toast.error("Language server format failed", {
193+
description: String(e),
194+
});
195+
}
191196
if (res === "unsupported" && !warnedNoFormatRef.current) {
192197
warnedNoFormatRef.current = true;
193198
toast.warning("Format on save skipped", {
@@ -217,9 +222,12 @@ export const EditorPane = memo(
217222
if (error) {
218223
toast.error(`${formatter} format failed`, { description: error });
219224
} else {
220-
const text = await readFileText(pathRef.current);
221-
if (text !== null && view && view.state.doc === docAtSave) {
222-
applyFormattedContent(view, adoptDiskTextRef.current(text));
225+
const readBack = await readFileText(pathRef.current);
226+
if (readBack !== null && view && view.state.doc === docAtSave) {
227+
applyFormattedContent(
228+
view,
229+
adoptDiskTextRef.current(readBack.text, readBack.mtime),
230+
);
223231
}
224232
}
225233
}

src/modules/editor/lib/externalFormat.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { currentWorkspaceEnv } from "@/modules/workspace";
44
import type { EditorView } from "@codemirror/view";
55
import { invoke } from "@tauri-apps/api/core";
66

7-
type ReadResult = { kind: string; content?: string };
7+
type ReadResult = { kind: string; content?: string; mtime?: number };
88

99
type CommandOutput = {
1010
stdout: string;
@@ -141,12 +141,15 @@ export async function runExternalFormatter(
141141
}
142142
}
143143

144-
export async function readFileText(path: string): Promise<string | null> {
144+
export async function readFileText(
145+
path: string,
146+
): Promise<{ text: string; mtime: number } | null> {
145147
const res = await invoke<ReadResult>("fs_read_file", {
146148
path,
147149
workspace: currentWorkspaceEnv(),
148150
}).catch(() => null);
149-
return res?.kind === "text" ? (res.content ?? null) : null;
151+
if (res?.kind !== "text" || res.content == null) return null;
152+
return { text: res.content, mtime: res.mtime ?? 0 };
150153
}
151154

152155
// Minimal change dispatch: trimming the common prefix/suffix keeps the

src/modules/editor/lib/useDocument.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ export function useDocument({ path, onDirtyChange }: Options) {
6868
});
6969
diskMtimeRef.current = mtime;
7070
savedRef.current = content;
71-
setDirty(false);
71+
// Edits typed while the write was in flight must stay dirty.
72+
setDirty(bufferRef.current !== content);
7273
notifyDocumentSaved(path);
7374
}, [path]);
7475

@@ -139,6 +140,8 @@ export function useDocument({ path, onDirtyChange }: Options) {
139140
// Load on path change.
140141
useEffect(() => {
141142
let cancelled = false;
143+
// "Open anyway" is a per-file decision; a new path starts unforced.
144+
forceRef.current = false;
142145
setDoc({ status: "loading" });
143146
setDirty(false);
144147

@@ -163,11 +166,14 @@ export function useDocument({ path, onDirtyChange }: Options) {
163166
.catch((e) => setDoc({ status: "error", message: String(e) }));
164167
}, [readFromDisk, adoptRead]);
165168

166-
// Skipped while dirty: never clobber unsaved edits.
169+
// Skipped while dirty: never clobber unsaved edits. Re-checked when the
170+
// read resolves, since typing can start while it is in flight.
167171
const reload = useCallback((): boolean => {
168172
if (dirtyRef.current) return false;
169173
void readFromDisk(forceRef.current)
170-
.then((res) => adoptRead(res, true))
174+
.then((res) => {
175+
if (!dirtyRef.current) adoptRead(res, true);
176+
})
171177
// Transient failures (e.g. ENOENT mid atomic-rename) must not replace
172178
// a healthy buffer with an error screen.
173179
.catch((e) => console.warn("[editor] reload failed", path, e));
@@ -181,15 +187,21 @@ export function useDocument({ path, onDirtyChange }: Options) {
181187
}, [clearAutoSaveTimer, saveNow]);
182188

183189
// Adopt externally formatted disk content as the saved baseline before the
184-
// matching editor dispatch lands, so the buffer never flashes dirty.
190+
// matching editor dispatch lands, so the buffer never flashes dirty. The
191+
// formatter's own write must also become the known mtime, or the next save
192+
// would report it as an external conflict.
185193
// Returns the LF-normalized text the caller should dispatch.
186-
const adoptDiskText = useCallback((diskText: string): string => {
187-
eolRef.current = detectEol(diskText);
188-
const content = normalizeToLf(diskText);
189-
savedRef.current = content;
190-
setDirty(bufferRef.current !== content);
191-
return content;
192-
}, []);
194+
const adoptDiskText = useCallback(
195+
(diskText: string, mtime: number): string => {
196+
eolRef.current = detectEol(diskText);
197+
diskMtimeRef.current = mtime;
198+
const content = normalizeToLf(diskText);
199+
savedRef.current = content;
200+
setDirty(bufferRef.current !== content);
201+
return content;
202+
},
203+
[],
204+
);
193205

194206
const onChange = useCallback(
195207
(next: string) => {

src/modules/lsp/lib/client.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { highlightingFor, language } from "@codemirror/language";
1+
import { highlightingFor, indentUnit, language } from "@codemirror/language";
22
import {
33
type Extension,
44
StateEffect,
@@ -87,7 +87,10 @@ export async function formatDocumentAndWait(
8787
const doc = view.state.doc;
8888
const edits = await client.textDocumentFormatting({
8989
textDocument: { uri: plugin.documentUri },
90-
options: { tabSize: view.state.tabSize, insertSpaces: true },
90+
options: {
91+
tabSize: view.state.tabSize,
92+
insertSpaces: view.state.facet(indentUnit) !== "\t",
93+
},
9194
});
9295
if (!edits || edits.length === 0) return "done";
9396
// Edits are offsets into the requested snapshot; typing during the

src/modules/lsp/lib/useLspExtension.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export function useLspExtension(
1919
preset ? (s.generations[preset.id] ?? 0) : 0,
2020
);
2121

22+
const presetId = preset?.id;
2223
// biome-ignore lint/correctness/useExhaustiveDependencies(generation): re-acquire after a server crash tears the session down
24+
// biome-ignore lint/correctness/useExhaustiveDependencies(presetId): swapping the enabled server for a language must rebind the doc
2325
useEffect(() => {
2426
if (!ready || !langId || activation !== "enabled") {
2527
setExt(null);
@@ -43,7 +45,7 @@ export function useLspExtension(
4345
handle?.release();
4446
setExt(null);
4547
};
46-
}, [path, langId, ready, activation, generation]);
48+
}, [path, langId, ready, activation, generation, presetId]);
4749

4850
return ext;
4951
}

0 commit comments

Comments
 (0)