diff --git a/src/editor/api.ts b/src/editor/api.ts index f4581b3fc..15f7c20f5 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -65,6 +65,47 @@ export interface EditorContribution { embedded?: boolean; } +/** + * Editor extensions whose code loads with the first editor that wants them: `wants` + * decides synchronously, `load` resolves the real `extensions`. Until it has, the editor + * runs without them and reconfigures once loaded. + */ +export function lazyEditorExtension( + wants: (ctx: EditorDocContext) => boolean, + load: () => Promise<(ctx: EditorDocContext) => Extension>, +): (ctx: EditorDocContext) => Extension { + let loaded: ((ctx: EditorDocContext) => Extension) | null = null; + let pending: Promise<(ctx: EditorDocContext) => Extension> | null = null; + const get = () => + (pending ??= load().then( + (f) => (loaded = f), + (e: unknown) => { + pending = null; + throw e; + }, + )); + return (ctx) => { + if (!wants(ctx)) return []; + if (loaded) return loaded(ctx); + const slot = new Compartment(); + return [ + slot.of([]), + ViewPlugin.define((view) => { + let live = true; + get().then( + (f) => live && view.dispatch({ effects: slot.reconfigure(f(ctx)) }), + (e: unknown) => console.error("could not load editor extensions", e), + ); + return { + destroy: () => { + live = false; + }, + }; + }), + ]; + }; +} + export interface TextEditorHandle { view: EditorView; ctx: EditorDocContext; diff --git a/src/editor/lazyEditorExtension.test.ts b/src/editor/lazyEditorExtension.test.ts new file mode 100644 index 000000000..633774fcb --- /dev/null +++ b/src/editor/lazyEditorExtension.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { EditorState, Facet } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { lazyEditorExtension, type EditorDocContext } from "./api"; + +const marker = Facet.define(); +const ctx = (language: string) => ({ path: `/p/a.${language}`, language }) as EditorDocContext; +const flush = () => new Promise((resolve) => setTimeout(resolve)); + +describe("lazyEditorExtension", () => { + it("reconfigures wanted editors once loaded and applies directly afterwards", async () => { + const load = vi.fn(async () => (c: EditorDocContext) => marker.of(c.language)); + const extensions = lazyEditorExtension((c) => c.language === "sql", load); + + const first = new EditorView({ state: EditorState.create({ extensions: extensions(ctx("sql")) }) }); + expect(first.state.facet(marker)).toEqual([]); + await flush(); + expect(first.state.facet(marker)).toEqual(["sql"]); + + const second = EditorState.create({ extensions: extensions(ctx("sql")) }); + expect(second.facet(marker)).toEqual(["sql"]); + expect(extensions(ctx("ts"))).toEqual([]); + expect(load).toHaveBeenCalledTimes(1); + first.destroy(); + }); +}); diff --git a/src/features/database/console.tsx b/src/features/database/console.tsx index 5a0e83341..25d838ca7 100644 --- a/src/features/database/console.tsx +++ b/src/features/database/console.tsx @@ -63,7 +63,8 @@ import { tx } from "@/platform/i18n"; /** `templates` feature's language-data key for finer template contexts. */ const TEMPLATE_CONTEXT_KEY = "openideaTemplateContexts"; -export const SQL_LANGUAGE = "sql"; +import { SQL_LANGUAGE } from "./sqlLanguage"; +export { SQL_LANGUAGE }; const ROW_LIMITS = ["100", "500", "1000", "5000", "50000"]; /** Statement outcomes of the last run, kept on the text (IDEA's gutter check / error and timing). */ diff --git a/src/features/database/index.ts b/src/features/database/index.ts index 57e524572..2146a62b5 100644 --- a/src/features/database/index.ts +++ b/src/features/database/index.ts @@ -4,8 +4,8 @@ * results grids, and a table data editor. Connections, introspection and * statements run in Rust (`ide-db`). */ -import { lazyComponent } from "@/ui"; -import { activeEditor, registerEditorContribution } from "@/editor/api"; +import { lazyComponent, lazyWhen } from "@/ui"; +import { activeEditor, lazyEditorExtension, registerEditorContribution } from "@/editor/api"; import { registerActions } from "@/platform/actions"; import { useProject } from "@/platform/project"; import { @@ -16,8 +16,7 @@ import { setFileDecorations, toolWindowService, } from "@/workbench/contributions"; -import { SQL_LANGUAGE, consoleExtension, executeInView, explainInView } from "./console"; -import { DatabaseDialogs } from "./Dialogs"; +import { SQL_LANGUAGE } from "./sqlLanguage"; import { TABLE_KIND, tableTitle } from "./tableInput"; import { nodeTarget, type DbNode } from "./nodes"; import { registerDatabaseTreeActions } from "./treeActions"; @@ -34,6 +33,8 @@ import { watchIdleConnections, } from "./store"; +const useDialogOpen = () => useDatabase((s) => !!s.dialog); + export function activate(): () => void { const disposers = [ registerToolWindow({ @@ -59,8 +60,18 @@ export function activate(): () => void { title: tableTitle, icon: () => "database", }), - registerOverlay({ id: "database.dialogs", component: DatabaseDialogs }), - registerEditorContribution({ id: "database.console", order: 57, extensions: consoleExtension }), + registerOverlay({ + id: "database.dialogs", + component: lazyWhen(useDialogOpen, () => import("./Dialogs").then((m) => m.DatabaseDialogs)), + }), + registerEditorContribution({ + id: "database.console", + order: 57, + extensions: lazyEditorExtension( + (ctx) => ctx.language === SQL_LANGUAGE && !ctx.virtual && !ctx.readOnly, + () => import("./console").then((m) => m.consoleExtension), + ), + }), // A reopened project starts with a fresh backend: forget the old one's state. useProject.subscribe((s, prev) => { if (s.project?.root === prev.project?.root) return; @@ -149,7 +160,7 @@ export function activate(): () => void { openSourceDialog(null); return; } - await executeInView(e.view, e.ctx.path); + await (await import("./console")).executeInView(e.view, e.ctx.path); }, }, { @@ -163,7 +174,7 @@ export function activate(): () => void { const e = activeEditor(); if (!e || e.ctx.language !== SQL_LANGUAGE) return; if (!useDatabase.getState().loaded) await loadSources(); - await explainInView(e.view, e.ctx.path); + await (await import("./console")).explainInView(e.view, e.ctx.path); }, }, { diff --git a/src/features/database/sqlLanguage.ts b/src/features/database/sqlLanguage.ts new file mode 100644 index 000000000..b89a6151a --- /dev/null +++ b/src/features/database/sqlLanguage.ts @@ -0,0 +1,2 @@ +/** The language id of SQL files and consoles. */ +export const SQL_LANGUAGE = "sql"; diff --git a/src/features/logcat/index.ts b/src/features/logcat/index.ts index 5eef15f83..8e4d804ca 100644 --- a/src/features/logcat/index.ts +++ b/src/features/logcat/index.ts @@ -3,7 +3,7 @@ * level and text filters of IDEA's Android plugin. */ import { registerToolWindow } from "@/workbench/contributions"; -import { LogcatView } from "./LogcatView"; +import { lazyComponent } from "@/ui"; import { resetLogcat } from "./store"; export function activate(): () => void { @@ -15,7 +15,7 @@ export function activate(): () => void { // Listed under the stripe's "…" until first opened: most projects are not Android ones. stripe: "onUse", order: 26, - component: LogcatView, + component: lazyComponent(() => import("./LogcatView").then((m) => m.LogcatView)), // A device can be watched without a project open. requiresProject: false, }); diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index 9f1b3d031..b6e00d8c9 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -14,7 +14,7 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { registerActions, type Action } from "@/platform/actions"; import { call, commands, isTauri } from "@/platform/ipc"; -import { isPopupOpen, lazyComponent } from "@/ui"; +import { isPopupOpen, lazyComponent, lazyWhen } from "@/ui"; import { loadSettings, useProject } from "@/platform/project"; import { logError } from "@/platform/errorLog"; import { restoreRemote } from "@/platform/remote"; @@ -35,7 +35,7 @@ import { zoomActions } from "./appearanceRuntime"; import { activateFileTypes } from "./fileTypesRuntime"; import { trackRuntimeSettings } from "./runtime"; import { installActionProxies, installSettingsHost } from "./window/bridge"; -import { AssignShortcutOverlay, showAssignShortcut } from "./keymap/AssignShortcut"; +import { showAssignShortcut, useAssignShortcut } from "./keymap/assignShortcutStore"; import { settingsSearchEntries } from "./searchIndex"; import { activatePythonStatus } from "./languages/PythonStatus"; import { activatePhpStatus } from "./languages/PhpStatus"; @@ -105,6 +105,8 @@ async function showSettings(page?: string, newProjects = false) { openSettings(page, { newProjects }); } +const useAssignShortcutOpen = () => useAssignShortcut((s) => s.actionId !== null); + export function activate(): () => void { const disposers = [ trackRuntimeSettings(), @@ -116,7 +118,12 @@ export function activate(): () => void { registerActions(zoomActions()), registerActions(settingsActions((page, newProjects) => void showSettings(page, newProjects))), activateFileTypes(), - registerOverlay({ id: "settings.assignShortcut", component: AssignShortcutOverlay }), + registerOverlay({ + id: "settings.assignShortcut", + component: lazyWhen(useAssignShortcutOpen, () => + import("./keymap/AssignShortcut").then((m) => m.AssignShortcutOverlay), + ), + }), registerActions([ { // Search Everywhere's ⌥↵ on an action (arg: the action id). diff --git a/src/features/settings/keymap/AssignShortcut.tsx b/src/features/settings/keymap/AssignShortcut.tsx index 2610ad434..ade3401e0 100644 --- a/src/features/settings/keymap/AssignShortcut.tsx +++ b/src/features/settings/keymap/AssignShortcut.tsx @@ -4,7 +4,6 @@ * active keymap (overrides of a bundled keymap, or the user keymap itself). */ import { useMemo } from "react"; -import { create } from "zustand"; import { useActions } from "@/platform/actions"; import { call, commands, isTauri } from "@/platform/ipc"; import type { Keymap } from "@/platform/keymap"; @@ -21,11 +20,8 @@ import { } from "./model"; import { RecordShortcutDialog } from "./RecordShortcutDialog"; -export const useAssignShortcut = create<{ actionId: string | null }>(() => ({ actionId: null })); - -export function showAssignShortcut(actionId: string) { - useAssignShortcut.setState({ actionId }); -} +import { useAssignShortcut } from "./assignShortcutStore"; +export { showAssignShortcut, useAssignShortcut } from "./assignShortcutStore"; interface KeymapSettings { keymap: string; diff --git a/src/features/settings/keymap/assignShortcutStore.ts b/src/features/settings/keymap/assignShortcutStore.ts new file mode 100644 index 000000000..06a350b1e --- /dev/null +++ b/src/features/settings/keymap/assignShortcutStore.ts @@ -0,0 +1,8 @@ +import { create } from "zustand"; + +/** The action whose shortcut the Assign Shortcut dialog is editing. */ +export const useAssignShortcut = create<{ actionId: string | null }>(() => ({ actionId: null })); + +export function showAssignShortcut(actionId: string) { + useAssignShortcut.setState({ actionId }); +} diff --git a/src/features/vcs/ComparisonTree.tsx b/src/features/vcs/ComparisonTree.tsx index e2fd952c6..ec52938df 100644 --- a/src/features/vcs/ComparisonTree.tsx +++ b/src/features/vcs/ComparisonTree.tsx @@ -1,10 +1,11 @@ import { useState, type ReactNode } from "react"; -import { Icon, Tree, fileIconName } from "@/ui"; -import { DiffEditor } from "./DiffEditor"; +import { Icon, Tree, fileIconName, lazyComponent } from "@/ui"; import { diffInput, openDiff, type DiffData } from "./diffInput"; import { dirKeys, type FileTreeNode } from "./fileTree"; import styles from "./common.module.css"; +const DiffEditor = lazyComponent(() => import("./DiffEditor").then((m) => m.DiffEditor)); + /** Shared changes tree and on-demand diff surface for revisions and disk folders. */ export function ComparisonTree({ tree, diff --git a/src/features/vcs/__tests__/directoryCompare.test.tsx b/src/features/vcs/__tests__/directoryCompare.test.tsx index 896fc57e1..d4c069d10 100644 --- a/src/features/vcs/__tests__/directoryCompare.test.tsx +++ b/src/features/vcs/__tests__/directoryCompare.test.tsx @@ -84,7 +84,7 @@ describe("disk directory comparison", () => { it("previews on selection and opens the existing diff on double click, including missing sides", async () => { setup(); fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 }); - expect(screen.getByTestId("diff")).toHaveTextContent("/p/left/nested/中文.txt"); + expect(await screen.findByTestId("diff")).toHaveTextContent("/p/left/nested/中文.txt"); fireEvent.doubleClick(screen.getByText("中文.txt")); expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ path: "/p/right/nested/中文.txt" })); expect(directoryDiff("/l", "/r", rows[2]!)?.right.type).toBe("text"); @@ -122,7 +122,7 @@ describe("disk directory comparison", () => { ? ["/p/right", "/p/left", "nested/中文.txt"] : ["/p/left", "/p/right", "nested/中文.txt"], ); - expect(screen.getByTestId("diff")).toBeInTheDocument(); + expect(await screen.findByTestId("diff")).toBeInTheDocument(); }, ); diff --git a/src/features/vcs/__tests__/folderCompare.test.tsx b/src/features/vcs/__tests__/folderCompare.test.tsx index c1b806400..58d703ced 100644 --- a/src/features/vcs/__tests__/folderCompare.test.tsx +++ b/src/features/vcs/__tests__/folderCompare.test.tsx @@ -147,7 +147,7 @@ describe("directory comparison", () => { expect(screen.getByText("−1")).toBeInTheDocument(); expect(screen.queryByTestId("diff")).toBeNull(); fireEvent.mouseDown(screen.getByText("app.ts"), { button: 0 }); - expect(screen.getByTestId("diff")).toHaveTextContent('"rev":"aaa"'); + expect(await screen.findByTestId("diff")).toHaveTextContent('"rev":"aaa"'); expect(screen.getByTestId("diff")).toHaveTextContent("/workspace/client/src/app.ts"); }); diff --git a/src/features/vcs/historyActions.tsx b/src/features/vcs/historyActions.tsx index 4becc6a35..c0efdb0fa 100644 --- a/src/features/vcs/historyActions.tsx +++ b/src/features/vcs/historyActions.tsx @@ -60,7 +60,6 @@ import { unshelve, } from "./shelf"; import { currentRepo, focusRepo, useVcs } from "./store"; -import { openExternalLog } from "./RevisionBrowser"; const VCS = "vcsAvailable"; const IN_GIT_LOG = "(vcsAvailable || projectOpen) && (activeToolWindow == git || vcsLogFocus)"; @@ -250,7 +249,7 @@ export function registerHistoryActions(): () => void { id: "vcs.log.openExternal", title: "Open External Git Log…", category: "Git Log", - run: openExternalLog, + run: async () => (await import("./RevisionBrowser")).openExternalLog(), }, { id: "vcs.log.goToRef",