diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ed31621b4f3e..962afec7679d 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -161,8 +161,10 @@ const makeRunner = PRIMARY KEY (migration_id) )`, pg: () => - Effect.catch(sql`select ${table}::regclass`, () => - sql`CREATE TABLE ${sql(table)} ( + Effect.catch( + sql`select ${table}::regclass`, + () => + sql`CREATE TABLE ${sql(table)} ( migration_id integer primary key, created_at timestamp with time zone not null default now(), name text not null diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 75e76b5e8e29..c0921ea62a89 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -478,6 +478,81 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { }), ); + it.effect("resolves custom editors with {path} placeholder substitution", () => + Effect.gen(function* () { + const customEditors = [ + { + id: "nvim-ghostty", + name: "Neovim (Ghostty)", + command: ["ghostty", "-e", "nvim", "{path}"] as const, + }, + ]; + + const launch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "custom:nvim-ghostty" }, + "darwin", + { PATH: "" }, + customEditors, + ); + assert.deepEqual(launch, { + command: "ghostty", + args: ["-e", "nvim", "/tmp/workspace"], + }); + }), + ); + + it.effect("appends the target path when a custom editor command has no placeholder", () => + Effect.gen(function* () { + const customEditors = [{ id: "nvim", name: "Neovim", command: ["nvim"] as const }]; + + const launch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "custom:nvim" }, + "darwin", + { PATH: "" }, + customEditors, + ); + assert.deepEqual(launch, { + command: "nvim", + args: ["/tmp/workspace"], + }); + }), + ); + + it.effect("substitutes the placeholder in every argument containing it", () => + Effect.gen(function* () { + const customEditors = [ + { + id: "wezterm-nvim", + name: "Neovim (WezTerm)", + command: ["wezterm", "start", "--cwd={path}", "nvim", "{path}"] as const, + }, + ]; + + const launch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "custom:wezterm-nvim" }, + "linux", + { PATH: "" }, + customEditors, + ); + assert.deepEqual(launch, { + command: "wezterm", + args: ["start", "--cwd=/tmp/workspace", "nvim", "/tmp/workspace"], + }); + }), + ); + + it.effect("fails for custom editor ids without a matching definition", () => + Effect.gen(function* () { + const result = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "custom:missing" }, + "darwin", + { PATH: "" }, + [], + ).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }), + ); + it.effect("maps file-manager editor to OS open commands", () => Effect.gen(function* () { const launch1 = yield* resolveEditorLaunch( diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index da19864dcf81..730cbfbd204c 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -7,11 +7,14 @@ * @module ExternalLauncher */ import { + CUSTOM_EDITOR_PATH_PLACEHOLDER, EDITORS, ExternalLauncherError, + type CustomEditorDefinition, type EditorId, type LaunchEditorInput, } from "@t3tools/contracts"; +import { customEditorId, isCustomEditorId } from "@t3tools/shared/editors"; import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -109,6 +112,13 @@ function resolveEditorArgs( return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; } +function resolveCustomEditorLaunch(editor: CustomEditorDefinition, target: string): EditorLaunch { + const [command, ...args] = editor.command; + const hasPlaceholder = args.some((arg) => arg.includes(CUSTOM_EDITOR_PATH_PLACEHOLDER)); + const resolvedArgs = args.map((arg) => arg.replaceAll(CUSTOM_EDITOR_PATH_PLACEHOLDER, target)); + return { command, args: hasPlaceholder ? resolvedArgs : [...resolvedArgs, target] }; +} + function resolveAvailableCommand( commands: ReadonlyArray, options: CommandAvailabilityOptions = {}, @@ -249,8 +259,13 @@ export interface ExternalLauncherShape { * Launch a workspace path in a selected editor integration. * * Launches the editor as a detached process so server startup is not blocked. + * Custom editor ids are resolved against the caller-provided definitions + * (sourced from server settings). */ - readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect; + readonly launchEditor: ( + input: LaunchEditorInput, + customEditors?: ReadonlyArray, + ) => Effect.Effect; } /** @@ -268,12 +283,26 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( input: LaunchEditorInput, platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, + customEditors: ReadonlyArray = [], ): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ "externalLauncher.editor": input.editor, "externalLauncher.cwd": input.cwd, "externalLauncher.platform": platform, }); + if (isCustomEditorId(input.editor)) { + const requestedEditorId = input.editor; + const definition = customEditors.find( + (editor) => customEditorId(editor.id) === requestedEditorId, + ); + if (!definition) { + return yield* new ExternalLauncherError({ + message: `Unknown custom editor: ${input.editor}`, + }); + } + return resolveCustomEditorLaunch(definition, input.cwd); + } + const editorDef = EDITORS.find((editor) => editor.id === input.editor); if (!editorDef) { return yield* new ExternalLauncherError({ message: `Unknown editor: ${input.editor}` }); @@ -352,11 +381,13 @@ const make = Effect.gen(function* () { launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), - launchEditor: (input) => - Effect.flatMap(resolveEditorLaunch(input), (launch) => - launchEditorProcess(launch).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), + launchEditor: (input, customEditors) => + Effect.flatMap( + resolveEditorLaunch(input, process.platform, process.env, customEditors), + (launch) => + launchEditorProcess(launch).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), ), } satisfies ExternalLauncherShape; }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9a6e5176f88e..b62e1f27878e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1187,9 +1187,22 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => { "rpc.aggregate": "workspace" }, ), [WS_METHODS.shellOpenInEditor]: (input) => - observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { - "rpc.aggregate": "workspace", - }), + observeRpcEffect( + WS_METHODS.shellOpenInEditor, + serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new ExternalLauncher.ExternalLauncherError({ + message: "Failed to load custom editor settings", + cause, + }), + ), + Effect.flatMap((settings) => + externalLauncher.launchEditor(input, settings.customEditors), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.filesystemBrowse]: (input) => observeRpcEffect( WS_METHODS.filesystemBrowse, diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index cc023d34cfb4..2be3cf7b850d 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,8 +1,14 @@ -import { EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { + EditorId, + type CustomEditorDefinition, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { customEditorId } from "@t3tools/shared/editors"; import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; -import { usePreferredEditor } from "../../editorPreferences"; -import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; +import { selectableEditorIds, usePreferredEditor } from "../../editorPreferences"; +import { useServerCustomEditors } from "../../rpc/serverState"; +import { ChevronDownIcon, FolderClosedIcon, SquareTerminalIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; @@ -33,8 +39,13 @@ import { } from "../JetBrainsIcons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; +import { toastManager } from "../ui/toast"; -const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { +const resolveOptions = ( + platform: string, + availableEditors: ReadonlyArray, + customEditors: ReadonlyArray, +) => { const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ { label: "Cursor", @@ -147,7 +158,15 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray availableEditorSet.has(option.value)); + const customOptions = customEditors.map(({ id, name }) => ({ + label: name, + Icon: SquareTerminalIcon as Icon, + value: customEditorId(id), + })); + return [ + ...baseOptions.filter((option) => availableEditorSet.has(option.value)), + ...customOptions, + ]; }; export const OpenInPicker = memo(function OpenInPicker({ @@ -159,10 +178,15 @@ export const OpenInPicker = memo(function OpenInPicker({ availableEditors: ReadonlyArray; openInCwd: string | null; }) { - const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); + const customEditors = useServerCustomEditors(); + const selectableEditors = useMemo( + () => selectableEditorIds(availableEditors, customEditors), + [availableEditors, customEditors], + ); + const [preferredEditor, setPreferredEditor] = usePreferredEditor(selectableEditors); const options = useMemo( - () => resolveOptions(navigator.platform, availableEditors), - [availableEditors], + () => resolveOptions(navigator.platform, availableEditors, customEditors), + [availableEditors, customEditors], ); const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; @@ -172,7 +196,13 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!api || !openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; - void api.shell.openInEditor(openInCwd, editor); + void api.shell.openInEditor(openInCwd, editor).catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Unable to open editor", + description: error instanceof Error ? error.message : "Unknown error opening editor.", + }); + }); setPreferredEditor(editor); }, [preferredEditor, openInCwd, setPreferredEditor], @@ -185,17 +215,15 @@ export const OpenInPicker = memo(function OpenInPicker({ useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { - const api = readLocalApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; - if (!api || !openInCwd) return; - if (!preferredEditor) return; + if (!openInCwd || !preferredEditor) return; e.preventDefault(); - void api.shell.openInEditor(openInCwd, preferredEditor); + openInEditor(preferredEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [preferredEditor, keybindings, openInCwd]); + }, [preferredEditor, keybindings, openInCwd, openInEditor]); return ( diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 3a36e2a51e50..e15a748862ef 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -18,9 +18,13 @@ import * as Option from "effect/Option"; import { ensureLocalApi } from "../../localApi"; import { cn } from "../../lib/utils"; -import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; +import { resolveAndPersistPreferredEditor, selectableEditorIds } from "../../editorPreferences"; import { formatRelativeTime } from "../../timestampFormat"; -import { useServerAvailableEditors, useServerObservability } from "../../rpc/serverState"; +import { + useServerAvailableEditors, + useServerCustomEditors, + useServerObservability, +} from "../../rpc/serverState"; import { useProcessDiagnostics, useProcessResourceHistory, @@ -805,6 +809,7 @@ function DiagnosticsRefreshButton({ export function DiagnosticsSettingsPanel() { const observability = useServerObservability(); const availableEditors = useServerAvailableEditors(); + const customEditors = useServerCustomEditors(); const [resourceWindowMs, setResourceWindowMs] = useState(15 * 60_000); const selectedResourceWindow = RESOURCE_HISTORY_WINDOWS.find((option) => option.windowMs === resourceWindowMs) ?? @@ -833,7 +838,9 @@ export function DiagnosticsSettingsPanel() { const logsDirectoryPath = observability?.logsDirectoryPath ?? null; if (!logsDirectoryPath) return; - const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); + const editor = resolveAndPersistPreferredEditor( + selectableEditorIds(availableEditors ?? [], customEditors), + ); if (!editor) { setOpenLogsDirectoryError("No available editors found."); return; @@ -851,7 +858,7 @@ export function DiagnosticsSettingsPanel() { .finally(() => { setIsOpeningLogsDirectory(false); }); - }, [availableEditors, observability?.logsDirectoryPath]); + }, [availableEditors, customEditors, observability?.logsDirectoryPath]); const isInitialLoading = isPending && data === null; const isProcessInitialLoading = isProcessPending && processData === null; diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index 38c59115a55d..35cbdfc8d74c 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -1,34 +1,46 @@ import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts"; +import { selectableEditorIds } from "@t3tools/shared/editors"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; import { useMemo } from "react"; +export { selectableEditorIds }; + const LAST_EDITOR_KEY = "t3code:last-editor"; -export function usePreferredEditor(availableEditors: ReadonlyArray) { +function fallbackEditor(selectableEditors: ReadonlyArray): EditorId | null { + return ( + EDITORS.find((editor) => selectableEditors.includes(editor.id))?.id ?? + selectableEditors[0] ?? + null + ); +} + +export function usePreferredEditor(selectableEditors: ReadonlyArray) { const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); const effectiveEditor = useMemo(() => { - if (lastEditor && availableEditors.includes(lastEditor)) return lastEditor; - return EDITORS.find((editor) => availableEditors.includes(editor.id))?.id ?? null; - }, [lastEditor, availableEditors]); + if (lastEditor && selectableEditors.includes(lastEditor)) return lastEditor; + return fallbackEditor(selectableEditors); + }, [lastEditor, selectableEditors]); return [effectiveEditor, setLastEditor] as const; } export function resolveAndPersistPreferredEditor( - availableEditors: readonly EditorId[], + selectableEditors: ReadonlyArray, ): EditorId | null { - const availableEditorIds = new Set(availableEditors); const stored = getLocalStorageItem(LAST_EDITOR_KEY, EditorId); - if (stored && availableEditorIds.has(stored)) return stored; - const editor = EDITORS.find((editor) => availableEditorIds.has(editor.id))?.id ?? null; + if (stored && selectableEditors.includes(stored)) return stored; + const editor = fallbackEditor(selectableEditors); if (editor) setLocalStorageItem(LAST_EDITOR_KEY, editor, EditorId); - return editor ?? null; + return editor; } export async function openInPreferredEditor(api: LocalApi, targetPath: string): Promise { - const { availableEditors } = await api.server.getConfig(); - const editor = resolveAndPersistPreferredEditor(availableEditors); + const { availableEditors, settings } = await api.server.getConfig(); + const editor = resolveAndPersistPreferredEditor( + selectableEditorIds(availableEditors, settings.customEditors), + ); if (!editor) throw new Error("No available editors found."); await api.shell.openInEditor(targetPath, editor); return editor; diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 88283d451c3a..5424b1e54e1c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -28,7 +28,7 @@ import { ToastProvider, toastManager, } from "../components/ui/toast"; -import { resolveAndPersistPreferredEditor } from "../editorPreferences"; +import { resolveAndPersistPreferredEditor, selectableEditorIds } from "../editorPreferences"; import { readLocalApi } from "../localApi"; import { useSettings } from "../hooks/useSettings"; import { @@ -384,7 +384,9 @@ function EventRouter() { void Promise.resolve(serverConfig ?? api.server.getConfig()) .then((config) => { - const editor = resolveAndPersistPreferredEditor(config.availableEditors); + const editor = resolveAndPersistPreferredEditor( + selectableEditorIds(config.availableEditors, config.settings.customEditors), + ); if (!editor) { throw new Error("No available editors found."); } diff --git a/apps/web/src/rpc/serverState.ts b/apps/web/src/rpc/serverState.ts index 64bc2d80e5ae..46cb7b743833 100644 --- a/apps/web/src/rpc/serverState.ts +++ b/apps/web/src/rpc/serverState.ts @@ -1,6 +1,7 @@ import { useAtomSubscribe, useAtomValue } from "@effect/atom-react"; import { DEFAULT_SERVER_SETTINGS, + type CustomEditorDefinition, type EditorId, type ServerConfig, type ServerConfigStreamEvent, @@ -43,10 +44,13 @@ function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdated } const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = []; +const EMPTY_CUSTOM_EDITORS: ReadonlyArray = []; const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray => config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; +const selectCustomEditors = (config: ServerConfig | null): ReadonlyArray => + config?.settings.customEditors ?? EMPTY_CUSTOM_EDITORS; const selectKeybindings = (config: ServerConfig | null) => config?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; const selectKeybindingsConfigPath = (config: ServerConfig | null) => @@ -284,6 +288,10 @@ export function useServerAvailableEditors(): ReadonlyArray { return useAtomValue(serverConfigAtom, selectAvailableEditors); } +export function useServerCustomEditors(): ReadonlyArray { + return useAtomValue(serverConfigAtom, selectCustomEditors); +} + export function useServerKeybindingsConfigPath(): string | null { return useAtomValue(serverConfigAtom, selectKeybindingsConfigPath); } diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index c180cf242944..6e4626111e9d 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -41,9 +41,51 @@ export const EDITORS = [ { id: "file-manager", label: "File Manager", commands: null, launchStyle: "direct-path" }, ] as const satisfies ReadonlyArray; -export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); +export const BuiltinEditorId = Schema.Literals(EDITORS.map((e) => e.id)); +export type BuiltinEditorId = typeof BuiltinEditorId.Type; + +export const MAX_CUSTOM_EDITOR_ID_LENGTH = 32; +export const MAX_CUSTOM_EDITORS_COUNT = 32; + +/** + * Placeholder replaced with the target path when launching a custom editor. + * When no command argument contains it, the target path is appended instead. + */ +export const CUSTOM_EDITOR_PATH_PLACEHOLDER = "{path}"; + +export const CUSTOM_EDITOR_ID_PREFIX = "custom:"; + +export const CustomEditorSlug = Schema.NonEmptyString.check( + Schema.isMaxLength(MAX_CUSTOM_EDITOR_ID_LENGTH), + Schema.isPattern(/^[a-z0-9][a-z0-9-]*$/), +); +export type CustomEditorSlug = typeof CustomEditorSlug.Type; + +export const CustomEditorId = Schema.TemplateLiteral([ + Schema.Literal(CUSTOM_EDITOR_ID_PREFIX), + CustomEditorSlug, +]); +export type CustomEditorId = typeof CustomEditorId.Type; + +export const EditorId = Schema.Union([BuiltinEditorId, CustomEditorId]); export type EditorId = typeof EditorId.Type; +/** + * User-defined editor launched via an arbitrary command, e.g. a terminal + * editor wrapped in a terminal emulator: `["ghostty", "-e", "nvim", "{path}"]`. + */ +export const CustomEditorDefinition = Schema.Struct({ + id: CustomEditorSlug, + name: TrimmedNonEmptyString, + command: Schema.NonEmptyArray(TrimmedNonEmptyString), +}); +export type CustomEditorDefinition = typeof CustomEditorDefinition.Type; + +export const CustomEditorsConfig = Schema.Array(CustomEditorDefinition).check( + Schema.isMaxLength(MAX_CUSTOM_EDITORS_COUNT), +); +export type CustomEditorsConfig = typeof CustomEditorsConfig.Type; + export const LaunchEditorInput = Schema.Struct({ cwd: TrimmedNonEmptyString, editor: EditorId, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 33781f56c949..81bc868fb715 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -3,6 +3,7 @@ import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { CustomEditorsConfig } from "./editor.ts"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; @@ -374,6 +375,10 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + // User-defined "Open in" editors (e.g. nvim wrapped in a terminal + // emulator). Edited by hand in settings.json; the file watcher hot-reloads + // changes and streams them to clients via the server config subscription. + customEditors: CustomEditorsConfig.pipe(Schema.withDecodingDefault(Effect.succeed([]))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ diff --git a/packages/shared/package.json b/packages/shared/package.json index 97af1fa58404..46bb70c17621 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -39,6 +39,10 @@ "types": "./src/shell.ts", "import": "./src/shell.ts" }, + "./editors": { + "types": "./src/editors.ts", + "import": "./src/editors.ts" + }, "./semver": { "types": "./src/semver.ts", "import": "./src/semver.ts" diff --git a/packages/shared/src/editors.ts b/packages/shared/src/editors.ts new file mode 100644 index 000000000000..c5a081a7809d --- /dev/null +++ b/packages/shared/src/editors.ts @@ -0,0 +1,36 @@ +/** + * Editors - Shared helpers for editor identifiers. + * + * Maps user-defined custom editor definitions to namespaced `EditorId` + * values so they can flow through the same RPC/preference plumbing as + * built-in editors without colliding with built-in ids. + * + * @module Editors + */ +import { + CUSTOM_EDITOR_ID_PREFIX, + type CustomEditorDefinition, + type CustomEditorId, + type EditorId, +} from "@t3tools/contracts"; + +export function customEditorId(slug: CustomEditorDefinition["id"]): CustomEditorId { + return `${CUSTOM_EDITOR_ID_PREFIX}${slug}`; +} + +export function isCustomEditorId(editor: EditorId): editor is CustomEditorId { + return editor.startsWith(CUSTOM_EDITOR_ID_PREFIX); +} + +/** + * Full list of editor ids a user can pick from: built-in editors detected on + * the server plus all configured custom editors. Custom editors are not + * availability-checked — the user opted into them explicitly, and a missing + * command surfaces as a launch error instead of a silently hidden entry. + */ +export function selectableEditorIds( + availableEditors: ReadonlyArray, + customEditors: ReadonlyArray, +): ReadonlyArray { + return [...availableEditors, ...customEditors.map((editor) => customEditorId(editor.id))]; +}