diff --git a/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts b/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts index 3d0254bcafd..19fe02ed3ce 100644 --- a/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts +++ b/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import os from "node:os"; import path from "node:path"; -import { getAppCommand, resolvePath, stripPathWrappers } from "./helpers"; +import { + getAppCommand, + RelativePathWithoutCwdError, + resolvePath, + stripPathWrappers, +} from "./helpers"; describe("getAppCommand", () => { const originalPlatform = process.platform; @@ -205,9 +210,8 @@ describe("resolvePath", () => { expect(result).toBe("/project/sibling/file.ts"); }); - test("resolves relative path against process.cwd() when no cwd provided", () => { - const result = resolvePath("file.ts"); - expect(result).toBe(path.resolve("file.ts")); + test("throws RelativePathWithoutCwdError when no cwd provided", () => { + expect(() => resolvePath("file.ts")).toThrow(RelativePathWithoutCwdError); }); }); @@ -581,3 +585,35 @@ describe("stripPathWrappers", () => { }); }); }); + +describe("resolvePath guards against process.cwd() fallback", () => { + test("throws RelativePathWithoutCwdError for a relative path with no cwd", () => { + expect(() => resolvePath("apps/desktop/src/index.ts")).toThrow( + RelativePathWithoutCwdError, + ); + }); + + test("throws for a wrapped/quoted relative path with no cwd", () => { + expect(() => resolvePath('"apps/desktop/src/index.ts"')).toThrow( + RelativePathWithoutCwdError, + ); + }); + + test("absolute paths do not need a cwd", () => { + expect(() => resolvePath("/Users/me/file.ts")).not.toThrow(); + }); + + test("~-prefixed paths do not need a cwd", () => { + expect(() => resolvePath("~/file.ts")).not.toThrow(); + }); + + test("file:// URLs do not need a cwd", () => { + expect(() => resolvePath("file:///Users/me/file.ts")).not.toThrow(); + }); + + test("a relative path with a cwd resolves correctly", () => { + expect(resolvePath("src/index.ts", "/workspace")).toBe( + "/workspace/src/index.ts", + ); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/external/helpers.ts b/apps/desktop/src/lib/trpc/routers/external/helpers.ts index e083b3297b5..5b987202d15 100644 --- a/apps/desktop/src/lib/trpc/routers/external/helpers.ts +++ b/apps/desktop/src/lib/trpc/routers/external/helpers.ts @@ -267,10 +267,28 @@ export function stripPathWrappers(filePath: string): string { return result; } +export class RelativePathWithoutCwdError extends Error { + readonly originalPath: string; + constructor(originalPath: string) { + super( + `resolvePath received a relative path (${JSON.stringify(originalPath)}) without a cwd. ` + + "Pass an absolute path, or supply cwd (e.g. the workspace worktreePath). " + + "Falling back to process.cwd() would resolve against Electron's working directory and silently produce wrong paths.", + ); + this.name = "RelativePathWithoutCwdError"; + this.originalPath = originalPath; + } +} + /** * Resolve a path by expanding ~ and converting relative paths to absolute. * Also handles file:// URLs by converting them to regular file paths. * Strips wrapping characters like quotes, parentheses, brackets, etc. + * + * Throws `RelativePathWithoutCwdError` if the input resolves to a relative + * path and no `cwd` was supplied — callers must be explicit about what + * relative paths are relative to. (A silent `process.cwd()` fallback would + * point at Electron's working directory, not the workspace.) */ export function resolvePath(filePath: string, cwd?: string): string { let resolved = stripPathWrappers(filePath); @@ -293,9 +311,8 @@ export function resolvePath(filePath: string, cwd?: string): string { } if (!nodePath.isAbsolute(resolved)) { - resolved = cwd - ? nodePath.resolve(cwd, resolved) - : nodePath.resolve(resolved); + if (!cwd) throw new RelativePathWithoutCwdError(filePath); + resolved = nodePath.resolve(cwd, resolved); } return resolved; diff --git a/apps/desktop/src/lib/trpc/routers/external/index.ts b/apps/desktop/src/lib/trpc/routers/external/index.ts index 25105fd66c6..9de5daf3b47 100644 --- a/apps/desktop/src/lib/trpc/routers/external/index.ts +++ b/apps/desktop/src/lib/trpc/routers/external/index.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import nodePath from "node:path"; import { EXTERNAL_APPS, NON_EDITOR_APPS, @@ -17,10 +18,28 @@ import { getWorkspacePath } from "../workspaces/utils/worktree"; import { type ExternalApp, getAppCommand, + RelativePathWithoutCwdError, resolvePath, spawnAsync, } from "./helpers"; +/** + * Wraps a tRPC handler so a `RelativePathWithoutCwdError` (thrown by + * `resolvePath` when a relative path arrives without a `worktreePath`) + * surfaces as a clear BAD_REQUEST with the root-cause message instead + * of a generic 500. + */ +async function withResolveGuard(fn: () => Promise | T): Promise { + try { + return await fn(); + } catch (err) { + if (err instanceof RelativePathWithoutCwdError) { + throw new TRPCError({ code: "BAD_REQUEST", message: err.message }); + } + throw err; + } +} + const ExternalAppSchema = z.enum(EXTERNAL_APPS); const nonEditorSet = new Set(NON_EDITOR_APPS); @@ -136,6 +155,16 @@ export const createExternalRouter = () => { }), ) .mutation(async ({ input }) => { + // openInApp hands `path` directly to the editor CLI / shell; with no + // cwd input there's no safe way to interpret a relative path, so we + // reject them loudly instead of silently resolving against Electron's + // working directory. + if (!nodePath.isAbsolute(input.path)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `openInApp requires an absolute path (got ${JSON.stringify(input.path)}).`, + }); + } await openPathInApp(input.path, input.app); // Persist defaults only after successful launch @@ -170,10 +199,13 @@ export const createExternalRouter = () => { .input( z.object({ path: z.string(), - cwd: z.string().optional(), + /** Absolute workspace worktree path — relative `path`s are resolved against this. */ + worktreePath: z.string().optional(), }), ) - .query(({ input }) => resolvePath(input.path, input.cwd)), + .query(({ input }) => + withResolveGuard(() => resolvePath(input.path, input.worktreePath)), + ), statPath: publicProcedure .input( @@ -182,24 +214,26 @@ export const createExternalRouter = () => { workspaceId: z.string().optional(), }), ) - .mutation(async ({ input }) => { - const workspace = input.workspaceId - ? getWorkspace(input.workspaceId) - : null; - const cwd = workspace - ? (getWorkspacePath(workspace) ?? undefined) - : undefined; - const resolved = resolvePath(input.path, cwd); - try { - const stats = await fs.promises.stat(resolved); - return { - isDirectory: stats.isDirectory(), - resolvedPath: resolved, - }; - } catch { - return null; - } - }), + .mutation(({ input }) => + withResolveGuard(async () => { + const workspace = input.workspaceId + ? getWorkspace(input.workspaceId) + : null; + const cwd = workspace + ? (getWorkspacePath(workspace) ?? undefined) + : undefined; + const resolved = resolvePath(input.path, cwd); + try { + const stats = await fs.promises.stat(resolved); + return { + isDirectory: stats.isDirectory(), + resolvedPath: resolved, + }; + } catch { + return null; + } + }), + ), openFileInEditor: publicProcedure .input( @@ -207,24 +241,41 @@ export const createExternalRouter = () => { path: z.string(), line: z.number().optional(), column: z.number().optional(), - cwd: z.string().optional(), + /** + * Absolute workspace worktree path. Required when `path` is + * relative; ignored when `path` is already absolute. Using the + * workspace's worktreePath (rather than an arbitrary cwd) means + * relative diff/tree paths always resolve against the workspace + * the user is in, never Electron's process cwd. + */ + worktreePath: z.string().optional(), projectId: z.string().optional(), + /** + * Explicit app override from the caller (e.g. the v2 CMD+O + * choice stored client-side in tanstack-db). When provided, + * bypasses the server-side `resolveDefaultEditor` lookup — + * which only knows about v1 localDb tables and would + * otherwise return a stale global default for v2 projects. + */ + app: ExternalAppSchema.optional(), }), ) - .mutation(async ({ input }) => { - const filePath = resolvePath(input.path, input.cwd); - const app = resolveDefaultEditor(input.projectId); - - if (!app) { - // No preferred editor configured yet. - // Fall back to OS default file handler so Cmd/Ctrl+click still works - // even when Cursor (or any specific editor) isn't installed. - await shell.openPath(filePath); - return; - } + .mutation(({ input }) => + withResolveGuard(async () => { + const filePath = resolvePath(input.path, input.worktreePath); + const app = input.app ?? resolveDefaultEditor(input.projectId); - await openPathInApp(filePath, app); - }), + if (!app) { + // No preferred editor configured yet. + // Fall back to OS default file handler so Cmd/Ctrl+click still works + // even when Cursor (or any specific editor) isn't installed. + await shell.openPath(filePath); + return; + } + + await openPathInApp(filePath, app); + }), + ), }); }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx index d7c94dac4e3..46984021756 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx @@ -8,8 +8,6 @@ import { import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; import { useCallback, useMemo } from "react"; import { HiChevronDown } from "react-icons/hi2"; import { @@ -18,8 +16,7 @@ import { } from "renderer/components/OpenInExternalDropdown"; import { HotkeyLabel, useHotkey, useHotkeyDisplay } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useV2ProjectDefaultApp } from "renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp"; import { useThemeStore } from "renderer/stores"; interface V2OpenInMenuButtonProps { @@ -33,30 +30,11 @@ export function V2OpenInMenuButton({ branch, projectId, }: V2OpenInMenuButtonProps) { - const collections = useCollections(); - const { ensureProjectInSidebar } = useDashboardSidebarState(); const activeTheme = useThemeStore((state) => state.activeTheme); - const { data: sidebarProjectRows = [] } = useLiveQuery( - (q) => - q - .from({ sp: collections.v2SidebarProjects }) - .where(({ sp }) => eq(sp.projectId, projectId)) - .select(({ sp }) => ({ defaultOpenInApp: sp.defaultOpenInApp })), - [collections, projectId], - ); - const resolvedApp: ExternalApp = - (sidebarProjectRows[0]?.defaultOpenInApp as ExternalApp | null) ?? "finder"; - - const persistDefaultApp = useCallback( - (app: ExternalApp) => { - ensureProjectInSidebar(projectId); - collections.v2SidebarProjects.update(projectId, (draft) => { - draft.defaultOpenInApp = app; - }); - }, - [collections, ensureProjectInSidebar, projectId], - ); + const { app: persistedApp, setApp: persistDefaultApp } = + useV2ProjectDefaultApp(projectId); + const resolvedApp: ExternalApp = persistedApp ?? "finder"; const openInApp = electronTrpc.external.openInApp.useMutation({ onSuccess: (_data, variables) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx index 9a48bd6db5a..b90f8e315b0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx @@ -1,12 +1,9 @@ import type { AppRouter } from "@superset/host-service"; -import type { ExternalApp } from "@superset/local-db"; import { alert } from "@superset/ui/atoms/Alert"; import { Button } from "@superset/ui/button"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { workspaceTrpc } from "@superset/workspace-client"; -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; import type { inferRouterOutputs } from "@trpc/server"; import { FilePlus, FolderPlus, FoldVertical, RefreshCw } from "lucide-react"; import { Fragment, useCallback, useEffect, useRef, useState } from "react"; @@ -19,9 +16,7 @@ import { useGitStatusMap, } from "renderer/hooks/host-service/useGitStatusMap"; import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; -import { electronTrpcClient } from "renderer/lib/trpc-client"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; import { ROW_HEIGHT, TREE_INDENT, @@ -225,52 +220,14 @@ export function FilesTab({ id: workspaceId, }); const rootPath = workspaceQuery.data?.worktreePath ?? ""; - const projectId = workspaceQuery.data?.projectId; - - const collections = useCollections(); - const { machineId } = useLocalHostService(); - const { data: workspacesWithHost = [] } = useLiveQuery( - (q) => - q - .from({ workspaces: collections.v2Workspaces }) - .leftJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => - eq(workspaces.hostId, hosts.id), - ) - .where(({ workspaces }) => eq(workspaces.id, workspaceId)) - .select(({ hosts }) => ({ - hostMachineId: hosts?.machineId ?? null, - })), - [collections, workspaceId], - ); - const workspaceHost = workspacesWithHost[0]; - - const { data: sidebarProjectRows = [] } = useLiveQuery( - (q) => - q - .from({ sp: collections.v2SidebarProjects }) - .where(({ sp }) => eq(sp.projectId, projectId ?? "")) - .select(({ sp }) => ({ defaultOpenInApp: sp.defaultOpenInApp })), - [collections, projectId], - ); - const resolvedOpenInApp: ExternalApp = - (sidebarProjectRows[0]?.defaultOpenInApp as ExternalApp | null) ?? "finder"; + + const openInExternalEditor = useOpenInExternalEditor(workspaceId); const handleOpenInEditor = useCallback( (absolutePath: string) => { - if (!workspaceHost) return; - if (workspaceHost.hostMachineId !== machineId) { - toast.error("Opening in editor is only supported on local workspaces"); - return; - } - electronTrpcClient.external.openInApp - .mutate({ path: absolutePath, app: resolvedOpenInApp }) - .catch((err) => { - toast.error("Couldn't open file", { - description: err instanceof Error ? err.message : String(err), - }); - }); + openInExternalEditor(absolutePath); }, - [workspaceHost, machineId, resolvedOpenInApp], + [openInExternalEditor], ); const writeFile = workspaceTrpc.filesystem.writeFile.useMutation(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts index d5517584455..eb9058a466b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts @@ -1,8 +1,10 @@ import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useCallback } from "react"; import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useV2ProjectDefaultApp } from "renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; @@ -22,12 +24,23 @@ export function useOpenInExternalEditor(workspaceId: string) { eq(workspaces.hostId, hosts.id), ) .where(({ workspaces }) => eq(workspaces.id, workspaceId)) - .select(({ hosts }) => ({ + .select(({ workspaces, hosts }) => ({ hostMachineId: hosts?.machineId ?? null, + projectId: workspaces.projectId ?? null, })), [collections, workspaceId], ); const workspaceHost = workspacesWithHost[0]; + const projectId = workspaceHost?.projectId ?? undefined; + + // Forward the v2 CMD+O choice as an explicit app override; the server + // can't look this up on its own (v2 projects aren't in the v1 localDb). + const { app: v2PreferredApp } = useV2ProjectDefaultApp(projectId); + + const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ + id: workspaceId, + }); + const worktreePath = workspaceQuery.data?.worktreePath ?? undefined; return useCallback( (path: string, opts?: OpenInExternalEditorOptions) => { @@ -38,12 +51,19 @@ export function useOpenInExternalEditor(workspaceId: string) { return; } electronTrpcClient.external.openFileInEditor - .mutate({ path, line: opts?.line, column: opts?.column }) + .mutate({ + path, + line: opts?.line, + column: opts?.column, + worktreePath, + projectId, + app: v2PreferredApp, + }) .catch((error) => { console.error("Failed to open in external editor:", error); toast.error("Failed to open in external editor"); }); }, - [workspaceHost, machineId], + [workspaceHost, machineId, projectId, worktreePath, v2PreferredApp], ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx index 544142d5d86..d2f2dd061ec 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx @@ -1,12 +1,10 @@ import { useVirtualizer, Virtualizer } from "@pierre/diffs/react"; import type { RendererContext } from "@superset/panes"; -import { toast } from "@superset/ui/sonner"; -import { workspaceTrpc } from "@superset/workspace-client"; import { useCallback, useEffect, useMemo, useRef } from "react"; -import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useSettings } from "renderer/stores/settings"; import type { DiffPaneData, PaneViewerData } from "../../../../types"; import { useChangeset } from "../../../useChangeset"; +import { useOpenInExternalEditor } from "../../../useOpenInExternalEditor"; import { useSidebarDiffRef } from "../../../useSidebarDiffRef"; import { useViewedFiles } from "../../../useViewedFiles"; import { DiffFileEntry } from "./components/DiffFileEntry"; @@ -55,24 +53,7 @@ export function DiffPane({ context, workspaceId }: DiffPaneProps) { const { viewedSet, setViewed } = useViewedFiles(workspaceId); - const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ - id: workspaceId, - }); - const worktreePath = workspaceQuery.data?.worktreePath; - const projectId = workspaceQuery.data?.projectId; - const openFile = useCallback( - (path: string) => { - if (!worktreePath) return; - electronTrpcClient.external.openFileInEditor - .mutate({ path, cwd: worktreePath, projectId }) - .catch((err) => { - toast.error("Couldn't open file", { - description: err instanceof Error ? err.message : String(err), - }); - }); - }, - [worktreePath, projectId], - ); + const openInExternalEditor = useOpenInExternalEditor(workspaceId); // O(1) collapsed lookup per child instead of Array.includes. const collapsedSet = useMemo( @@ -123,7 +104,7 @@ export function DiffPane({ context, workspaceId }: DiffPaneProps) { onSetCollapsed={setCollapsed} viewed={viewedSet.has(file.path)} onSetViewed={setViewed} - onOpenFile={openFile} + onOpenFile={openInExternalEditor} /> ))} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp/index.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp/index.ts new file mode 100644 index 00000000000..98df1dd16bd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp/index.ts @@ -0,0 +1 @@ +export { useV2ProjectDefaultApp } from "./useV2ProjectDefaultApp"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp/useV2ProjectDefaultApp.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp/useV2ProjectDefaultApp.ts new file mode 100644 index 00000000000..3945f927f49 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp/useV2ProjectDefaultApp.ts @@ -0,0 +1,45 @@ +import type { ExternalApp } from "@superset/local-db"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback } from "react"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +/** + * Single source of truth for the v2 per-project "open in" app choice — + * the value the user picked via the CMD+O menu in `V2OpenInMenuButton`. + * + * v2 stores this client-side in `v2SidebarProjects.defaultOpenInApp` + * (tanstack-db) because v2 projects are not in the v1 localDb tables + * that the server-side `resolveDefaultEditor` consults. Anywhere v2 code + * needs to read or write this preference should go through this hook so + * CMD+O and file-open flows stay in sync. + */ +export function useV2ProjectDefaultApp(projectId: string | undefined) { + const collections = useCollections(); + const { ensureProjectInSidebar } = useDashboardSidebarState(); + + const { data: rows = [] } = useLiveQuery( + (q) => + q + .from({ sp: collections.v2SidebarProjects }) + .where(({ sp }) => eq(sp.projectId, projectId ?? "")) + .select(({ sp }) => ({ defaultOpenInApp: sp.defaultOpenInApp })), + [collections, projectId], + ); + const app = + (rows[0]?.defaultOpenInApp as ExternalApp | null | undefined) ?? undefined; + + const setApp = useCallback( + (next: ExternalApp) => { + if (!projectId) return; + ensureProjectInSidebar(projectId); + collections.v2SidebarProjects.update(projectId, (draft) => { + draft.defaultOpenInApp = next; + }); + }, + [collections, ensureProjectInSidebar, projectId], + ); + + return { app, setApp }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx index e6b3e785714..8d920e73a5b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx @@ -138,7 +138,7 @@ export function FileDiffSection({ e.stopPropagation(); if (worktreePath) { const absolutePath = toAbsoluteWorkspacePath(worktreePath, file.path); - openInEditorMutation.mutate({ path: absolutePath, cwd: worktreePath }); + openInEditorMutation.mutate({ path: absolutePath, worktreePath }); } }, [worktreePath, file.path, openInEditorMutation], diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/FileItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/FileItem.tsx index c322531341e..b579ff88926 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/FileItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/FileItem.tsx @@ -104,7 +104,7 @@ export function FileItem({ usePathActions({ absolutePath, relativePath: file.path, - cwd: worktreePath, + worktreePath, defaultApp, projectId, }); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/hooks/usePathActions.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/hooks/usePathActions.ts index 44c156c7ee4..ee8df271ccf 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/hooks/usePathActions.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/hooks/usePathActions.ts @@ -7,8 +7,8 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; interface UsePathActionsProps { absolutePath: string | null; relativePath?: string; - /** For files: pass cwd to use openFileInEditor. For folders: omit to use openInApp */ - cwd?: string; + /** For files: pass worktreePath to use openFileInEditor. For folders: omit to use openInApp */ + worktreePath?: string; /** Pre-resolved app to avoid per-row default-app queries */ defaultApp?: ExternalApp | null; /** Project identifier for project-scoped actions/metadata */ @@ -18,7 +18,7 @@ interface UsePathActionsProps { export function usePathActions({ absolutePath, relativePath, - cwd, + worktreePath, defaultApp, projectId, }: UsePathActionsProps) { @@ -60,8 +60,12 @@ export function usePathActions({ const openInEditor = useCallback(() => { if (!absolutePath) return; - if (cwd) { - openFileInEditorMutation.mutate({ path: absolutePath, cwd, projectId }); + if (worktreePath) { + openFileInEditorMutation.mutate({ + path: absolutePath, + worktreePath, + projectId, + }); } else { // Avoid opening with an incorrect fallback before upstream default app query resolves. if (defaultApp === undefined) { @@ -87,7 +91,7 @@ export function usePathActions({ } }, [ absolutePath, - cwd, + worktreePath, projectId, defaultApp, openInAppMutation, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx index b6ef54d33ce..87058fbbce1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx @@ -431,7 +431,7 @@ export function FilesView() { if (!worktreePath) return; openFileInEditorMutation.mutate({ path: entry.path, - cwd: worktreePath, + worktreePath, projectId, }); }, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/FileSearchResultItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/FileSearchResultItem.tsx index a36ec967acd..cd2238ad89c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/FileSearchResultItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/FileSearchResultItem.tsx @@ -77,7 +77,7 @@ export function FileSearchResultItem({ usePathActions({ absolutePath: entry.path, relativePath: entry.relativePath, - cwd: worktreePath, + worktreePath, projectId, }); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/FileTreeItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/FileTreeItem.tsx index b49a70fa3ee..dc48b3a21a6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/FileTreeItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/FileTreeItem.tsx @@ -64,7 +64,7 @@ export function FileTreeItem({ usePathActions({ absolutePath: entry.path, relativePath: entry.relativePath, - cwd: worktreePath, + worktreePath, projectId, }); diff --git a/bun.lock b/bun.lock index 806d7f24daa..c51b654f812 100644 --- a/bun.lock +++ b/bun.lock @@ -110,7 +110,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.5.8", + "version": "1.5.10", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36",