дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
c8cc69a56e
коміт
239753df9f
10 files changed
+627
-28
No files matched your search
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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)));
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in new issue
Заблокувати користувача