From 7c92fc3d7f7a33bebb3d1c47c218fc4059f51f81 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Tue, 26 May 2026 00:04:08 -0700 Subject: [PATCH] Add default app open target --- .../thread/ThreadWorkspaceOpenButton.tsx | 1 + .../src/hooks/useLocalOpenTargets.test.tsx | 66 ++++++++++++++ apps/app/src/hooks/useLocalOpenTargets.ts | 42 +++++++++ .../workspace-open-target-preference.test.ts | 21 +++++ .../views/thread-detail/ThreadDetailView.tsx | 14 +-- .../src/workspace-open-targets.test.ts | 29 ++++++ .../host-daemon/src/workspace-open-targets.ts | 89 ++++++++++++++----- packages/host-daemon-contract/src/commands.ts | 2 +- packages/host-daemon-contract/src/local.ts | 1 + .../test/contract.test.ts | 10 +++ 10 files changed, 248 insertions(+), 27 deletions(-) diff --git a/apps/app/src/components/thread/ThreadWorkspaceOpenButton.tsx b/apps/app/src/components/thread/ThreadWorkspaceOpenButton.tsx index fe23a11a4..3d0ad2bd3 100644 --- a/apps/app/src/components/thread/ThreadWorkspaceOpenButton.tsx +++ b/apps/app/src/components/thread/ThreadWorkspaceOpenButton.tsx @@ -17,6 +17,7 @@ import xcodeIcon from "@/assets/workspace-open-target-icons/xcode.png"; import { SplitButton, type SplitButtonAction } from "@/components/ui/split-button.js"; const WORKSPACE_OPEN_TARGET_ICONS: Record = { + "default-app": finderIcon, vscode: vscodeIcon, cursor: cursorIcon, "sublime-text": sublimeTextIcon, diff --git a/apps/app/src/hooks/useLocalOpenTargets.test.tsx b/apps/app/src/hooks/useLocalOpenTargets.test.tsx index 535e6dc16..e52cf2544 100644 --- a/apps/app/src/hooks/useLocalOpenTargets.test.tsx +++ b/apps/app/src/hooks/useLocalOpenTargets.test.tsx @@ -292,6 +292,72 @@ describe("useLocalOpenTargets", () => { }); }); + it("opens editor requests in an editor target when the stored workspace target is Finder", async () => { + window.localStorage.setItem(WORKSPACE_OPEN_TARGET_STORAGE_KEY, "finder"); + const state: LocalOpenTargetsFetchState = { + daemonStatus: { + connected: true, + hostId: "host-1", + protocolVersion: HOST_DAEMON_PROTOCOL_VERSION, + serverUrl: "http://localhost:3334", + supportsNativeFolderPicker: false, + platform: "darwin", + }, + hostDaemonPort: 4123, + workspaceOpenTargets: [ + { id: "default-app", kind: "editor", label: "Default App" }, + { id: "finder", kind: "file-browser", label: "Finder" }, + ], + workspaceOpenTargetsStatus: 200, + }; + const openTargetRequests: Array< + ReturnType + > = []; + installLocalOpenTargetsFetchRoutes(state, openTargetRequests); + const modules = await importFreshLocalOpenTargetsModules(); + const latestSnapshot: { current: LocalOpenTargetsSnapshot | null } = { + current: null, + }; + await act(async () => { + render( + { + latestSnapshot.current = snapshot; + }} + />, + { wrapper: createSuspenseWrapper() }, + ); + }); + + await waitFor(() => { + const localOpenTargets = requireLocalOpenTargetsSnapshot( + latestSnapshot.current, + ).localOpenTargets; + expect(localOpenTargets.preferredTarget?.label).toBe("Finder"); + expect(localOpenTargets.preferredEditorTarget?.label).toBe("Default App"); + }); + + await act(async () => { + await requireLocalOpenTargetsSnapshot( + latestSnapshot.current, + ).localOpenTargets.openPathInPreferredEditorTarget({ + lineNumber: 27, + path: "/tmp/workspace/file.md", + }); + }); + + await waitFor(() => { + expect(openTargetRequests).toEqual([ + { + lineNumber: 27, + path: "/tmp/workspace/file.md", + targetId: "default-app", + }, + ]); + }); + }); + it("stores an explicitly selected target for future opens", async () => { const state: LocalOpenTargetsFetchState = { daemonStatus: { diff --git a/apps/app/src/hooks/useLocalOpenTargets.ts b/apps/app/src/hooks/useLocalOpenTargets.ts index 3c86b0ba6..333eb05d5 100644 --- a/apps/app/src/hooks/useLocalOpenTargets.ts +++ b/apps/app/src/hooks/useLocalOpenTargets.ts @@ -32,10 +32,15 @@ export interface OpenPathInTargetArgs extends OpenLocalPathRequest { export interface OpenPathInPreferredTargetArgs extends OpenLocalPathRequest {} export interface UseLocalOpenTargetsResult { + canOpenPreferredEditorTarget: boolean; canOpenPreferredTarget: boolean; + openPathInPreferredEditorTarget: ( + args: OpenPathInPreferredTargetArgs, + ) => Promise; openPathInPreferredTarget: ( args: OpenPathInPreferredTargetArgs, ) => Promise; + preferredEditorTarget: WorkspaceOpenTarget | null; openPathInTarget: (args: OpenPathInTargetArgs) => Promise; preferredTarget: WorkspaceOpenTarget | null; workspaceOpenTargets: WorkspaceOpenTarget[]; @@ -74,6 +79,16 @@ export function useLocalOpenTargets( }), [preferredTargetId, workspaceOpenTargets], ); + const preferredEditorTarget = useMemo( + () => + resolvePreferredWorkspaceOpenTarget({ + preferredTargetId, + targets: workspaceOpenTargets.filter( + (target) => target.kind === "editor", + ), + }), + [preferredTargetId, workspaceOpenTargets], + ); const openPathInTarget = useCallback( async (request: OpenPathInTargetArgs) => { @@ -140,10 +155,37 @@ export function useLocalOpenTargets( preferredTarget, ], ); + const openPathInPreferredEditorTarget = useCallback( + async (request: OpenPathInPreferredTargetArgs) => { + if (!preferredEditorTarget) { + appToast.error(LOCAL_OPEN_FAILURE_TITLE, { + description: getOpenUnavailableDescription({ + hasDaemon, + }), + }); + return false; + } + + return openPathInTarget({ + lineNumber: request.lineNumber, + path: request.path, + rememberTarget: false, + targetId: preferredEditorTarget.id, + }); + }, + [ + hasDaemon, + openPathInTarget, + preferredEditorTarget, + ], + ); return { + canOpenPreferredEditorTarget: preferredEditorTarget !== null, canOpenPreferredTarget: preferredTarget !== null, + openPathInPreferredEditorTarget, openPathInPreferredTarget, + preferredEditorTarget, openPathInTarget, preferredTarget, workspaceOpenTargets, diff --git a/apps/app/src/lib/workspace-open-target-preference.test.ts b/apps/app/src/lib/workspace-open-target-preference.test.ts index d49068e82..9d5bcc869 100644 --- a/apps/app/src/lib/workspace-open-target-preference.test.ts +++ b/apps/app/src/lib/workspace-open-target-preference.test.ts @@ -37,6 +37,27 @@ describe("resolvePreferredWorkspaceOpenTarget", () => { kind: "editor", label: "VS Code", }); + expect( + resolvePreferredWorkspaceOpenTarget({ + preferredTargetId: null, + targets: [ + { + id: "default-app", + kind: "editor", + label: "Default App", + }, + { + id: "finder", + kind: "file-browser", + label: "Finder", + }, + ], + }), + ).toEqual({ + id: "default-app", + kind: "editor", + label: "Default App", + }); expect( resolvePreferredWorkspaceOpenTarget({ preferredTargetId: null, diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 47c634117..ff6a3d0c3 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -593,7 +593,9 @@ export function ThreadDetailView() { threadEnvironmentIsLocal, }); const { + canOpenPreferredEditorTarget, canOpenPreferredTarget, + openPathInPreferredEditorTarget, openPathInPreferredTarget, openPathInTarget, preferredTarget, @@ -1025,18 +1027,18 @@ export function ThreadDetailView() { : undefined; const handleOpenFileInEditor = buildOpenInEditorHandler({ rootPath: localWorkspaceRootPath, - canOpenPreferredTarget, - openInPreferredTarget: openPathInPreferredTarget, + canOpenPreferredTarget: canOpenPreferredEditorTarget, + openInPreferredTarget: openPathInPreferredEditorTarget, }); const handleOpenStorageFileInEditor = buildOpenInEditorHandler({ rootPath: threadEnvironmentIsLocal ? threadStorageRootPath : null, - canOpenPreferredTarget, - openInPreferredTarget: openPathInPreferredTarget, + canOpenPreferredTarget: canOpenPreferredEditorTarget, + openInPreferredTarget: openPathInPreferredEditorTarget, }); const handleOpenHostFileInEditor = - threadEnvironmentIsLocal && canOpenPreferredTarget + threadEnvironmentIsLocal && canOpenPreferredEditorTarget ? (path: string) => { - void openPathInPreferredTarget({ + void openPathInPreferredEditorTarget({ lineNumber: activeHostFileLineNumber, path, }); diff --git a/apps/host-daemon/src/workspace-open-targets.test.ts b/apps/host-daemon/src/workspace-open-targets.test.ts index 2a591efa3..46d06a1ef 100644 --- a/apps/host-daemon/src/workspace-open-targets.test.ts +++ b/apps/host-daemon/src/workspace-open-targets.test.ts @@ -100,6 +100,7 @@ describe("workspace open targets", () => { expect(targets.map((target) => target.id)).toEqual([ "zed", + "default-app", "finder", "terminal", ]); @@ -111,6 +112,34 @@ describe("workspace open targets", () => { ).toBe(false); }); + it("opens paths with the macOS default app", async () => { + const workspacePath = await mkdtemp(path.join(tmpdir(), "bb-workspace-")); + const filePath = path.join(workspacePath, "notes.md"); + const calls: ExecFileCall[] = []; + const execFile = createAvailableExecFile({ calls }); + + try { + await writeFile(filePath, "# Notes\n"); + + await openPathInTargetWithRuntime( + { + lineNumber: 12, + path: filePath, + targetId: "default-app", + }, + createRuntime({ execFile }), + ); + + expect(calls.find((call) => call.file === "open")).toEqual({ + file: "open", + args: ["--", filePath], + }); + expect(calls.some((call) => call.file === "which")).toBe(false); + } finally { + await rm(workspacePath, { force: true, recursive: true }); + } + }); + it("falls back to application bundle paths when bundle id lookup misses", async () => { const root = await mkdtemp( path.join(tmpdir(), "bb-workspace-open-targets-"), diff --git a/apps/host-daemon/src/workspace-open-targets.ts b/apps/host-daemon/src/workspace-open-targets.ts index 6d117849f..e6df8cd43 100644 --- a/apps/host-daemon/src/workspace-open-targets.ts +++ b/apps/host-daemon/src/workspace-open-targets.ts @@ -54,13 +54,22 @@ export interface WorkspaceOpenTargetRuntime { platform: NodeJS.Platform; } -interface MacWorkspaceOpenTargetDefinition { +interface MacDefaultOpenTargetDefinition { + openMode: "default-app"; +} + +interface MacApplicationOpenTargetDefinition { appName: string; bundleIds: string[]; builtIn: boolean; lineOpenCommand?: MacLineOpenCommandDefinition; + openMode: "application"; } +type MacWorkspaceOpenTargetDefinition = + | MacApplicationOpenTargetDefinition + | MacDefaultOpenTargetDefinition; + interface WorkspaceOpenTargetDefinition { fileOpenBehavior: "direct" | "containing-directory"; id: WorkspaceOpenTargetId; @@ -106,6 +115,7 @@ const WORKSPACE_OPEN_TARGET_DEFINITIONS: WorkspaceOpenTargetDefinition[] = [ label: "VS Code", fileOpenBehavior: "direct", macos: { + openMode: "application", appName: "Visual Studio Code", bundleIds: ["com.microsoft.VSCode"], builtIn: false, @@ -121,6 +131,7 @@ const WORKSPACE_OPEN_TARGET_DEFINITIONS: WorkspaceOpenTargetDefinition[] = [ label: "Cursor", fileOpenBehavior: "direct", macos: { + openMode: "application", appName: "Cursor", // ToDesktop bundle IDs are generated; keep app-name path fallback below. bundleIds: ["com.todesktop.230313mzl4w4u92"], @@ -137,6 +148,7 @@ const WORKSPACE_OPEN_TARGET_DEFINITIONS: WorkspaceOpenTargetDefinition[] = [ label: "Sublime Text", fileOpenBehavior: "direct", macos: { + openMode: "application", appName: "Sublime Text", bundleIds: ["com.sublimetext.4", "com.sublimetext.3"], builtIn: false, @@ -152,6 +164,7 @@ const WORKSPACE_OPEN_TARGET_DEFINITIONS: WorkspaceOpenTargetDefinition[] = [ label: "Zed", fileOpenBehavior: "direct", macos: { + openMode: "application", appName: "Zed", bundleIds: ["dev.zed.Zed"], builtIn: false, @@ -167,6 +180,7 @@ const WORKSPACE_OPEN_TARGET_DEFINITIONS: WorkspaceOpenTargetDefinition[] = [ label: "Windsurf", fileOpenBehavior: "direct", macos: { + openMode: "application", appName: "Windsurf", bundleIds: ["com.exafunction.windsurf"], builtIn: false, @@ -182,17 +196,44 @@ const WORKSPACE_OPEN_TARGET_DEFINITIONS: WorkspaceOpenTargetDefinition[] = [ label: "Antigravity", fileOpenBehavior: "direct", macos: { + openMode: "application", appName: "Antigravity", bundleIds: ["com.google.antigravity", "com.googlelabs.antigravity"], builtIn: false, }, }, + { + id: "xcode", + kind: "editor", + label: "Xcode", + fileOpenBehavior: "direct", + macos: { + openMode: "application", + appName: "Xcode", + bundleIds: ["com.apple.dt.Xcode"], + builtIn: false, + lineOpenCommand: { + executable: "xed", + toArgs: (args) => ["-l", String(args.lineNumber), args.path], + }, + }, + }, + { + id: "default-app", + kind: "editor", + label: "Default App", + fileOpenBehavior: "direct", + macos: { + openMode: "default-app", + }, + }, { id: "finder", kind: "file-browser", label: "Finder", fileOpenBehavior: "direct", macos: { + openMode: "application", appName: "Finder", bundleIds: ["com.apple.finder"], builtIn: true, @@ -204,6 +245,7 @@ const WORKSPACE_OPEN_TARGET_DEFINITIONS: WorkspaceOpenTargetDefinition[] = [ label: "Terminal", fileOpenBehavior: "containing-directory", macos: { + openMode: "application", appName: "Terminal", bundleIds: ["com.apple.Terminal"], builtIn: true, @@ -215,6 +257,7 @@ const WORKSPACE_OPEN_TARGET_DEFINITIONS: WorkspaceOpenTargetDefinition[] = [ label: "iTerm2", fileOpenBehavior: "containing-directory", macos: { + openMode: "application", appName: "iTerm", bundleIds: ["com.googlecode.iterm2"], builtIn: false, @@ -226,26 +269,12 @@ const WORKSPACE_OPEN_TARGET_DEFINITIONS: WorkspaceOpenTargetDefinition[] = [ label: "Ghostty", fileOpenBehavior: "containing-directory", macos: { + openMode: "application", appName: "Ghostty", bundleIds: ["com.mitchellh.ghostty"], builtIn: false, }, }, - { - id: "xcode", - kind: "editor", - label: "Xcode", - fileOpenBehavior: "direct", - macos: { - appName: "Xcode", - bundleIds: ["com.apple.dt.Xcode"], - builtIn: false, - lineOpenCommand: { - executable: "xed", - toArgs: (args) => ["-l", String(args.lineNumber), args.path], - }, - }, - }, ]; function toWorkspaceOpenTarget( @@ -287,6 +316,10 @@ function getMacApplicationCandidatePaths( definition: WorkspaceOpenTargetDefinition, runtime: WorkspaceOpenTargetRuntime, ): string[] { + if (definition.macos.openMode === "default-app") { + return []; + } + const appBundleName = `${definition.macos.appName}.app`; return runtime.applicationDirectories.map((directory) => path.join(directory, appBundleName), @@ -333,6 +366,10 @@ async function isMacTargetAvailable( definition: WorkspaceOpenTargetDefinition, runtime: WorkspaceOpenTargetRuntime, ): Promise { + if (definition.macos.openMode === "default-app") { + return true; + } + if (definition.macos.builtIn) { return true; } @@ -449,6 +486,10 @@ async function maybeResolveMacLineOpenInvocation( return null; } + if (args.definition.macos.openMode === "default-app") { + return null; + } + const lineOpenCommand = args.definition.macos.lineOpenCommand; if (!lineOpenCommand) { return null; @@ -479,16 +520,24 @@ async function resolveMacOpenInvocation( return lineOpenInvocation; } + const openPath = resolveTargetOpenPath({ + definition: args.definition, + existingPath: args.existingPath, + }); + if (args.definition.macos.openMode === "default-app") { + return { + file: "open", + args: ["--", openPath], + }; + } + return { file: "open", args: [ "-a", args.definition.macos.appName, "--", - resolveTargetOpenPath({ - definition: args.definition, - existingPath: args.existingPath, - }), + openPath, ], }; } diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index 6a3def934..abd91e55c 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -26,7 +26,7 @@ import { } from "@bb/replay-capture/schema"; import { z } from "zod"; -export const HOST_DAEMON_PROTOCOL_VERSION = 24 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 25 as const; export const FILE_LIST_QUERY_MAX_LENGTH = 256; export const FILE_LIST_LIMIT_MAX = 10_000; diff --git a/packages/host-daemon-contract/src/local.ts b/packages/host-daemon-contract/src/local.ts index c56669e37..ce556fda1 100644 --- a/packages/host-daemon-contract/src/local.ts +++ b/packages/host-daemon-contract/src/local.ts @@ -17,6 +17,7 @@ export const DEFAULT_EPHEMERAL_HOST_DAEMON_LOCAL_HEALTH_VALUE = export const DEFAULT_EPHEMERAL_HOST_DAEMON_LOCAL_PORT = 9111; export const workspaceOpenTargetIdValues = [ + "default-app", "vscode", "cursor", "sublime-text", diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index be444c5fa..40ba1fa63 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -73,6 +73,11 @@ describe("host-daemon local schemas", () => { expect( contract.workspaceOpenTargetsResponseSchema.parse({ targets: [ + { + id: "default-app", + kind: "editor", + label: "Default App", + }, { id: "finder", kind: "file-browser", @@ -87,6 +92,11 @@ describe("host-daemon local schemas", () => { }), ).toEqual({ targets: [ + { + id: "default-app", + kind: "editor", + label: "Default App", + }, { id: "finder", kind: "file-browser",