From 3dcf64a0b86ffcac7fefb957f7730b8d208a62d7 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:22:29 +0200 Subject: [PATCH] fix(app): open outside folder links without breaking Files Outside folder links were routed to the workspace-scoped Files explorer, which kept an access error until Retry. Route explicit outside folders to the local file manager or show an outside-workspace message, while preserving existing file and inside-folder behavior. Refs #7513 Co-authored-by: Jacek Tomaszewski --- packages/app/src/agent-stream/view.tsx | 5 ++- .../assistant-file-links/path-open.test.ts | 28 +++++++++++++++ .../app/src/assistant-file-links/path-open.ts | 18 ++++++++-- 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 | 11 +++++- .../open-in-file-manager/outside-folder.ts | 36 +++++++++++++++++++ .../features/editor-targets/registry.test.ts | 17 +++++++++ .../src/features/editor-targets/registry.ts | 8 ++++- .../src/features/editor-targets/runtime.ts | 9 +++++ .../src/features/editor-targets/target.ts | 2 ++ 18 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/workspace/open-in-file-manager/outside-folder.ts diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index d662a2f345..0c311910ef 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -88,6 +88,7 @@ import { import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache"; import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface"; import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links"; +import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder"; import { createWorkspaceFileTabTarget, type WorkspaceFileLocation, @@ -448,6 +449,7 @@ const AgentStreamViewComponent = forwardRef createInlinePathOpener({ @@ -455,8 +457,9 @@ const AgentStreamViewComponent = forwardRef { if (!isActive) inlinePathOpener.cancel(); diff --git a/packages/app/src/assistant-file-links/path-open.test.ts b/packages/app/src/assistant-file-links/path-open.test.ts index a046a598f6..a85d4c8ec0 100644 --- a/packages/app/src/assistant-file-links/path-open.test.ts +++ b/packages/app/src/assistant-file-links/path-open.test.ts @@ -71,6 +71,9 @@ it("ignores an older probe after a newer file link opens", async () => { openDirectory: (directory) => { opened.push(directory); }, + openOutsideDirectory: (directory) => { + opened.push(directory); + }, }); const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred"); await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred"); @@ -95,6 +98,9 @@ it("ignores a pending probe when its view is left", async () => { openDirectory: (directory) => { opened.push(directory); }, + openOutsideDirectory: (directory) => { + opened.push(directory); + }, }); const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred"); opener.cancel(); @@ -102,3 +108,25 @@ it("ignores a pending probe when its view is left", async () => { await pending; expect(opened).toEqual([]); }); + +it("sends folders outside the workspace away from the workspace Files panel", async () => { + const { createInlinePathOpener } = await import("./path-open"); + const workspaceFolders: string[] = []; + const outsideFolders: string[] = []; + const opener = createInlinePathOpener({ + workspaceRoot: "/Users/me/repo", + client: null, + openFile: () => {}, + openDirectory: (directory) => { + workspaceFolders.push(directory); + }, + openOutsideDirectory: (directory) => { + outsideFolders.push(directory); + }, + }); + for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) { + await opener.open({ raw, path: raw }, "preferred"); + } + expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]); + expect(workspaceFolders).toEqual(["docs"]); +}); diff --git a/packages/app/src/assistant-file-links/path-open.ts b/packages/app/src/assistant-file-links/path-open.ts index b3d2178165..7c9cf228f4 100644 --- a/packages/app/src/assistant-file-links/path-open.ts +++ b/packages/app/src/assistant-file-links/path-open.ts @@ -14,12 +14,23 @@ interface PathOpenInput { client: { listDirectory(root: string, path: string): Promise } | null; } -type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string }; +type PathOpenTarget = + | { kind: "file" } + | { kind: "directory"; directoryPath: string } + | { kind: "outsideDirectory"; directoryPath: string }; + +// Workspace paths arrive relative to the root; anything still absolute or +// home-relative lives outside it, where the Files panel cannot list it. +function isOutsideWorkspacePath(pathValue: string): boolean { + return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/"); +} /** Resolve workspace folders from filesystem facts, including names that contain dots. */ export async function resolveInlinePathOpen(input: PathOpenInput): Promise { if (!input.file) { - return { kind: "directory", directoryPath: input.directory }; + return isOutsideWorkspacePath(input.directory) + ? { kind: "outsideDirectory", directoryPath: input.directory } + : { kind: "directory", directoryPath: input.directory }; } if ( input.lineStart !== undefined || @@ -42,6 +53,7 @@ export function createInlinePathOpener(input: { client: PathOpenInput["client"]; openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void; openDirectory: (path: string) => void; + openOutsideDirectory: (path: string) => void; }) { let generation = 0; return { @@ -58,6 +70,8 @@ export function createInlinePathOpener(input: { if (ownGeneration !== generation) return; if (destination.kind === "directory") { input.openDirectory(destination.directoryPath); + } else if (destination.kind === "outsideDirectory") { + input.openOutsideDirectory(destination.directoryPath); } else if (normalized.file) { const location = normalizeWorkspaceFileLocation({ path: normalized.file, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 16779d95e1..3c3f638ff5 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -67,6 +67,7 @@ export const ar: TranslationResources = { daemonClientUnavailable: "عميل Daemon غير متوفر", daemonClientDisconnected: "تم قطع اتصال عميل Daemon", noFileFound: "لم يتم العثور على ملف لـ{{token}}", + folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه", unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.", }, connectionStatus: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 6471b03f60..60f20ec726 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -63,6 +63,7 @@ export const en = { daemonClientUnavailable: "Daemon client unavailable", daemonClientDisconnected: "Daemon client is disconnected", noFileFound: "No file found for {{token}}", + folderOutsideWorkspace: "{{path}} is outside this workspace", unexpectedDictationError: "An unexpected error occurred while handling dictation.", }, connectionStatus: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 82ab9e6977..9cb5e83519 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -68,6 +68,7 @@ export const es: TranslationResources = { daemonClientUnavailable: "ClienteDaemonno disponible", daemonClientDisconnected: "El clienteDaemonestá desconectado", noFileFound: "No se encontró ningún archivo para{{token}}", + folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo", unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.", }, connectionStatus: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 44aae87628..32f321c0ee 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -68,6 +68,7 @@ export const fr: TranslationResources = { daemonClientUnavailable: "Client du daemon indisponible", daemonClientDisconnected: "Le client du daemon est déconnecté", noFileFound: "Aucun fichier trouvé pour {{token}}", + folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail", unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.", }, connectionStatus: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index baa248aa31..d0b6667be2 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -68,6 +68,7 @@ export const ja: TranslationResources = { daemonClientUnavailable: "デーモンクライアントが利用できません", daemonClientDisconnected: "デーモンクライアントが切断されています", noFileFound: "{{token}}のファイルが見つかりません", + folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります", unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。", }, connectionStatus: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index a573ef1ffa..c0bd0c30a6 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -67,6 +67,7 @@ export const ko: TranslationResources = { daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다", daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다", noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다", + folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다", unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.", }, connectionStatus: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 43c32d2914..73a463ef46 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -68,6 +68,7 @@ export const ptBR: TranslationResources = { daemonClientUnavailable: "Cliente do daemon indisponível", daemonClientDisconnected: "O cliente do daemon está desconectado", noFileFound: "Nenhum arquivo encontrado para {{token}}", + folderOutsideWorkspace: "{{path}} está fora deste workspace", unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.", }, connectionStatus: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 586ad8595d..1219146ad3 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -68,6 +68,7 @@ export const ru: TranslationResources = { daemonClientUnavailable: "Daemon клиента недоступен", daemonClientDisconnected: "Daemon клиента отключен", noFileFound: "Файл для {{token}} не найден", + folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства", unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.", }, connectionStatus: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 08bb8491cd..cf3b6dc4f0 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -67,6 +67,7 @@ export const zhCN: TranslationResources = { daemonClientUnavailable: "Daemon client 不可用", daemonClientDisconnected: "Daemon client 已断开连接", noFileFound: "未找到 {{token}} 对应的文件", + folderOutsideWorkspace: "{{path}} 不在此工作区内", unexpectedDictationError: "处理听写时发生意外错误。", }, connectionStatus: { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index bcc2593611..28a97ed80c 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -63,6 +63,7 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/scripts"; import { ImportSessionSheet } from "@/components/import-session-sheet"; import { useNavigateToImportedAgent } from "@/hooks/use-import-session"; import { useToast } from "@/contexts/toast-context"; +import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder"; import { getOrCreateClientId } from "@/utils/client-id"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; @@ -3681,6 +3682,7 @@ function WorkspaceScreenContent({ view: "files", }); }); + const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId); const markdownPathOpener = useMemo( () => createInlinePathOpener({ @@ -3688,8 +3690,15 @@ function WorkspaceScreenContent({ client, openFile: openMarkdownFile, openDirectory: openMarkdownDirectory, + openOutsideDirectory: openMarkdownOutsideDirectory, }), - [workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory], + [ + workspaceDirectory, + client, + openMarkdownFile, + openMarkdownDirectory, + openMarkdownOutsideDirectory, + ], ); useEffect(() => { if (!isRouteFocused) markdownPathOpener.cancel(); diff --git a/packages/app/src/workspace/open-in-file-manager/outside-folder.ts b/packages/app/src/workspace/open-in-file-manager/outside-folder.ts new file mode 100644 index 0000000000..e7d41d72d9 --- /dev/null +++ b/packages/app/src/workspace/open-in-file-manager/outside-folder.ts @@ -0,0 +1,36 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/contexts/toast-context"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; +import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; + +/** + * Opens a folder outside the workspace in the system file manager. The Files + * panel only lists the workspace, so hosts without a local file manager get a + * toast instead. + */ +export function useOpenOutsideWorkspaceFolder(serverId: string): (folderPath: string) => void { + const { t } = useTranslation(); + const toast = useToast(); + const isLocalExecution = useIsLocalDaemon(serverId); + const { targets } = useDesktopOpenTargets({ isLocalExecution }); + const fileManagerTarget = targets.find((target) => target.kind === "file-manager"); + + return useCallback( + (folderPath: string) => { + const showOutsideWorkspace = () => + toast.show(t("common.errors.folderOutsideWorkspace", { path: folderPath })); + if (!fileManagerTarget) { + showOutsideWorkspace(); + return; + } + void openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: folderPath }).catch( + (error: unknown) => { + console.warn("[open-outside-workspace-folder] open failed", error); + showOutsideWorkspace(); + }, + ); + }, + [fileManagerTarget, t, toast], + ); +} diff --git a/packages/desktop/src/features/editor-targets/registry.test.ts b/packages/desktop/src/features/editor-targets/registry.test.ts index 4a11594b4f..4f60f824f5 100644 --- a/packages/desktop/src/features/editor-targets/registry.test.ts +++ b/packages/desktop/src/features/editor-targets/registry.test.ts @@ -51,6 +51,10 @@ class FakeEditorTargets implements EditorTargetRuntime { return this.paths.has(targetPath); } + expandHomePath(targetPath: string): string { + return targetPath.startsWith("~/") ? `/home/me/${targetPath.slice(2)}` : targetPath; + } + isAbsolutePath(targetPath: string): boolean { return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath); } @@ -309,6 +313,19 @@ describe("editor target registry", () => { expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]); }); + it("opens a home-relative folder in the file manager", async () => { + const runtime = new FakeEditorTargets(); + runtime.addPath("/home/me/Downloads/contracts"); + + await openEditorTarget( + { editorId: "file-manager", workspacePath: "~/Downloads/contracts" }, + runtime, + [fileManagerTarget], + ); + + expect(runtime.openedPaths).toEqual(["/home/me/Downloads/contracts"]); + }); + it("keeps the platform file-manager ids used by stored preferences", async () => { const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [ finderTarget, diff --git a/packages/desktop/src/features/editor-targets/registry.ts b/packages/desktop/src/features/editor-targets/registry.ts index 060302c729..c08e138b7f 100644 --- a/packages/desktop/src/features/editor-targets/registry.ts +++ b/packages/desktop/src/features/editor-targets/registry.ts @@ -77,10 +77,16 @@ export function getEditorTarget( } export async function openEditorTarget( - input: EditorTargetLaunchInput & { editorId: string }, + request: EditorTargetLaunchInput & { editorId: string }, runtime: EditorTargetRuntime, targets: readonly EditorTarget[] = EDITOR_TARGETS, ): Promise { + // Assistant replies name folders like `~/Downloads/x`; expand them before the absolute-path guard. + const input = { + ...request, + workspacePath: runtime.expandHomePath(request.workspacePath), + filePath: request.filePath === undefined ? undefined : runtime.expandHomePath(request.filePath), + }; if (!runtime.isAbsolutePath(input.workspacePath)) { throw new Error("Editor target workspace path must be an absolute local path"); } diff --git a/packages/desktop/src/features/editor-targets/runtime.ts b/packages/desktop/src/features/editor-targets/runtime.ts index dcd5b514c4..767b7d09d2 100644 --- a/packages/desktop/src/features/editor-targets/runtime.ts +++ b/packages/desktop/src/features/editor-targets/runtime.ts @@ -56,6 +56,14 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean { return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value); } +function expandHomePath(value: string, homeDirectory: string): string { + if (value === "~") return homeDirectory; + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(homeDirectory, value.slice(2)); + } + return value; +} + function resolveExecutable( commands: readonly string[], input: { @@ -140,6 +148,7 @@ export function createEditorTargetRuntime( env, pathExists, isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform), + expandHomePath: (targetPath) => expandHomePath(targetPath, homeDirectory), resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }), async spawnDetached({ command, args }) { const commandScript = isWindowsCommandScript(command, platform); diff --git a/packages/desktop/src/features/editor-targets/target.ts b/packages/desktop/src/features/editor-targets/target.ts index 6e92d71b54..0bd952e318 100644 --- a/packages/desktop/src/features/editor-targets/target.ts +++ b/packages/desktop/src/features/editor-targets/target.ts @@ -24,6 +24,8 @@ export interface EditorTargetRuntime { pathExists(path: string): boolean; isAbsolutePath(path: string): boolean; + /** Resolves a leading `~` to the user's home directory. */ + expandHomePath(path: string): string; resolveCommand(commands: readonly string[]): string | null; spawnDetached(input: { command: string; args: readonly string[] }): Promise; openPath(path: string): Promise;