diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 90e12af6d..723a27d5a 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -171,6 +171,14 @@ function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: s type EditorKinds = ReturnType["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(); + private activationGeneration = 0; + private admitting = false; private closeRequests = 0; private projectTransition: Promise = 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(); 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 { - 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 { + 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; + } + }); } } diff --git a/src/editor/core/file-open-order.test.ts b/src/editor/core/file-open-order.test.ts new file mode 100644 index 000000000..cb8630974 --- /dev/null +++ b/src/editor/core/file-open-order.test.ts @@ -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((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(); + }); +}); diff --git a/src/editor/core/model/document-open-latency.test.ts b/src/editor/core/model/document-open-latency.test.ts new file mode 100644 index 000000000..7d6795a10 --- /dev/null +++ b/src/editor/core/model/document-open-latency.test.ts @@ -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(); + return { ...actual, loadSupport: vi.fn(actual.loadSupport) }; +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((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(); + 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(); + 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 | 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(); + } + } +}); diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index 9f81ad20f..ff94d564a 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -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); diff --git a/src/editor/core/model/documentLanguage.test.ts b/src/editor/core/model/documentLanguage.test.ts index 0618acf22..0993edbdc 100644 --- a/src/editor/core/model/documentLanguage.test.ts +++ b/src/editor/core/model/documentLanguage.test.ts @@ -9,7 +9,7 @@ const loading = vi.hoisted(() => { const ready = new Promise((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) => { 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(); }); diff --git a/src/editor/core/model/tabs.ts b/src/editor/core/model/tabs.ts index 269e4b206..56cf6a83a 100644 --- a/src/editor/core/model/tabs.ts +++ b/src/editor/core/model/tabs.ts @@ -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))); diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 05ce9b995..6c68edcf1 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -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")); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 832cbb51f..f4e140f8d 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -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; diff --git a/src/features/viewers/file-probe.test.ts b/src/features/viewers/file-probe.test.ts new file mode 100644 index 000000000..49ae1bd42 --- /dev/null +++ b/src/features/viewers/file-probe.test.ts @@ -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"]]); + }); +}); diff --git a/src/features/viewers/index.ts b/src/features/viewers/index.ts index 92ac8c156..a0137a656 100644 --- a/src/features/viewers/index.ts +++ b/src/features/viewers/index.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>(); 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; }, }),