From 3377997f30c727f6048179a5207402eb99521326 Mon Sep 17 00:00:00 2001 From: Caique Maurano Date: Thu, 27 Aug 2026 07:35:17 -0300 Subject: [PATCH] (feat): Open Child Folders as Apps (port from conductor) (#3615) * feat(app): open child folders in preferred editor * fix(app): hide unavailable desktop open targets * fix(app): test desktop targets without JSDOM * refactor(app): isolate open-in-editor feature --- .../app/src/components/file-actions-menu.tsx | 20 ++++ .../app/src/components/file-explorer-pane.tsx | 32 ++++++- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../screens/workspace/workspace-screen.tsx | 2 +- .../workspace/desktop-open-targets.test.ts | 31 +++++++ .../app/src/workspace/desktop-open-targets.ts | 21 ++++- .../open-in-editor/button.tsx} | 20 ++-- .../src/workspace/open-in-editor/directory.ts | 68 ++++++++++++++ .../planner.test.ts} | 33 ++++++- .../planner.ts} | 20 +++- packages/desktop/e2e/open-in-editor.spec.ts | 92 ++++++++++++++++++- 19 files changed, 332 insertions(+), 16 deletions(-) create mode 100644 packages/app/src/workspace/desktop-open-targets.test.ts rename packages/app/src/{screens/workspace/workspace-open-in-editor-button.tsx => workspace/open-in-editor/button.tsx} (95%) create mode 100644 packages/app/src/workspace/open-in-editor/directory.ts rename packages/app/src/workspace/{open-target-planner.test.ts => open-in-editor/planner.test.ts} (86%) rename packages/app/src/workspace/{open-target-planner.ts => open-in-editor/planner.ts} (88%) diff --git a/packages/app/src/components/file-actions-menu.tsx b/packages/app/src/components/file-actions-menu.tsx index 6573066ea6..84a140021c 100644 --- a/packages/app/src/components/file-actions-menu.tsx +++ b/packages/app/src/components/file-actions-menu.tsx @@ -5,6 +5,7 @@ import { Copy, CopyPlus, Download, + ExternalLink, FilePlus, FileText, FolderMinus, @@ -51,6 +52,8 @@ interface FileActionsContextMenuContentProps { fileKind: "file" | "directory"; fileExists?: boolean; onOpenFile?: () => void; + onOpenInEditor?: () => void; + editorTargetName?: string; onOpenToSide?: () => void; onCopyPath?: () => void; onCopyRelativePath?: () => void; @@ -76,6 +79,8 @@ export function FileActionsContextMenuContent({ fileKind, fileExists = true, onOpenFile, + onOpenInEditor, + editorTargetName, onOpenToSide, onCopyPath, onCopyRelativePath, @@ -93,6 +98,19 @@ export function FileActionsContextMenuContent({ testIDPrefix, }: FileActionsContextMenuContentProps): ReactElement | null { const { t } = useTranslation(); + const openInEditorAction = useMemo( + () => + fileKind === "directory" && onOpenInEditor && editorTargetName + ? { + key: "open-in-editor", + group: "open", + label: t("workspace.fileActions.openIn", { target: editorTargetName }), + icon: ExternalLink, + onSelect: onOpenInEditor, + } + : null, + [editorTargetName, fileKind, onOpenInEditor, t], + ); const actions = useMemo(() => { const availableFile = fileKind === "file" && fileExists; const specs: Array = [ @@ -132,6 +150,7 @@ export function FileActionsContextMenuContent({ onSelect: onOpenFile, } : null, + openInEditorAction, optionalFileAction(availableFile, onOpenToSide, { key: "open-to-side", group: "open", @@ -242,6 +261,7 @@ export function FileActionsContextMenuContent({ onNewFile, onNewFolder, onOpenFile, + openInEditorAction, onOpenToSide, onRename, onReveal, diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index f636074a20..e014928531 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -59,8 +59,8 @@ import { useSessionStore } from "@/stores/session-store"; import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; import { ContextMenu, ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; import { useFileDownload } from "@/hooks/use-file-download"; -import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; @@ -77,6 +77,7 @@ import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-dra import { confirmDialog } from "@/utils/confirm-dialog"; import { useToast } from "@/contexts/toast-context"; import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; +import { useOpenDirectoryInEditor } from "@/workspace/open-in-editor/directory"; const SORT_OPTIONS: { value: SortOption }[] = [ { value: "name" }, @@ -113,6 +114,8 @@ interface TreeRowItemProps { onSelectEntry: (entry: ExplorerEntry) => void; onCopyPath: (path: string) => void; onCopyRelativePath: (path: string) => void; + onOpenInEditor?: (entry: ExplorerEntry) => void; + editorTargetName?: string; onRevealEntry?: (entry: ExplorerEntry) => void; revealTargetName?: string; onDownloadEntry: (entry: ExplorerEntry) => void; @@ -237,6 +240,8 @@ function TreeRowItem({ onSelectEntry, onCopyPath, onCopyRelativePath, + onOpenInEditor, + editorTargetName, onRevealEntry, revealTargetName, onDownloadEntry, @@ -290,6 +295,10 @@ function TreeRowItem({ onCopyRelativePath(entry.path); }, [onCopyRelativePath, entry.path]); + const handleOpenInEditor = useCallback(() => { + onOpenInEditor?.(entry); + }, [entry, onOpenInEditor]); + const handleReveal = useCallback(() => { onRevealEntry?.(entry); }, [onRevealEntry, entry]); @@ -366,6 +375,8 @@ function TreeRowItem({ target.kind === "file-manager"); + const openDirectoryInEditor = useOpenDirectoryInEditor({ + serverId, + workspaceDirectory: normalizedWorkspaceRoot, + }); // COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08. const fsEntryOpsEnabled = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryOps === true, @@ -608,6 +623,11 @@ export function FileExplorerPane({ [fileManagerTarget, normalizedWorkspaceRoot, t, toast], ); + const handleOpenDirectoryInEditor = useCallback( + (entry: ExplorerEntry) => openDirectoryInEditor?.open(entry.path), + [openDirectoryInEditor], + ); + const handleDownloadEntry = useCallback( (entry: ExplorerEntry) => { if (entry.kind !== "file") { @@ -959,6 +979,8 @@ export function FileExplorerPane({ onSelectEntry={handleSelectEntry} onCopyPath={handleCopyPath} onCopyRelativePath={handleCopyRelativePath} + onOpenInEditor={openDirectoryInEditor ? handleOpenDirectoryInEditor : undefined} + editorTargetName={openDirectoryInEditor?.targetName} onRevealEntry={fileManagerTarget ? handleRevealEntry : undefined} revealTargetName={fileManagerTarget?.label} onDownloadEntry={handleDownloadEntry} @@ -979,6 +1001,7 @@ export function FileExplorerPane({ handleCollapseDirectory, handleCopyPath, handleCopyRelativePath, + handleOpenDirectoryInEditor, handleDeleteEntry, handleDownloadEntry, handleDraftCommit, @@ -992,6 +1015,7 @@ export function FileExplorerPane({ handleSelectEntry, isDirectoryLoading, fileManagerTarget, + openDirectoryInEditor, selectedEntryPath, onAddToChat, onOpenFileToSide, @@ -1424,6 +1448,8 @@ function TreeRowDispatcher({ onSelectEntry, onCopyPath, onCopyRelativePath, + onOpenInEditor, + editorTargetName, onRevealEntry, revealTargetName, onDownloadEntry, @@ -1446,6 +1472,8 @@ function TreeRowDispatcher({ onSelectEntry: (entry: ExplorerEntry) => void; onCopyPath: (path: string) => void | Promise; onCopyRelativePath: (path: string) => void | Promise; + onOpenInEditor?: (entry: ExplorerEntry) => void; + editorTargetName?: string; onRevealEntry?: (entry: ExplorerEntry) => void; revealTargetName?: string; onDownloadEntry: (entry: ExplorerEntry) => void; @@ -1477,6 +1505,8 @@ function TreeRowDispatcher({ onSelectEntry={onSelectEntry} onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath} + onOpenInEditor={onOpenInEditor} + editorTargetName={editorTargetName} onRevealEntry={onRevealEntry} revealTargetName={revealTargetName} onDownloadEntry={onDownloadEntry} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 5a5af66b47..aee38b2254 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -416,6 +416,7 @@ export const ar: TranslationResources = { }, fileActions: { openFile: "افتح الملف", + openIn: "فتح في {{target}}", openToSide: "فتح إلى الجانب", copyPath: "نسخ المسار", copyRelativePath: "نسخ المسار النسبي", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index fb29fb1d92..5c9385ebbf 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -414,6 +414,7 @@ export const en = { }, fileActions: { openFile: "Open file", + openIn: "Open in {{target}}", openToSide: "Open to the side", copyPath: "Copy path", copyRelativePath: "Copy relative path", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 691af5f698..ac8d52889a 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -420,6 +420,7 @@ export const es: TranslationResources = { }, fileActions: { openFile: "Abrir archivo", + openIn: "Abrir en {{target}}", openToSide: "Abrir al lado", copyPath: "Copiar ruta", copyRelativePath: "Copiar ruta relativa", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 35c9b3c42e..057816a746 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -420,6 +420,7 @@ export const fr: TranslationResources = { }, fileActions: { openFile: "Ouvrir le fichier", + openIn: "Ouvrir dans {{target}}", openToSide: "Ouvrir sur le côté", copyPath: "Copier le chemin", copyRelativePath: "Copier le chemin relatif", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index ad99106e28..4317a67fdd 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -420,6 +420,7 @@ export const ja: TranslationResources = { }, fileActions: { openFile: "ファイルを開く", + openIn: "{{target}}で開く", openToSide: "横に開く", copyPath: "パスをコピー", copyRelativePath: "相対パスをコピー", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 21540ec348..20c1a7b2b1 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -417,6 +417,7 @@ export const ko: TranslationResources = { }, fileActions: { openFile: "파일 열기", + openIn: "{{target}}에서 열기", openToSide: "옆에 열기", copyPath: "경로 복사", copyRelativePath: "상대 경로 복사", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 576ca298e3..17ba25eb5c 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -420,6 +420,7 @@ export const ptBR: TranslationResources = { }, fileActions: { openFile: "Abrir arquivo", + openIn: "Abrir no {{target}}", openToSide: "Abrir ao lado", copyPath: "Copiar caminho", copyRelativePath: "Copiar caminho relativo", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 7aa31e9f53..c6c6188a0d 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -419,6 +419,7 @@ export const ru: TranslationResources = { }, fileActions: { openFile: "Открыть файл", + openIn: "Открыть в {{target}}", openToSide: "Открыть сбоку", copyPath: "Копировать путь", copyRelativePath: "Копировать относительный путь", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 240a1c5a8b..d7e4d37970 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -416,6 +416,7 @@ export const zhCN: TranslationResources = { }, fileActions: { openFile: "打开文件", + openIn: "在 {{target}} 中打开", openToSide: "在侧边打开", copyPath: "复制路径", copyRelativePath: "复制相对路径", diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 8b7036234c..3d2dc99dbd 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -38,7 +38,7 @@ import { import { SplitContainer } from "@/components/split-container"; import { RetainedPanel } from "@/components/retained-panel"; import { WorkspaceActions } from "@/git/workspace-actions"; -import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button"; +import { WorkspaceOpenInEditorButton } from "@/workspace/open-in-editor/button"; import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; import { ImportSessionSheet } from "@/components/import-session-sheet"; import { useToast } from "@/contexts/toast-context"; diff --git a/packages/app/src/workspace/desktop-open-targets.test.ts b/packages/app/src/workspace/desktop-open-targets.test.ts new file mode 100644 index 0000000000..6505e0accf --- /dev/null +++ b/packages/app/src/workspace/desktop-open-targets.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { selectDesktopOpenTargets } from "./desktop-open-targets"; + +const cachedTargets = [ + { + id: "vscode", + label: "VS Code", + kind: "editor" as const, + icon: { kind: "symbol" as const, name: "terminal" as const }, + }, +]; + +describe("selectDesktopOpenTargets", () => { + it("hides cached targets when listing is unavailable", () => { + expect( + selectDesktopOpenTargets({ + canListTargets: false, + targets: cachedTargets, + }), + ).toEqual([]); + }); + + it("returns cached targets when listing is available", () => { + expect( + selectDesktopOpenTargets({ + canListTargets: true, + targets: cachedTargets, + }), + ).toEqual(cachedTargets); + }); +}); diff --git a/packages/app/src/workspace/desktop-open-targets.ts b/packages/app/src/workspace/desktop-open-targets.ts index 83eea4a949..54730c2dfb 100644 --- a/packages/app/src/workspace/desktop-open-targets.ts +++ b/packages/app/src/workspace/desktop-open-targets.ts @@ -26,6 +26,21 @@ interface AvailableDesktopEditorBridge { openTarget: NonNullable; } +interface SelectDesktopOpenTargetsInput { + canListTargets: boolean; + targets: DesktopOpenTarget[] | undefined; +} + +export function selectDesktopOpenTargets({ + canListTargets, + targets, +}: SelectDesktopOpenTargetsInput): DesktopOpenTarget[] { + if (!canListTargets) { + return []; + } + return targets ?? []; +} + function getDesktopEditorBridge(): AvailableDesktopEditorBridge | null { const bridge = getDesktopHost()?.editor; if (!bridge?.listTargets || !bridge.openTarget) { @@ -67,9 +82,13 @@ export function useDesktopOpenTargets(input: { isLocalExecution: boolean }) { retry: false, queryFn: listDesktopOpenTargets, }); + const targets = selectDesktopOpenTargets({ + canListTargets, + targets: query.data, + }); return { - targets: query.data ?? [], + targets, isAvailable: canListTargets, }; } diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/workspace/open-in-editor/button.tsx similarity index 95% rename from packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx rename to packages/app/src/workspace/open-in-editor/button.tsx index 969f04f3a1..384ffe72c6 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/workspace/open-in-editor/button.tsx @@ -23,7 +23,7 @@ import { isAbsolutePath } from "@/utils/path"; import { isWeb } from "@/constants/platform"; import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; import { resolveWorkspaceFilePaths, type WorkspaceFileLocation } from "@/workspace/file-open"; -import { planWorkspaceOpenTargets } from "@/workspace/open-target-planner"; +import { planWorkspaceOpenTargets } from "@/workspace/open-in-editor/planner"; import type { Theme } from "@/styles/theme"; import { ForgeBrandIcon } from "@/git/forge-icon"; import { getForgePresentation } from "@/git/forge"; @@ -59,11 +59,11 @@ function renderForgeOpenTargetIcon(icon: string): ReactElement { interface OpenTargetMenuItemProps { target: OpenTarget; isPreferred: boolean; - onOpen: (target: OpenTarget) => void; + onSelect: (target: OpenTarget) => void; } -function OpenTargetMenuItem({ target, isPreferred, onOpen }: OpenTargetMenuItemProps) { - const handleSelect = useCallback(() => onOpen(target), [onOpen, target]); +function OpenTargetMenuItem({ target, isPreferred, onSelect }: OpenTargetMenuItemProps) { + const handleSelect = useCallback(() => onSelect(target), [onSelect, target]); const trailing = useMemo( () => (isPreferred ? : undefined), [isPreferred], @@ -178,10 +178,16 @@ export function WorkspaceOpenInEditorButton({ const handleOpenTarget = useCallback( (target: OpenTarget) => { - void updatePreferredEditor(target.id).catch(() => undefined); openMutation.mutate(target); }, - [openMutation, updatePreferredEditor], + [openMutation], + ); + + const handleSelectTarget = useCallback( + (target: OpenTarget) => { + void updatePreferredEditor(target.id).catch(() => undefined); + }, + [updatePreferredEditor], ); const primaryPressableStyle = useCallback( @@ -267,7 +273,7 @@ export function WorkspaceOpenInEditorButton({ key={target.id} target={target} isPreferred={target.id === effectivePreferredEditorId} - onOpen={handleOpenTarget} + onSelect={handleSelectTarget} /> ))} diff --git a/packages/app/src/workspace/open-in-editor/directory.ts b/packages/app/src/workspace/open-in-editor/directory.ts new file mode 100644 index 0000000000..e4fcca74d7 --- /dev/null +++ b/packages/app/src/workspace/open-in-editor/directory.ts @@ -0,0 +1,68 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/contexts/toast-context"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; +import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; +import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; +import { planWorkspaceOpenTargets } from "@/workspace/open-in-editor/planner"; + +interface UseOpenDirectoryInEditorInput { + serverId: string; + workspaceDirectory: string; +} + +interface OpenDirectoryInEditorAction { + targetName: string; + open: (directoryPath: string) => void; +} + +export function useOpenDirectoryInEditor({ + serverId, + workspaceDirectory, +}: UseOpenDirectoryInEditorInput): OpenDirectoryInEditorAction | null { + const { t } = useTranslation(); + const toast = useToast(); + const isLocalExecution = useIsLocalDaemon(serverId); + const { preferredEditorId } = usePreferredEditor(); + const { targets, isAvailable } = useDesktopOpenTargets({ isLocalExecution }); + const editorTargets = useMemo( + () => targets.filter((target) => target.kind === "editor"), + [targets], + ); + const preferredTarget = useMemo(() => { + const preferredId = resolvePreferredEditorId( + editorTargets.map((target) => target.id), + preferredEditorId, + ); + return editorTargets.find((target) => target.id === preferredId) ?? null; + }, [editorTargets, preferredEditorId]); + + const open = useCallback( + (directoryPath: string) => { + if (!preferredTarget) { + return; + } + const target = planWorkspaceOpenTargets({ + workspaceDirectory, + directoryPath, + desktopTargets: [preferredTarget], + canUseDesktopBridge: isAvailable, + isLocalExecution, + }).find((candidate) => candidate.source === "desktop"); + if (!target) { + return; + } + void openDesktopTarget(target.openInput).catch((cause: unknown) => { + toast.error( + cause instanceof Error ? cause.message : t("sidebar.project.actions.openFolderFailed"), + ); + }); + }, + [isAvailable, isLocalExecution, preferredTarget, t, toast, workspaceDirectory], + ); + + return useMemo( + () => (preferredTarget ? { targetName: preferredTarget.label, open } : null), + [open, preferredTarget], + ); +} diff --git a/packages/app/src/workspace/open-target-planner.test.ts b/packages/app/src/workspace/open-in-editor/planner.test.ts similarity index 86% rename from packages/app/src/workspace/open-target-planner.test.ts rename to packages/app/src/workspace/open-in-editor/planner.test.ts index 471b594cef..d97e54c22c 100644 --- a/packages/app/src/workspace/open-target-planner.test.ts +++ b/packages/app/src/workspace/open-in-editor/planner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { planWorkspaceOpenTargets } from "./open-target-planner"; +import { planWorkspaceOpenTargets } from "./planner"; const desktopTargets = [ { @@ -84,6 +84,37 @@ describe("planWorkspaceOpenTargets", () => { }); }); + it("plans a nested directory as the editor workspace", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/specs", + directoryPath: "repos/sample-android-app", + desktopTargets: [ + { + id: "android-studio", + label: "Android Studio", + kind: "editor", + icon: { kind: "symbol", name: "terminal" }, + }, + ], + canUseDesktopBridge: true, + isLocalExecution: true, + }); + + expect(targets).toEqual([ + { + source: "desktop", + id: "android-studio", + label: "Android Studio", + editorId: "android-studio", + icon: { kind: "symbol", name: "terminal" }, + openInput: { + editorId: "android-studio", + workspacePath: "/specs/repos/sample-android-app", + }, + }, + ]); + }); + it("passes custom target ids through as strings", () => { const targets = planWorkspaceOpenTargets({ workspaceDirectory: "/repo", diff --git a/packages/app/src/workspace/open-target-planner.ts b/packages/app/src/workspace/open-in-editor/planner.ts similarity index 88% rename from packages/app/src/workspace/open-target-planner.ts rename to packages/app/src/workspace/open-in-editor/planner.ts index ca1e786ecb..08cd23ffef 100644 --- a/packages/app/src/workspace/open-target-planner.ts +++ b/packages/app/src/workspace/open-in-editor/planner.ts @@ -33,6 +33,7 @@ export type PlannedWorkspaceOpenTarget = PlannedDesktopOpenTarget | PlannedForge export interface PlanWorkspaceOpenTargetsInput { workspaceDirectory: string; + directoryPath?: string | null; activeFile?: WorkspaceFileLocation | null; resolvedActiveFile?: ResolvedWorkspaceFilePaths | null; desktopTargets: readonly DesktopOpenTarget[]; @@ -61,6 +62,7 @@ function resolveActiveFileForOpenTargets( function planDesktopOpenTargets(input: { workspaceDirectory: string; + directoryPath?: string | null; activeFile?: WorkspaceFileLocation | null; resolvedFile: ResolvedWorkspaceFilePaths | null; desktopTargets: readonly DesktopOpenTarget[]; @@ -71,6 +73,20 @@ function planDesktopOpenTargets(input: { return []; } + const resolvedDirectory = + input.directoryPath === undefined || input.directoryPath === null + ? null + : resolveWorkspaceFilePaths({ + path: input.directoryPath, + workspaceRoot: input.workspaceDirectory, + }); + if (input.directoryPath !== undefined && input.directoryPath !== null) { + if (!resolvedDirectory?.relativePath) { + return []; + } + } + const workspacePath = resolvedDirectory?.absolutePath ?? input.workspaceDirectory; + return input.desktopTargets.map((target) => { if (!input.resolvedFile) { return { @@ -79,7 +95,7 @@ function planDesktopOpenTargets(input: { label: target.label, editorId: target.id, icon: target.icon, - openInput: { editorId: target.id, workspacePath: input.workspaceDirectory }, + openInput: { editorId: target.id, workspacePath }, }; } return { @@ -90,7 +106,7 @@ function planDesktopOpenTargets(input: { icon: target.icon, openInput: { editorId: target.id, - workspacePath: input.workspaceDirectory, + workspacePath, filePath: input.resolvedFile.absolutePath, ...(input.activeFile?.lineStart ? { line: input.activeFile.lineStart } : {}), }, diff --git a/packages/desktop/e2e/open-in-editor.spec.ts b/packages/desktop/e2e/open-in-editor.spec.ts index 655155d280..739974ff0e 100644 --- a/packages/desktop/e2e/open-in-editor.spec.ts +++ b/packages/desktop/e2e/open-in-editor.spec.ts @@ -1,6 +1,8 @@ -import { readFile, rm } from "node:fs/promises"; +import { mkdir, readFile, rm } from "node:fs/promises"; +import path from "node:path"; import { expect, test, type Page } from "../../app/e2e/support/fixtures"; import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app"; +import { expandFolder, openFileExplorer } from "../../app/e2e/support/helpers/file-explorer"; import { installDesktopRuntime } from "./support/runtime"; import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings"; @@ -35,7 +37,7 @@ async function readEditorOpenRecords(recordPath: string): Promise { +async function selectEditorTarget(page: Page, targetId: string): Promise { await expect(page.getByTestId("workspace-open-in-editor-primary")).toBeVisible({ timeout: 30_000, }); @@ -66,6 +68,89 @@ async function expectEditorOpened(input: { } test.describe("Workspace open in editor", () => { + test("opens a nested folder in the preferred editor", async ({ page, withWorkspace }) => { + test.setTimeout(90_000); + + const serverId = requireE2EEnv("E2E_SERVER_ID"); + const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH"); + await rm(recordPath, { force: true }); + await installDesktopRuntime(page, { + serverId, + editorTargets: [ + { + id: "android-studio", + label: "Android Studio", + kind: "editor", + icon: { kind: "symbol", name: "terminal" }, + }, + ], + editorRecordPath: recordPath, + }); + + const workspace = await withWorkspace({ prefix: "workspace-nested-editor-target-" }); + const childFolderName = "sample-android-app"; + const childFolderPath = path.join(workspace.repoPath, "repos", childFolderName); + await mkdir(childFolderPath, { recursive: true }); + await workspace.navigateTo(); + + await openFileExplorer(page); + await expandFolder(page, "repos"); + await page.getByText(childFolderName, { exact: true }).click({ button: "right" }); + const openInEditor = page.getByTestId(/file-explorer-row-\d+-open-in-editor$/); + await expect(openInEditor).toBeVisible({ timeout: 5_000 }); + await openInEditor.click(); + + await expectEditorOpened({ + recordPath, + editorId: "android-studio", + path: childFolderPath, + afterCount: 0, + }); + }); + + test("selects an editor without opening it", async ({ page, withWorkspace }) => { + test.setTimeout(90_000); + + const serverId = requireE2EEnv("E2E_SERVER_ID"); + const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH"); + await rm(recordPath, { force: true }); + await installDesktopRuntime(page, { + serverId, + editorTargets: [ + { + id: "cursor", + label: "Cursor", + kind: "editor", + icon: { kind: "symbol", name: "terminal" }, + }, + { + id: "zed", + label: "Zed", + kind: "editor", + icon: { kind: "symbol", name: "terminal" }, + }, + ], + editorRecordPath: recordPath, + }); + + const workspace = await withWorkspace({ prefix: "workspace-editor-selection-" }); + await workspace.navigateTo(); + + await selectEditorTarget(page, "zed"); + const primaryButton = page.getByTestId("workspace-open-in-editor-primary"); + await expect(primaryButton).toHaveAccessibleName("Open workspace in Zed"); + await expect(primaryButton).toBeEnabled(); + expect(await readEditorOpenRecords(recordPath)).toEqual([]); + + await primaryButton.click(); + await expectEditorOpened({ + recordPath, + editorId: "zed", + path: workspace.repoPath, + afterCount: 0, + }); + }); + test("keeps the selected editor target after leaving and returning to the workspace", async ({ page, withWorkspace, @@ -97,7 +182,8 @@ test.describe("Workspace open in editor", () => { const workspace = await withWorkspace({ prefix: "workspace-editor-target-" }); await workspace.navigateTo(); - await chooseEditorTarget(page, "vscode"); + await selectEditorTarget(page, "vscode"); + await page.getByTestId("workspace-open-in-editor-primary").click(); await expectEditorOpened({ recordPath, editorId: "vscode",