Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
43 changes: 37 additions & 6 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>,
options: CommandAvailabilityOptions = {},
Expand Down Expand Up @@ -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<void, ExternalLauncherError>;
readonly launchEditor: (
input: LaunchEditorInput,
customEditors?: ReadonlyArray<CustomEditorDefinition>,
) => Effect.Effect<void, ExternalLauncherError>;
}

/**
Expand All @@ -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<CustomEditorDefinition> = [],
): Effect.fn.Return<EditorLaunch, ExternalLauncherError> {
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}` });
Expand Down Expand Up @@ -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;
});
Expand Down
19 changes: 16 additions & 3 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
56 changes: 42 additions & 14 deletions apps/web/src/components/chat/OpenInPicker.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<EditorId>) => {
const resolveOptions = (
platform: string,
availableEditors: ReadonlyArray<EditorId>,
customEditors: ReadonlyArray<CustomEditorDefinition>,
) => {
const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [
{
label: "Cursor",
Expand Down Expand Up @@ -147,7 +158,15 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray<Editor
},
];
const availableEditorSet = new Set(availableEditors);
return baseOptions.filter((option) => 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({
Expand All @@ -159,10 +178,15 @@ export const OpenInPicker = memo(function OpenInPicker({
availableEditors: ReadonlyArray<EditorId>;
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;

Expand All @@ -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],
Expand All @@ -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 (
<Group aria-label="Subscription actions">
Expand Down
15 changes: 11 additions & 4 deletions apps/web/src/components/settings/DiagnosticsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) ??
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading