fix(editor): speed up rapid file opening (#734)

Rapid double-clicks queued their opens one after another and an earlier open finishing late took the active tab and focus back. Each open now carries a sequence/activation generation: only the newest request activates and focuses, older ones still add their tab. Pending file probes are shared, document and syntax loading overlap, and repeated folder double-clicks are handled. A pending navigation selection survives a later plain open of any file; a newer selection still wins. Refs #724.

Co-authored-by: kkMihai <[email protected]>
Цей коміт міститься в:
kkmihaiтаkkMihai authored and GitHub committed 2026-11-02 10:38:02 +08:00
1 джерело c8cc69a56e
коміт 239753df9f
10 files changed
+627 -28

No files matched your search

+95 -21
Переглянути файл
@@ -171,6 +171,14 @@ function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: s
type EditorKinds = ReturnType<typeof useContributions.getState>["editorKinds"];
interface OpenRequest {
project: number;
admission: number;
sequence: number;
activation: number;
pane: string;
}
/** Tab title of an editor input: the file name, or what its editor kind calls it. */
export function tabTitle(input: EditorInput, kinds: EditorKinds): string {
if (input.kind === "file") return basename(input.path ?? input.key);
@@ -223,6 +231,10 @@ export class EditorController implements SlotHost {
private closeCommitted = false;
private projectGeneration = 0;
private admissionGeneration = 0;
private openSequence = 0;
private selectionRequests = new Map<string, number>();
private activationGeneration = 0;
private admitting = false;
private closeRequests = 0;
private projectTransition: Promise<void> = Promise.resolve();
@@ -246,6 +258,11 @@ export class EditorController implements SlotHost {
useTabs.subscribe((s) => {
const prev = prevTabs;
prevTabs = s;
if (
!this.admitting &&
(s.activePane !== prev.activePane || s.panes[s.activePane]?.active !== prev.panes[prev.activePane]?.active)
)
++this.activationGeneration;
this.stampActive(s);
this.onTabsChanged(prev, s);
}),
@@ -347,6 +364,7 @@ export class EditorController implements SlotHost {
this.preparedDocuments.clear();
for (const slot of this.slots.values()) slot.dispose();
this.slots.clear();
this.selectionRequests.clear();
this.manager.stop();
useTabs.setState(initialTabsState(), true);
void this.releaseDocuments(documents.values()).catch((e) => this.documentError(e));
@@ -361,6 +379,7 @@ export class EditorController implements SlotHost {
private onTabsChanged(prev: TabsState, next: TabsState) {
const live = new Set<string>();
for (const p of Object.values(next.panes)) for (const t of p.tabs) live.add(t.id);
for (const id of this.selectionRequests.keys()) if (!live.has(id)) this.selectionRequests.delete(id);
for (const [id, slot] of this.slots) {
if (!live.has(id)) {
this.slots.delete(id);
@@ -529,7 +548,9 @@ export class EditorController implements SlotHost {
if (active?.id === slot.tabId) this.setActiveSlot(slot);
if (slot.wantsFocus) {
slot.wantsFocus = false;
requestAnimationFrame(() => slot.focus());
requestAnimationFrame(() => {
if (!slot.disposed && this.activeTabOf()?.id === slot.tabId) slot.focus();
});
}
}
@@ -849,8 +870,15 @@ export class EditorController implements SlotHost {
// --- opening ---------------------------------------------------------------------------
private openingGeneration() {
const generation = { project: this.projectGeneration, admission: this.admissionGeneration };
private openingGeneration(o: OpenOptions): OpenRequest {
const s = useTabs.getState();
const generation = {
project: this.projectGeneration,
admission: this.admissionGeneration,
sequence: ++this.openSequence,
activation: this.activationGeneration,
pane: o.pane && s.panes[o.pane] ? o.pane : s.activePane,
};
this.checkOpening(generation);
return generation;
}
@@ -861,16 +889,28 @@ export class EditorController implements SlotHost {
throw new Error("The project is closing");
}
private isCurrentOpening(generation: OpenRequest) {
return (
generation.project === this.projectGeneration &&
generation.admission === this.admissionGeneration &&
generation.sequence === this.openSequence &&
generation.activation === this.activationGeneration
);
}
async openFile(path: string, o: OpenOptions = {}): Promise<void> {
const generation = this.openingGeneration();
if (isVirtualUri(path)) return this.openVirtual(path, o);
return this.openFileRequest(path, o, this.openingGeneration(o));
}
private async openFileRequest(path: string, o: OpenOptions, generation: OpenRequest): Promise<void> {
if (isVirtualUri(path)) return this.openVirtualRequest(path, o, generation);
if (isClassFile(path)) {
await this.projectTransition;
this.checkOpening(generation);
try {
const target = await call(commands.lspResolveClassFile(path));
this.checkOpening(generation);
return isVirtualUri(target) ? this.openVirtual(target, o) : this.openFile(target, o);
return this.openFileRequest(target, o, generation);
} catch (e) {
notifications().notify({ level: "warning", title: "Cannot open library class", body: String(e), group: "lsp" });
return;
@@ -895,7 +935,10 @@ export class EditorController implements SlotHost {
/** Opens the library class (or other server-provided document) behind `uri` read-only. */
async openVirtual(uri: string, o: OpenOptions = {}) {
const generation = this.openingGeneration();
return this.openVirtualRequest(uri, o, this.openingGeneration(o));
}
private async openVirtualRequest(uri: string, o: OpenOptions, generation: OpenRequest) {
await this.projectTransition;
this.checkOpening(generation);
let vd: VirtualDocument;
@@ -915,13 +958,13 @@ export class EditorController implements SlotHost {
async openInput(input: EditorInput, o: OpenOptions = {}) {
if (input.path && isClassFile(input.path)) return this.openFile(input.path, o);
return this.admitInput(input, o, this.openingGeneration());
return this.admitInput(input, o, this.openingGeneration(o));
}
private async admitInput(
input: EditorInput,
o: OpenOptions,
generation: { project: number; admission: number },
generation: OpenRequest,
resolution?: FileEditorResolution,
) {
await this.projectTransition;
@@ -931,7 +974,14 @@ export class EditorController implements SlotHost {
this.navSuppress++;
try {
let s = useTabs.getState();
let paneId = o.pane && s.panes[o.pane] ? o.pane : s.activePane;
const activate = this.isCurrentOpening(generation);
const preview =
!!o.preview &&
editorPrefs().tabs.previewTab &&
!documentLifecycle(input, useContributions.getState().editorKinds);
if (!activate && preview) return;
let paneId = s.panes[generation.pane] ? generation.pane : activate ? s.activePane : null;
if (!paneId) return;
if (o.side) {
const right = neighbourPane(s, paneId);
if (right) paneId = right;
@@ -942,34 +992,44 @@ export class EditorController implements SlotHost {
// The new pane starts with only the requested input.
const pane = s.panes[paneId]!;
s = { ...s, panes: { ...s.panes, [paneId]: { ...pane, tabs: [], active: null, mru: [] } } };
if (!activate) s = { ...s, activePane: useTabs.getState().activePane };
}
}
const found = findTab(s, input.key, paneId);
let tabId: string;
if (found && (!(o.side || o.pane) || found.paneId === paneId)) {
paneId = found.paneId;
s = activateTab(setActivePane(s, paneId), paneId, found.tab.id);
if (activate) s = activateTab(setActivePane(s, paneId), paneId, found.tab.id);
if (!o.preview || documentLifecycle(input, useContributions.getState().editorKinds))
s = promoteTab(s, paneId, found.tab.id);
tabId = found.tab.id;
if (found.tab.input.data !== input.data && input.data !== undefined)
if (activate && found.tab.input.data !== input.data && input.data !== undefined)
s = openTab(s, paneId, input, {
preview: o.preview && !documentLifecycle(input, useContributions.getState().editorKinds),
activate,
}).state;
} else {
const tabs = editorPrefs().tabs;
const preview =
!!o.preview && tabs.previewTab && !documentLifecycle(input, useContributions.getState().editorKinds);
const r = openTab(s, paneId, input, {
preview,
activate,
preservePreview: !activate,
...(tabs.openAtEnd ? { index: s.panes[paneId]?.tabs.length ?? 0 } : {}),
});
s = r.state;
tabId = r.tabId;
if (tabs.sortAlphabetically) s = sortTabs(s, paneId, (t) => tabSortKey(t.input));
}
resolution?.admit();
useTabs.setState(s, true);
if (o.selection)
this.selectionRequests.set(tabId, Math.max(generation.sequence, this.selectionRequests.get(tabId) ?? 0));
this.admitting = true;
try {
resolution?.admit();
useTabs.setState(s, true);
} finally {
this.admitting = false;
}
const canFocus = () => this.isCurrentOpening(generation) && this.activeTabOf()?.id === tabId;
const tabPrefs = editorPrefs().tabs;
await this.removeGuarded(
(current) =>
@@ -982,19 +1042,26 @@ export class EditorController implements SlotHost {
(tab) =>
inputDirty(tab.input, useContributions.getState().editorKinds) ??
!!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty),
tabId,
activate ? tabId : (current.panes[paneId]?.active ?? tabId),
(tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true,
)
: current,
{ protect: (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true },
);
if (!isTextInput(input)) {
if (o.focus !== false) requestAnimationFrame(() => this.focus());
if (o.focus !== false)
requestAnimationFrame(() => {
if (canFocus()) this.focus();
});
return;
}
const slot = await this.ensureSlot(tabId);
if (!slot) return;
if (o.selection) {
if (
o.selection &&
this.selectionRequests.get(tabId) === generation.sequence &&
generation.activation === this.activationGeneration
) {
const state = slot.currentState();
const toOffset = (line: number, col: number) => {
const l = state.doc.line(Math.min(Math.max(1, line + 1), state.doc.lines));
@@ -1006,7 +1073,7 @@ export class EditorController implements SlotHost {
// Selection runs anchor→head; place the caret at the start like IDEA's navigation.
slot.reveal({ anchor: head, head: anchor, center: true });
}
if (o.focus !== false) {
if (o.focus !== false && canFocus()) {
if (slot.view) slot.focus();
else slot.wantsFocus = true;
}
@@ -1017,12 +1084,19 @@ export class EditorController implements SlotHost {
}
activate(paneId: string, tabId: string, focus = true) {
++this.activationGeneration;
useTabs.setState((s) => activateTab(setActivePane(s, paneId), paneId, tabId), true);
if (focus) {
const slot = this.slots.get(tabId);
if (slot?.view) slot.focus();
else if (slot) slot.wantsFocus = true;
else void this.ensureSlot(tabId).then((sl) => sl && (sl.view ? sl.focus() : (sl.wantsFocus = true)));
else
void this.ensureSlot(tabId).then((sl) => {
if (sl && this.activeTabOf()?.id === tabId) {
if (sl.view) sl.focus();
else sl.wantsFocus = true;
}
});
}
}
+300
Переглянути файл
@@ -0,0 +1,300 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerFileEditorProvider } from "@/workbench/contributions";
import { EditorController, openFilePaths, useTabs } from "./controller";
import { createMemoryBackend } from "./backend";
import { initialTabsState } from "./model/tabs";
import { useProject } from "@/platform/project";
let ctrl: EditorController | null = null;
let dispose: (() => void) | undefined;
const initialSettings = useProject.getState().settings;
afterEach(() => {
dispose?.();
dispose = undefined;
ctrl?.stop();
ctrl = null;
useTabs.setState(initialTabsState(), true);
useProject.setState({ settings: initialSettings });
});
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
function activePath() {
const state = useTabs.getState();
const pane = state.panes[state.activePane]!;
return pane.tabs.find((tab) => tab.id === pane.active)?.input.path;
}
function setup() {
ctrl = new EditorController(createMemoryBackend({ "/p/a.ts": "a\nsecond", "/p/b.ts": "b", "/p/c.ts": "c" }));
ctrl.start();
return ctrl;
}
function delayProvider(path = "/p/a.ts") {
const started = deferred();
const response = deferred();
let delayed = false;
dispose = registerFileEditorProvider({
id: "delayed-file",
resolve: async (requested) => {
if (requested === path && !delayed) {
delayed = true;
started.resolve();
await response.promise;
}
return null;
},
});
return { started, response };
}
function tabFor(path: string) {
return Object.values(useTabs.getState().panes)
.flatMap((pane) => pane.tabs)
.find((tab) => tab.input.path === path);
}
describe("file open order", () => {
it.each([1, 30])("keeps the newer file active after an earlier probe (tab limit: %s)", async (tabLimit) => {
useProject.setState({ settings: { editor: { tabs: { tabLimit } } } as never });
const { started, response } = delayProvider();
const controller = setup();
const first = controller.openFile("/p/a.ts", { focus: false });
await started.promise;
await controller.openFile("/p/b.ts", { focus: false });
expect(activePath()).toBe("/p/b.ts");
response.resolve();
await first;
expect(openFilePaths(useTabs.getState()).sort()).toEqual(tabLimit === 1 ? ["/p/b.ts"] : ["/p/a.ts", "/p/b.ts"]);
expect(activePath()).toBe("/p/b.ts");
expect(controller.activeSlot?.model.path).toBe("/p/b.ts");
});
it("activates the newer file after an earlier probe finishes while both are pending", async () => {
const a = { started: deferred(), response: deferred() };
const b = { started: deferred(), response: deferred() };
dispose = registerFileEditorProvider({
id: "two-delayed-files",
resolve: async (path) => {
const pending = path === "/p/a.ts" ? a : b;
pending.started.resolve();
await pending.response.promise;
return null;
},
});
const controller = setup();
const first = controller.openFile("/p/a.ts", { focus: false });
await a.started.promise;
const second = controller.openFile("/p/b.ts", { focus: false });
await b.started.promise;
a.response.resolve();
await first;
b.response.resolve();
await second;
expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]);
expect(activePath()).toBe("/p/b.ts");
});
it("discards an earlier preview without replacing the newer preview", async () => {
const { started, response } = delayProvider();
const controller = setup();
const first = controller.openFile("/p/a.ts", { preview: true, focus: false });
await started.promise;
await controller.openFile("/p/b.ts", { preview: true, focus: false });
response.resolve();
await first;
expect(openFilePaths(useTabs.getState())).toEqual(["/p/b.ts"]);
expect(tabFor("/p/b.ts")?.preview).toBe(true);
expect(activePath()).toBe("/p/b.ts");
});
it("keeps an earlier regular file without removing the newer preview", async () => {
const { started, response } = delayProvider();
const controller = setup();
const first = controller.openFile("/p/a.ts", { focus: false });
await started.promise;
await controller.openFile("/p/b.ts", { preview: true, focus: false });
response.resolve();
await first;
expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]);
expect(tabFor("/p/a.ts")?.preview).toBe(false);
expect(tabFor("/p/b.ts")?.preview).toBe(true);
expect(activePath()).toBe("/p/b.ts");
});
it("keeps manual tab activation while an earlier probe is pending", async () => {
const { started, response } = delayProvider();
const controller = setup();
await controller.openFile("/p/b.ts", { focus: false });
await controller.openFile("/p/c.ts", { focus: false });
const first = controller.openFile("/p/a.ts", { focus: false });
await started.promise;
controller.activate(useTabs.getState().activePane, tabFor("/p/b.ts")!.id, false);
response.resolve();
await first;
expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts", "/p/c.ts"]);
expect(activePath()).toBe("/p/b.ts");
});
it("keeps a newer explicit open regular when the same file's preview resolves late", async () => {
const { started, response } = delayProvider();
const controller = setup();
const preview = controller.openFile("/p/a.ts", { preview: true, focus: false });
await started.promise;
await controller.openFile("/p/a.ts", { focus: false });
response.resolve();
await preview;
expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]);
expect(tabFor("/p/a.ts")?.preview).toBe(false);
expect(activePath()).toBe("/p/a.ts");
});
it("promotes an admitted preview when the same file is opened explicitly", async () => {
const controller = setup();
await controller.openFile("/p/a.ts", { preview: true, focus: false });
const tabId = tabFor("/p/a.ts")!.id;
expect(tabFor("/p/a.ts")?.preview).toBe(true);
await controller.openFile("/p/a.ts", { focus: false });
expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]);
expect(tabFor("/p/a.ts")).toMatchObject({ id: tabId, preview: false });
expect(activePath()).toBe("/p/a.ts");
});
it("keeps the newer selection when an earlier open of the same file resolves late", async () => {
const { started, response } = delayProvider();
const controller = setup();
const first = controller.openFile("/p/a.ts", { selection: { line: 0, col: 0 }, focus: false });
await started.promise;
await controller.openFile("/p/a.ts", { selection: { line: 1, col: 0 }, focus: false });
response.resolve();
await first;
const state = controller.slotFor(tabFor("/p/a.ts")!.id)!.currentState();
expect(state.doc.lineAt(state.selection.main.head).number).toBe(2);
});
it.each([false, true])("keeps a pending navigation selection after a plain open (same file: %s)", async (same) => {
const backend = createMemoryBackend({ "/p/a.ts": "a\nsecond", "/p/b.ts": "b" });
const started = deferred();
const response = deferred();
const open = backend.open.bind(backend);
backend.open = async (path) => {
if (path === "/p/a.ts") {
started.resolve();
await response.promise;
}
return open(path);
};
ctrl = new EditorController(backend);
ctrl.start();
const navigation = ctrl.openFile("/p/a.ts", { selection: { line: 1, col: 2 } });
await started.promise;
const plain = ctrl.openFile(same ? "/p/a.ts" : "/p/b.ts", { focus: false });
if (!same) await plain;
response.resolve();
await Promise.all([navigation, plain]);
const slot = ctrl.slotFor(tabFor("/p/a.ts")!.id)!;
expect(slot.caret()).toMatchObject({ line: 1, col: 2 });
expect(slot.wantsFocus).toBe(false);
expect(activePath()).toBe(same ? "/p/a.ts" : "/p/b.ts");
});
it.each([true, false])("keeps a delayed open in its origin pane (explicit: %s)", async (explicit) => {
const { started, response } = delayProvider();
const controller = setup();
await controller.openFile("/p/b.ts", { focus: false });
const origin = useTabs.getState().activePane;
controller.split(origin, "row");
const other = useTabs.getState().activePane;
expect(other).not.toBe(origin);
controller.activate(origin, tabFor("/p/b.ts")!.id, false);
const first = controller.openFile("/p/a.ts", { ...(explicit ? { pane: origin } : {}), focus: false });
await started.promise;
await controller.openFile("/p/c.ts", { pane: other, focus: false });
response.resolve();
await first;
const state = useTabs.getState();
expect(state.panes[origin]!.tabs.some((tab) => tab.input.path === "/p/a.ts")).toBe(true);
expect(state.panes[other]!.tabs.some((tab) => tab.input.path === "/p/a.ts")).toBe(false);
expect(activePath()).toBe("/p/c.ts");
});
it("does not request focus when an earlier document finishes loading late", async () => {
const backend = createMemoryBackend({ "/p/a.ts": "a", "/p/b.ts": "b" });
const started = deferred();
const response = deferred();
const open = backend.open.bind(backend);
backend.open = async (path) => {
if (path === "/p/a.ts") {
started.resolve();
await response.promise;
}
return open(path);
};
ctrl = new EditorController(backend);
ctrl.start();
const first = ctrl.openFile("/p/a.ts");
await started.promise;
await ctrl.openFile("/p/b.ts", { focus: false });
response.resolve();
await first;
expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]);
expect(activePath()).toBe("/p/b.ts");
expect(ctrl.slotFor(tabFor("/p/a.ts")!.id)?.wantsFocus).toBe(false);
});
it("does not run queued mount focus after a newer file becomes active", async () => {
const controller = setup();
await controller.openFile("/p/a.ts");
const slot = controller.slotFor(tabFor("/p/a.ts")!.id)!;
expect(slot.wantsFocus).toBe(true);
const focus = vi.spyOn(slot, "focus");
const frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => frames.push(callback));
try {
controller.onMounted(slot);
expect(frames.length).toBeGreaterThan(0);
await controller.openFile("/p/b.ts", { focus: false });
for (const frame of frames) frame(0);
expect(focus).not.toHaveBeenCalled();
expect(activePath()).toBe("/p/b.ts");
} finally {
vi.unstubAllGlobals();
focus.mockRestore();
}
});
it("rolls back a superseded custom preview before provider admission", async () => {
const started = deferred();
const response = deferred();
const commit = vi.fn();
const rollback = vi.fn(async () => {});
dispose = registerFileEditorProvider({
id: "delayed-custom-preview",
resolve: async (path, context) => {
if (path !== "/p/a.ts") return null;
context.onAdmission(commit, rollback);
started.resolve();
await response.promise;
return { kind: "image", key: "image:/p/a.ts", path };
},
});
const controller = setup();
const first = controller.openFile("/p/a.ts", { preview: true, focus: false });
await started.promise;
await controller.openFile("/p/b.ts", { preview: true, focus: false });
response.resolve();
await first;
expect(commit).not.toHaveBeenCalled();
expect(rollback).toHaveBeenCalledOnce();
expect(activePath()).toBe("/p/b.ts");
expect(tabFor("/p/a.ts")).toBeUndefined();
});
});
+120
Переглянути файл
@@ -0,0 +1,120 @@
import { afterEach, expect, it, vi } from "vitest";
import { LanguageSupport, StreamLanguage } from "@codemirror/language";
import { createMemoryBackend } from "../backend";
import { _resetLanguages, loadLanguageInfos, loadSupport } from "../cm/languages";
import { DocumentManager } from "./document";
vi.mock("../cm/languages", async (importOriginal) => {
const actual = await importOriginal<typeof import("../cm/languages")>();
return { ...actual, loadSupport: vi.fn(actual.loadSupport) };
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
afterEach(() => {
_resetLanguages();
vi.mocked(loadSupport).mockClear();
});
it.each(["/p/a.ts", "jdt://contents/a.ts"])("starts reading %s before the catalog resolves", async (path) => {
const backend = createMemoryBackend({ [path]: "const a = 1" });
const languages = await backend.languages();
const catalog = deferred<typeof languages>();
vi.spyOn(backend, "languages").mockReturnValue(catalog.promise);
const open = vi.spyOn(backend, path.startsWith("jdt:") ? "openVirtual" : "open");
const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() });
let exposed = false;
const pending = manager.acquire(path).then((model) => {
exposed = true;
return model;
});
try {
expect(open).toHaveBeenCalledWith(path);
await open.mock.results[0]!.value;
expect(exposed).toBe(false);
expect(manager.get(path)).toBeUndefined();
} finally {
catalog.resolve(languages);
const model = await pending;
model.dispose();
manager.stop();
}
});
it("starts the known mode while document content is still pending", async () => {
const path = "/p/a.ts";
const backend = createMemoryBackend({ [path]: "const a = 1" });
const info = await backend.open(path);
const content = deferred<typeof info>();
vi.spyOn(backend, "open").mockReturnValue(content.promise);
const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() });
const pending = manager.acquire(path);
await loadLanguageInfos(backend);
try {
expect(loadSupport).toHaveBeenCalledWith(expect.objectContaining({ name: "TypeScript" }));
} finally {
content.resolve(info);
const model = await pending;
model.dispose();
manager.stop();
}
});
it("handles a speculative mode rejection when opening the document fails", async () => {
const backend = createMemoryBackend();
const failure = new Error("cannot read document");
vi.spyOn(backend, "open").mockRejectedValue(failure);
vi.mocked(loadSupport).mockRejectedValueOnce(new Error("cannot preload mode"));
const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() });
try {
await expect(manager.acquire("/p/a.ts")).rejects.toBe(failure);
expect(loadSupport).toHaveBeenCalledWith(expect.objectContaining({ name: "TypeScript" }));
} finally {
manager.stop();
}
});
it("opens within 50 ms with catalog, read and cold mode budgets of 20, 40 and 30 ms", async () => {
const path = "/p/cold.rs";
const backend = createMemoryBackend({ [path]: "fn main() {}" });
const catalog = await backend.languages();
const info = await backend.open(path);
const originalSupport = vi.mocked(loadSupport).getMockImplementation()!;
const support = new LanguageSupport(StreamLanguage.define({ token: (stream) => (stream.skipToEnd(), null) }));
let loading: Promise<LanguageSupport> | undefined;
let readyAt: number | undefined;
const manager = new DocumentManager(backend, { settings: () => ({}), notify: vi.fn() });
vi.useFakeTimers();
const start = Date.now();
vi.spyOn(backend, "languages").mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(catalog), 20)),
);
vi.spyOn(backend, "open").mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(info), 40)));
vi.mocked(loadSupport).mockImplementation(
() => (loading ??= new Promise((resolve) => setTimeout(() => resolve(support), 30))),
);
const pending = manager.acquire(path).then((model) => {
readyAt = Date.now() - start;
return model;
});
try {
await vi.advanceTimersByTimeAsync(90);
expect(readyAt).toBeLessThanOrEqual(50);
expect((await pending).language).toBe(support);
} finally {
try {
await vi.runAllTimersAsync();
(await pending).dispose();
} finally {
manager.stop();
vi.mocked(loadSupport).mockImplementation(originalSupport);
vi.useRealTimers();
}
}
});
+12 -2
Переглянути файл
@@ -36,6 +36,7 @@ import {
loadSupport,
loadedSupport,
commentTokens,
uiModeForPath,
} from "../cm/languages";
import { foldEffect } from "@codemirror/language";
import { backendFolds, setBackendFolds } from "../cm/folding";
@@ -683,8 +684,17 @@ export class DocumentManager {
let p = this.opening.get(path);
if (!p) {
p = (async () => {
await loadLanguageInfos(this.backend);
const info = isVirtualUri(path) ? await this.backend.openVirtual(path) : await this.backend.open(path);
const catalog = loadLanguageInfos(this.backend);
void catalog
.then(() => {
const desc = findLanguageDescription(uiModeForPath(path) ?? null, path);
if (desc) return loadSupport(desc);
})
.catch(() => {});
const [info] = await Promise.all([
isVirtualUri(path) ? this.backend.openVirtual(path) : this.backend.open(path),
catalog,
]);
const raced = this.byPath.get(path);
if (raced) {
void this.backend.close(info.id);
+3 -1
Переглянути файл
@@ -9,7 +9,7 @@ const loading = vi.hoisted(() => {
const ready = new Promise<void>((resolve) => {
finish = resolve;
});
return { ready, finish, started: false };
return { ready, finish, started: false, modes: [] as string[] };
});
vi.mock("../cm/languages", async (importOriginal) => {
@@ -18,6 +18,7 @@ vi.mock("../cm/languages", async (importOriginal) => {
...actual,
loadSupport: async (...args: Parameters<typeof actual.loadSupport>) => {
loading.started = true;
loading.modes.push(args[0].name);
await loading.ready;
return actual.loadSupport(...args);
},
@@ -49,6 +50,7 @@ it("waits for the lazy mode before exposing an Angular document to its first slo
onContextMenu() {},
});
expect(slot.currentState().facet(languageFacet)?.name).toBe("angular");
expect(loading.modes).toEqual(expect.arrayContaining(["HTML", "Angular Template"]));
slot.dispose();
manager.stop();
});
+2 -1
Переглянути файл
@@ -113,6 +113,7 @@ export interface OpenTabOptions {
/** Insert at this index instead of after the active tab. */
index?: number;
activate?: boolean;
preservePreview?: boolean;
}
export function openTab(
@@ -152,7 +153,7 @@ export function openTab(
tabs.splice(index, 0, tab);
}
let active = pane.active;
if (!tab.preview && tabs.some((t) => t.preview)) {
if (!tab.preview && !opts.preservePreview && tabs.some((t) => t.preview)) {
// A new regular editor closes the preview tab (IDEA trims previews on every open).
const previews = new Set(tabs.filter((t) => t.preview).map((t) => t.id));
tabs.splice(0, tabs.length, ...tabs.filter((t) => !previews.has(t.id)));
+29
Переглянути файл
@@ -172,6 +172,9 @@ describe("ProjectView", () => {
act(() => setViewOption("previewTab", true));
fireEvent.click(content, { detail: 1 });
expect(openFile).toHaveBeenLastCalledWith("/w/acme/README.md", { preview: true, focus: false });
openFile.mockClear();
fireEvent.click(content, { detail: 3 });
expect(openFile).not.toHaveBeenCalled();
act(() => setViewOption("previewTab", false));
});
@@ -207,6 +210,32 @@ describe("ProjectView", () => {
act(() => setViewOption("singleClickExpand", false));
});
it("toggles folders once for each rapid double-click pair", async () => {
const { row, rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
act(() => setViewOption("singleClickExpand", true));
const content = () => row("src").querySelector("[data-project-path]")!;
const click = (detail: number) => {
fireEvent.mouseDown(row("src"), { detail });
fireEvent.click(content(), { detail });
};
click(1);
click(2);
fireEvent.doubleClick(content(), { detail: 2 });
await waitFor(() => expect(rows()).toContain("App.kt"));
click(3);
click(4);
fireEvent.doubleClick(content(), { detail: 4 });
expect(rows()).not.toContain("App.kt");
act(() => setViewOption("singleClickExpand", false));
click(5);
click(6);
expect(rows()).not.toContain("App.kt");
fireEvent.doubleClick(content(), { detail: 6 });
await waitFor(() => expect(rows()).toContain("App.kt"));
});
it("cancels a new file when its name field loses focus", async () => {
const { rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
+2 -1
Переглянути файл
@@ -529,10 +529,11 @@ export function ProjectView() {
const dragged = useRef(false);
const onRowClick = useCallback((node: ProjectNode, e: ReactMouseEvent) => {
if (e.button !== 0 || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey || e.detail > 1) return;
if (e.button !== 0 || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return;
if (node.placeholder || dragged.current) return;
const { options: o, editing: ed } = useProjectTree.getState();
if (ed) return;
if (e.detail > 1 && !(node.dir && o.singleClickExpand && e.detail % 2 === 1)) return;
if (node.dir) {
if (o.singleClickExpand) toggleExpanded(node);
return;
+56
Переглянути файл
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { commands, type FileProbe } from "@/platform/ipc";
import { resolveFileEditor } from "@/workbench/contributions";
import { activate } from "./index";
let dispose: (() => void) | undefined;
afterEach(() => {
dispose?.();
dispose = undefined;
vi.restoreAllMocks();
});
describe("file probes", () => {
it.each([false, true])("shares an in-flight probe without caching its result (binary: %s)", async (binary) => {
let finish!: (result: { status: "ok"; data: FileProbe }) => void;
const pending = new Promise<{ status: "ok"; data: FileProbe }>((resolve) => {
finish = resolve;
});
const probe = vi.spyOn(commands, "fsProbe").mockReturnValue(pending);
dispose = activate();
const first = resolveFileEditor("/p/a.ts");
const second = resolveFileEditor("/p/a.ts");
await vi.waitFor(() => expect(probe).toHaveBeenCalled());
finish({ status: "ok", data: { size: 12, binary, tooLarge: false } });
const resolutions = await Promise.all([first, second]);
expect(probe).toHaveBeenCalledTimes(1);
expect(resolutions.map((r) => r?.input.kind ?? "file")).toEqual(binary ? ["binary", "binary"] : ["file", "file"]);
if (binary) expect(resolutions[0]).not.toBe(resolutions[1]);
probe.mockResolvedValue({ status: "ok", data: { size: 20, binary: !binary, tooLarge: false } });
expect((await resolveFileEditor("/p/a.ts"))?.input.kind ?? "file").toBe(binary ? "file" : "binary");
expect(probe).toHaveBeenCalledTimes(2);
});
it("retries a failed probe", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const probe = vi
.spyOn(commands, "fsProbe")
.mockRejectedValueOnce(new Error("read failed"))
.mockResolvedValue({ status: "ok", data: { size: 20, binary: true, tooLarge: false } });
dispose = activate();
expect(await resolveFileEditor("/p/a.ts")).toBeNull();
expect((await resolveFileEditor("/p/a.ts"))?.input.kind).toBe("binary");
expect(probe).toHaveBeenCalledTimes(2);
});
it("probes different files independently", async () => {
const probe = vi.spyOn(commands, "fsProbe").mockResolvedValue({
status: "ok",
data: { size: 20, binary: false, tooLarge: false },
});
dispose = activate();
await Promise.all([resolveFileEditor("/p/a.ts"), resolveFileEditor("/p/b.ts")]);
expect(probe.mock.calls).toEqual([["/p/a.ts"], ["/p/b.ts"]]);
});
});
+8 -2
Переглянути файл
@@ -5,7 +5,7 @@
* Editor / Editor and Preview / Preview layouts around the text editor.
*/
import { registerActions, type Action } from "@/platform/actions";
import { call, commands } from "@/platform/ipc";
import { call, commands, type FileProbe } from "@/platform/ipc";
import { getContext } from "@/platform/context";
import { basename } from "@/platform/project";
import { fileIconName, lazyComponent } from "@/ui";
@@ -78,6 +78,7 @@ function markdownActions(): Action[] {
}
export function activate(): () => void {
const probes = new Map<string, Promise<FileProbe>>();
const disposers = [
registerEditorKind({ kind: IMAGE_KIND, component: ImageViewer, title: titleOf, icon: iconOf }),
registerEditorKind({ kind: BINARY_KIND, component: HexViewer, title: titleOf, icon: iconOf }),
@@ -90,7 +91,12 @@ export function activate(): () => void {
id: "viewers.hex",
order: 50,
resolve: async (path) => {
const probe = await call(commands.fsProbe(path));
let pending = probes.get(path);
if (!pending) {
pending = call(commands.fsProbe(path)).finally(() => probes.delete(path));
probes.set(path, pending);
}
const probe = await pending;
return wantsHexView(probe) ? binaryInput(path, probe) : null;
},
}),