From 2f603e1e8cc720fcf40a300051d04dadb0417cde Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 31 Mar 2026 23:52:31 -0500 Subject: [PATCH 1/2] Polish skill detail dialog layout - Add icon, tag chips, and improved command/path presentation - Make dialog content and skills page scrollable within available height --- apps/web/src/components/skills/SkillsPage.tsx | 55 +++++++++++++------ apps/web/src/components/ui/dialog.tsx | 8 ++- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/skills/SkillsPage.tsx b/apps/web/src/components/skills/SkillsPage.tsx index a7f3f2a2d..88ec65f9c 100644 --- a/apps/web/src/components/skills/SkillsPage.tsx +++ b/apps/web/src/components/skills/SkillsPage.tsx @@ -138,30 +138,53 @@ function SkillDetailDialog(props: { const mutable = isCatalog ? !skill.immutable && skill.installed : skill.mutable; const pathValue = skill.path; const slashName = isCatalog ? skill.name.toLowerCase().replace(/\s+/g, "-") : skill.name; + const tags = "tags" in skill ? skill.tags : []; + const Icon = skillIcon("icon" in skill ? skill.icon : "file"); return ( - {skill.name} - {skill.description} - - -
- {("tags" in skill ? skill.tags : []).map((tag) => ( - - {tag} - - ))} +
+
+ +
+
+ {skill.name} + {skill.description} +
+ {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} + +
-

Slash commands

-

/{slashName}

-

/skill read {slashName}

+

+ Slash commands +

+
+

+ /{slashName} +

+

+ /skill read {slashName} +

+
{pathValue ? (
-

Path

-

{pathValue}

+

+ Installed path +

+

+ {pathValue} +

) : null}
@@ -422,7 +445,7 @@ export function SkillsPage(props: {
-
+
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 774047fee..73ae52e11 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -90,7 +90,13 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { } function DialogPanel({ className, ...props }: React.ComponentProps<"div">) { - return
; + return ( +
+ ); } function DialogFooter({ From ae44a01d4dd10b610e770c62691ecb0b1531f4a4 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 1 Apr 2026 03:57:04 -0500 Subject: [PATCH 2/2] Add file tree context actions --- apps/server/src/main.test.ts | 2 + apps/server/src/open.test.ts | 41 +++ apps/server/src/open.ts | 40 ++- apps/server/src/wsServer.test.ts | 58 ++++ apps/server/src/wsServer.ts | 12 +- apps/web/src/components/WorkspaceFileTree.tsx | 252 +++++++++++++++++- apps/web/src/wsNativeApi.ts | 3 + packages/contracts/src/editor.ts | 5 + packages/contracts/src/ipc.ts | 2 + packages/contracts/src/ws.ts | 6 +- 10 files changed, 416 insertions(+), 5 deletions(-) diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 03fce48b1..caeb58625 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -50,6 +50,8 @@ const testLayer = Layer.mergeAll( Layer.succeed(Open, { openBrowser: (_target: string) => Effect.void, openInEditor: () => Effect.void, + openInFileManager: () => Effect.void, + revealInFileManager: () => Effect.void, } satisfies OpenShape), AnalyticsService.layerTest, FetchHttpClient.layer, diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 87672aa32..2f4ae8ad4 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -8,6 +8,8 @@ import { launchDetached, resolveAvailableEditors, resolveEditorLaunch, + resolveOpenInFileManagerLaunch, + resolveRevealInFileManagerLaunch, } from "./open"; it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { @@ -121,6 +123,45 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { }); }), ); + + it("maps direct file-manager launches for directories", () => { + assert.deepEqual(resolveOpenInFileManagerLaunch({ path: "/tmp/workspace" }, "darwin"), { + command: "open", + args: ["/tmp/workspace"], + }); + assert.deepEqual(resolveOpenInFileManagerLaunch({ path: "C:\\workspace" }, "win32"), { + command: "explorer", + args: ["C:\\workspace"], + }); + assert.deepEqual(resolveOpenInFileManagerLaunch({ path: "/tmp/workspace" }, "linux"), { + command: "xdg-open", + args: ["/tmp/workspace"], + }); + }); + + it("maps reveal launches to platform-specific file-manager commands", () => { + assert.deepEqual( + resolveRevealInFileManagerLaunch({ path: "/tmp/workspace/file.ts" }, "darwin"), + { + command: "open", + args: ["-R", "/tmp/workspace/file.ts"], + }, + ); + assert.deepEqual( + resolveRevealInFileManagerLaunch({ path: "C:\\workspace\\file.ts" }, "win32"), + { + command: "explorer", + args: ["/select,", "C:\\workspace\\file.ts"], + }, + ); + assert.deepEqual( + resolveRevealInFileManagerLaunch({ path: "/tmp/workspace/file.ts" }, "linux"), + { + command: "xdg-open", + args: ["/tmp/workspace"], + }, + ); + }); }); it.layer(NodeServices.layer)("launchDetached", (it) => { diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index ca94e5df6..85e7d8b7c 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -8,7 +8,7 @@ */ import { spawn } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; -import { extname, join } from "node:path"; +import { dirname, extname, join } from "node:path"; import { EDITORS, type EditorId } from "@okcode/contracts"; import { ServiceMap, Schema, Effect, Layer } from "effect"; @@ -27,6 +27,10 @@ export interface OpenInEditorInput { readonly editor: EditorId; } +export interface OpenPathInput { + readonly path: string; +} + interface EditorLaunch { readonly command: string; readonly args: ReadonlyArray; @@ -192,6 +196,16 @@ export interface OpenShape { * Launches the editor as a detached process so server startup is not blocked. */ readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect; + + /** + * Open a path in the OS file manager. + */ + readonly openInFileManager: (input: OpenPathInput) => Effect.Effect; + + /** + * Reveal a path in the OS file manager. + */ + readonly revealInFileManager: (input: OpenPathInput) => Effect.Effect; } /** @@ -225,6 +239,28 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] }; }); +export const resolveOpenInFileManagerLaunch = ( + input: OpenPathInput, + platform: NodeJS.Platform = process.platform, +): EditorLaunch => ({ + command: fileManagerCommandForPlatform(platform), + args: [input.path], +}); + +export const resolveRevealInFileManagerLaunch = ( + input: OpenPathInput, + platform: NodeJS.Platform = process.platform, +): EditorLaunch => { + switch (platform) { + case "darwin": + return { command: "open", args: ["-R", input.path] }; + case "win32": + return { command: "explorer", args: ["/select,", input.path] }; + default: + return { command: "xdg-open", args: [dirname(input.path)] }; + } +}; + export const launchDetached = (launch: EditorLaunch) => Effect.gen(function* () { if (!isCommandAvailable(launch.command)) { @@ -270,6 +306,8 @@ const make = Effect.gen(function* () { catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), }), openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached), + openInFileManager: (input) => launchDetached(resolveOpenInFileManagerLaunch(input)), + revealInFileManager: (input) => launchDetached(resolveRevealInFileManagerLaunch(input)), } satisfies OpenShape; }); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 1778ebb84..43bb2bd84 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -64,6 +64,8 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const defaultOpenService: OpenShape = { openBrowser: () => Effect.void, openInEditor: () => Effect.void, + openInFileManager: () => Effect.void, + revealInFileManager: () => Effect.void, }; const defaultProviderStatuses: ReadonlyArray = [ @@ -1024,6 +1026,8 @@ describe("WebSocket Server", () => { openCalls.push({ cwd: input.cwd, editor: input.editor }); return Effect.void; }, + openInFileManager: () => Effect.void, + revealInFileManager: () => Effect.void, }; server = await createTestServer({ cwd: "/my/workspace", open: openService }); @@ -1041,6 +1045,58 @@ describe("WebSocket Server", () => { expect(openCalls).toEqual([{ cwd: "/my/workspace", editor: "cursor" }]); }); + it("routes shell.openInFileManager through the injected open service", async () => { + const openCalls: string[] = []; + const openService: OpenShape = { + openBrowser: () => Effect.void, + openInEditor: () => Effect.void, + openInFileManager: (input) => { + openCalls.push(input.path); + return Effect.void; + }, + revealInFileManager: () => Effect.void, + }; + + server = await createTestServer({ cwd: "/my/workspace", open: openService }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.shellOpenInFileManager, { + path: "/my/workspace/src", + }); + expect(response.error).toBeUndefined(); + expect(openCalls).toEqual(["/my/workspace/src"]); + }); + + it("routes shell.revealInFileManager through the injected open service", async () => { + const revealCalls: string[] = []; + const openService: OpenShape = { + openBrowser: () => Effect.void, + openInEditor: () => Effect.void, + openInFileManager: () => Effect.void, + revealInFileManager: (input) => { + revealCalls.push(input.path); + return Effect.void; + }, + }; + + server = await createTestServer({ cwd: "/my/workspace", open: openService }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.shellRevealInFileManager, { + path: "/my/workspace/src/index.ts", + }); + expect(response.error).toBeUndefined(); + expect(revealCalls).toEqual(["/my/workspace/src/index.ts"]); + }); + it("reads keybindings from the configured state directory", async () => { const baseDir = makeTempDir("okcode-state-keybindings-"); const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); @@ -1503,6 +1559,8 @@ describe("WebSocket Server", () => { openBrowser: () => Effect.void, openInEditor: () => Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)), + openInFileManager: () => Effect.void, + revealInFileManager: () => Effect.void, }; try { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 1f103240b..8233b549e 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -710,7 +710,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const checkpointDiffQuery = yield* CheckpointDiffQuery; const orchestrationReactor = yield* OrchestrationReactor; const prReview = yield* PrReview; - const { openInEditor } = yield* Open; + const { openInEditor, openInFileManager, revealInFileManager } = yield* Open; const environmentVariables = yield* EnvironmentVariables; const skillService = yield* SkillService; @@ -1010,6 +1010,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* openInEditor(body); } + case WS_METHODS.shellOpenInFileManager: { + const body = stripRequestTag(request.body); + return yield* openInFileManager(body); + } + + case WS_METHODS.shellRevealInFileManager: { + const body = stripRequestTag(request.body); + return yield* revealInFileManager(body); + } + case WS_METHODS.gitStatus: { const body = stripRequestTag(request.body); return yield* gitManager.status(body); diff --git a/apps/web/src/components/WorkspaceFileTree.tsx b/apps/web/src/components/WorkspaceFileTree.tsx index b529c14b6..bad8adf25 100644 --- a/apps/web/src/components/WorkspaceFileTree.tsx +++ b/apps/web/src/components/WorkspaceFileTree.tsx @@ -8,14 +8,15 @@ import { SlidersHorizontalIcon, TriangleAlertIcon, } from "lucide-react"; -import { memo, useCallback, useDeferredValue, useState } from "react"; +import { type MouseEvent, memo, useCallback, useDeferredValue, useState } from "react"; import { openInPreferredEditor } from "~/editorPreferences"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useFileViewNavigation } from "~/hooks/useFileViewNavigation"; import { projectListDirectoryQueryOptions, projectSearchEntriesQueryOptions, } from "~/lib/projectReactQuery"; -import { cn } from "~/lib/utils"; +import { cn, isMacPlatform } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { resolvePathLinkTarget } from "~/terminal-links"; import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; @@ -26,6 +27,9 @@ import { toastManager } from "./ui/toast"; const TREE_ROW_LEFT_PADDING = 8; const TREE_ROW_DEPTH_OFFSET = 14; +type WorkspaceFileAction = "open" | "open-in-editor" | "reveal-in-finder" | "copy-path"; +type WorkspaceDirectoryAction = "expand" | "collapse" | "open-in-finder" | "copy-path"; +type WorkspaceSearchDirectoryAction = "reveal-in-tree" | "open-in-finder" | "copy-path"; export const WorkspaceFileTree = memo(function WorkspaceFileTree(props: { cwd: string; @@ -49,6 +53,26 @@ export const WorkspaceFileTree = memo(function WorkspaceFileTree(props: { }, []); const openFileInViewer = useFileViewNavigation(); + const fileManagerName = + typeof navigator !== "undefined" && isMacPlatform(navigator.platform) + ? "Finder" + : "File Manager"; + const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ + onCopy: (ctx) => { + toastManager.add({ + type: "success", + title: "Path copied", + description: ctx.path, + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + }); const filtersHaveContent = includePattern.trim().length > 0 || excludePattern.trim().length > 0; const filtersVisible = filtersOpen || filtersHaveContent; @@ -98,6 +122,75 @@ export const WorkspaceFileTree = memo(function WorkspaceFileTree(props: { [props.cwd, openFileInViewer], ); + const openFileInNativeEditor = useCallback( + async (pathValue: string) => { + const api = readNativeApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "File opening is unavailable.", + }); + return; + } + + const targetPath = resolvePathLinkTarget(pathValue, props.cwd); + try { + await openInPreferredEditor(api, targetPath); + } catch (error) { + toastManager.add({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, + [props.cwd], + ); + + const openDirectoryInFileManager = useCallback( + async (pathValue: string) => { + const api = readNativeApi(); + if (!api) return; + const absolutePath = resolvePathLinkTarget(pathValue, props.cwd); + try { + await api.shell.openInFileManager(absolutePath); + } catch (error) { + toastManager.add({ + type: "error", + title: `Unable to open in ${fileManagerName}`, + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, + [fileManagerName, props.cwd], + ); + + const revealFileInFileManager = useCallback( + async (pathValue: string) => { + const api = readNativeApi(); + if (!api) return; + const absolutePath = resolvePathLinkTarget(pathValue, props.cwd); + try { + await api.shell.revealInFileManager(absolutePath); + } catch (error) { + toastManager.add({ + type: "error", + title: `Unable to reveal in ${fileManagerName}`, + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }, + [fileManagerName, props.cwd], + ); + + const copyWorkspacePath = useCallback( + (pathValue: string) => { + const absolutePath = resolvePathLinkTarget(pathValue, props.cwd); + copyPathToClipboard(absolutePath, { path: absolutePath }); + }, + [copyPathToClipboard, props.cwd], + ); + const revealDirectory = useCallback((pathValue: string) => { setExpandedDirectories((current) => { const next = { ...current }; @@ -174,11 +267,17 @@ export const WorkspaceFileTree = memo(function WorkspaceFileTree(props: { {searchActive ? ( @@ -198,13 +302,19 @@ export const WorkspaceFileTree = memo(function WorkspaceFileTree(props: { }); const WorkspaceSearchResults = memo(function WorkspaceSearchResults(props: { + cwd: string; entries: readonly ProjectEntry[]; + fileManagerName: string; isLoading: boolean; isError: boolean; error: unknown; truncated: boolean; resolvedTheme: "light" | "dark"; + onCopyPath: (pathValue: string) => void; + onOpenDirectoryInFileManager: (pathValue: string) => void; + onOpenFileInEditor: (pathValue: string) => void; onOpenFile: (pathValue: string, event?: { metaKey?: boolean; ctrlKey?: boolean }) => void; + onRevealFileInFileManager: (pathValue: string) => void; onRevealDirectory: (pathValue: string) => void; }) { if (props.isLoading) { @@ -231,8 +341,14 @@ const WorkspaceSearchResults = memo(function WorkspaceSearchResults(props: { {props.entries.map((entry) => ( @@ -247,13 +363,66 @@ const WorkspaceSearchResults = memo(function WorkspaceSearchResults(props: { }); const WorkspaceSearchResultRow = memo(function WorkspaceSearchResultRow(props: { + cwd: string; entry: ProjectEntry; + fileManagerName: string; resolvedTheme: "light" | "dark"; + onCopyPath: (pathValue: string) => void; + onOpenDirectoryInFileManager: (pathValue: string) => void; + onOpenFileInEditor: (pathValue: string) => void; onOpenFile: (pathValue: string, event?: { metaKey?: boolean; ctrlKey?: boolean }) => void; + onRevealFileInFileManager: (pathValue: string) => void; onRevealDirectory: (pathValue: string) => void; }) { const parentPath = parentPathOf(props.entry.path); const isDirectory = props.entry.kind === "directory"; + const handleContextMenu = useCallback( + async (event: MouseEvent) => { + event.preventDefault(); + const api = readNativeApi(); + if (!api) return; + + if (isDirectory) { + const clicked = await api.contextMenu.show( + [ + { id: "reveal-in-tree", label: "Reveal in tree" }, + { id: "open-in-finder", label: `Open in ${props.fileManagerName}` }, + { id: "copy-path", label: "Copy path" }, + ], + { x: event.clientX, y: event.clientY }, + ); + if (clicked === "reveal-in-tree") { + props.onRevealDirectory(props.entry.path); + } else if (clicked === "open-in-finder") { + props.onOpenDirectoryInFileManager(props.entry.path); + } else if (clicked === "copy-path") { + props.onCopyPath(props.entry.path); + } + return; + } + + const clicked = await api.contextMenu.show( + [ + { id: "open", label: "Open" }, + { id: "open-in-editor", label: "Open in editor" }, + { id: "reveal-in-finder", label: `Reveal in ${props.fileManagerName}` }, + { id: "copy-path", label: "Copy path" }, + ], + { x: event.clientX, y: event.clientY }, + ); + + if (clicked === "open") { + props.onOpenFile(props.entry.path); + } else if (clicked === "open-in-editor") { + props.onOpenFileInEditor(props.entry.path); + } else if (clicked === "reveal-in-finder") { + props.onRevealFileInFileManager(props.entry.path); + } else if (clicked === "copy-path") { + props.onCopyPath(props.entry.path); + } + }, + [isDirectory, props], + ); return (