Skip to content

Commit fedf1e1

Browse files
authored
fix: race condition when moving to exact line/col (#2856)
* fix: race condition when moving to exact line/col * fix(search): cancel obsolete result navigation * fix(search): preserve loader during concurrent file opens
1 parent edb99ab commit fedf1e1

5 files changed

Lines changed: 560 additions & 22 deletions

File tree

‎src/lib/openFile.js‎

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import fileTypeHandler from "./fileTypeHandler";
1313
import recents from "./recents";
1414
import appSettings from "./settings";
1515

16+
let loadingFileCount = 0;
17+
1618
/**
1719
* @typedef {object} FileOptions
1820
* @property {string} text
@@ -24,6 +26,7 @@ import appSettings from "./settings";
2426
* @property {string} uri
2527
* @property {string} paneId
2628
* @property {boolean} persistInSession
29+
* @property {AbortSignal} signal Discard an obsolete open before activating its file.
2730
*/
2831

2932
/**
@@ -33,6 +36,9 @@ import appSettings from "./settings";
3336
*/
3437

3538
export default async function openFile(file, options = {}) {
39+
const { signal } = options;
40+
if (signal?.aborted) return;
41+
let releaseTitleLoader;
3642
try {
3743
let uri = typeof file === "string" ? file : file.uri;
3844
if (!uri) return;
@@ -112,13 +118,15 @@ export default async function openFile(file, options = {}) {
112118
return;
113119
}
114120

115-
loader.showTitleLoader();
121+
releaseTitleLoader = acquireTitleLoader(signal);
116122
const settings = appSettings.value;
117123
const fs = fsOperation(uri);
118124
const fileInfo = await fs.stat();
125+
if (signal?.aborted) return;
119126
const name = fileInfo.name || file.filename || uri;
120127
const readOnly = fileInfo.canWrite === false;
121128
const createEditor = (isUnsaved, text, detectedEncoding) => {
129+
if (signal?.aborted) return;
122130
new EditorFile(name, {
123131
uri,
124132
text,
@@ -152,10 +160,12 @@ export default async function openFile(file, options = {}) {
152160
encoding,
153161
mode,
154162
createEditor,
163+
signal,
155164
},
156165
});
157166
return;
158167
} catch (error) {
168+
if (signal?.aborted) return;
159169
console.error(`File handler '${customHandler.id}' failed:`, error);
160170
// Continue with default handling if custom handler fails
161171
}
@@ -173,6 +183,10 @@ export default async function openFile(file, options = {}) {
173183

174184
if (videoRegex.test(name)) {
175185
const objectUrl = await fileToDataUrl(uri);
186+
if (signal?.aborted) {
187+
URL.revokeObjectURL(objectUrl);
188+
return;
189+
}
176190
const videoContainer = (
177191
<div
178192
style={{
@@ -212,6 +226,10 @@ export default async function openFile(file, options = {}) {
212226

213227
if (imageRegex.test(name)) {
214228
const objectUrl = await fileToDataUrl(uri);
229+
if (signal?.aborted) {
230+
URL.revokeObjectURL(objectUrl);
231+
return;
232+
}
215233
const imageContainer = (
216234
<div
217235
className="image-container"
@@ -381,6 +399,10 @@ export default async function openFile(file, options = {}) {
381399

382400
if (audioRegex.test(name)) {
383401
const objectUrl = await fileToDataUrl(uri);
402+
if (signal?.aborted) {
403+
URL.revokeObjectURL(objectUrl);
404+
return;
405+
}
384406
const audioContainer = (
385407
<div
386408
style={{
@@ -425,10 +447,11 @@ export default async function openFile(file, options = {}) {
425447

426448
if (helpers.isBinary(uri)) {
427449
const confirmation = await confirm(strings.info, strings["binary file"]);
428-
if (!confirmation) return;
450+
if (!confirmation || signal?.aborted) return;
429451
}
430452

431453
const binData = await fs.readFile();
454+
if (signal?.aborted) return;
432455

433456
// Determine encoding: if explicit provided use it, otherwise
434457
// if settings.defaultFileEncoding === 'auto' then detect; else use the default as-is
@@ -448,18 +471,36 @@ export default async function openFile(file, options = {}) {
448471
}
449472
}
450473

474+
if (signal?.aborted) return;
451475
const fileContent = await decode(binData, detectedEncoding);
476+
if (signal?.aborted) return;
452477

453478
createEditor(false, fileContent, detectedEncoding);
454479
if (mode !== "single") recents.addFile(uri);
455480
return;
456481
} catch (error) {
457-
console.error(error);
482+
if (!signal?.aborted) console.error(error);
458483
} finally {
459-
loader.removeTitleLoader();
484+
releaseTitleLoader?.();
460485
}
461486
}
462487

488+
/** Keep the shared indicator visible while any file open still needs it. */
489+
function acquireTitleLoader(signal) {
490+
if (loadingFileCount++ === 0) loader.showTitleLoader();
491+
let released = false;
492+
const release = () => {
493+
// An aborted filesystem call may settle much later. Release immediately
494+
// on abort, and make its eventual finally block a no-op.
495+
if (released) return;
496+
released = true;
497+
signal?.removeEventListener("abort", release);
498+
if (--loadingFileCount === 0) loader.removeTitleLoader();
499+
};
500+
signal?.addEventListener("abort", release, { once: true });
501+
return release;
502+
}
503+
463504
/**
464505
* Converts file to data url
465506
* @param {string} file file url

‎src/sidebarApps/searchInFiles/index.js‎

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import "./styles.scss";
22
import fsOperation from "fileSystem";
3-
import { EditorView } from "@codemirror/view";
43
import autosize from "autosize";
54
import { getDocText } from "cm/editorUtils";
65
import Checkbox from "components/checkbox";
@@ -15,6 +14,7 @@ import { addedFolder } from "lib/openFolder";
1514
import settings from "lib/settings";
1615
import helpers from "utils/helpers";
1716
import { createSearchResultView } from "./cmResultView";
17+
import navigateToResult from "./navigateToResult";
1818

1919
// Local highlight sources
2020
const words = [];
@@ -1121,27 +1121,13 @@ async function onCursorChange(line) {
11211121
const result = results[line];
11221122
if (!result) return;
11231123
const { file, position } = result;
1124-
if (!position) {
1125-
// header line clicked; CM view folding not implemented yet
1126-
return;
1127-
}
1124+
const url = filesSearched[file]?.url;
1125+
if (!position || !url) return;
11281126

11291127
rememberResultScroll();
11301128
Sidebar.hide();
1131-
const { url } = filesSearched[file];
1132-
await openFile(url, { render: true });
1133-
const { editor } = editorManager;
11341129
try {
1135-
// Compute offsets from row/column (rows from worker are 0-based)
1136-
const doc = editor.state.doc;
1137-
const startLine = doc.line(position.start.row + 1);
1138-
const endLine = doc.line(position.end.row + 1);
1139-
const from = Math.min(startLine.from + position.start.column, startLine.to);
1140-
const to = Math.min(endLine.from + position.end.column, endLine.to);
1141-
editor.dispatch({
1142-
selection: { anchor: from, head: to },
1143-
effects: EditorView.scrollIntoView(from, { y: "center" }),
1144-
});
1130+
await navigateToResult(url, position);
11451131
} catch (error) {
11461132
console.warn(`Failed to focus search result at line ${line}.`, error);
11471133
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import openFile from "lib/openFile";
2+
3+
let currentNavigation;
4+
5+
/** Open a search match and reveal its zero-based row/column range. */
6+
export default async function navigateToResult(url, position) {
7+
currentNavigation?.abort();
8+
const navigation = new AbortController();
9+
currentNavigation = navigation;
10+
const { signal } = navigation;
11+
12+
try {
13+
// Start immediately even if an obsolete filesystem operation is stalled.
14+
// openFile checks the signal before creating/activating a late result.
15+
await openFile(url, { render: true, signal });
16+
const file = editorManager.getFile(url, "uri");
17+
if (
18+
signal.aborted ||
19+
file?.type !== "editor" ||
20+
editorManager.activeFile !== file
21+
) {
22+
return false;
23+
}
24+
25+
// load() reuses a restored tab's in-flight load. Do not cancel that shared
26+
// load; only discard this request's reveal if a newer result is selected.
27+
await file.load();
28+
if (
29+
signal.aborted ||
30+
!file.loaded ||
31+
file.loading ||
32+
editorManager.activeFile !== file ||
33+
editorManager.getFile(url, "uri") !== file
34+
) {
35+
return false;
36+
}
37+
38+
const doc = editorManager.editor.state.doc;
39+
const from = positionToOffset(doc, position.start);
40+
const to = positionToOffset(doc, position.end);
41+
// Cancel delayed tab scroll restoration and scrollbar locks before reveal.
42+
return editorManager.revealRange(from, to, {
43+
y: "center",
44+
userEvent: "select.search",
45+
});
46+
} catch (error) {
47+
if (signal.aborted) return false;
48+
throw error;
49+
} finally {
50+
if (currentNavigation === navigation) currentNavigation = undefined;
51+
}
52+
}
53+
54+
function positionToOffset(doc, { row, column }) {
55+
// Search results can outlive edits to the file. Clamp both coordinates so
56+
// an older result still navigates to the nearest available position.
57+
const line = doc.line(Math.max(1, Math.min(row + 1, doc.lines)));
58+
return line.from + Math.max(0, Math.min(column, line.length));
59+
}

0 commit comments

Comments
 (0)