From c8bf8325231f13a0392bae31ebd109c95ec0bc0e Mon Sep 17 00:00:00 2001 From: "2859521944@qq.com" <2859521944@qq.com> Date: Thu, 23 Jul 2026 00:26:53 +0800 Subject: [PATCH 1/8] feat(chat): VS Code-style context menu on changed-file lists Add a shared path context menu on the message navigator and reply artifacts cards: open in Codeg, add to chat as an @file badge, reveal in the system file manager, open with default app / VS Code / Cursor, and copy relative / absolute / file-name paths. Stop right-click propagation so the outer conversation panel menu no longer steals the event. Extend openPath to accept openWith for local desktop openers, and resolve native absolute paths for clipboard copy. --- .../message/conversation-message-nav.tsx | 102 +++--- src/components/message/reply-artifacts.tsx | 269 ++++++++-------- .../shared/file-path-context-menu.tsx | 295 ++++++++++++++++++ src/i18n/messages/ar.json | 20 ++ src/i18n/messages/de.json | 20 ++ src/i18n/messages/en.json | 20 ++ src/i18n/messages/es.json | 20 ++ src/i18n/messages/fr.json | 20 ++ src/i18n/messages/ja.json | 20 ++ src/i18n/messages/ko.json | 20 ++ src/i18n/messages/pt.json | 20 ++ src/i18n/messages/zh-CN.json | 20 ++ src/i18n/messages/zh-TW.json | 20 ++ src/lib/file-path-actions.test.ts | 43 +++ src/lib/file-path-actions.ts | 78 +++++ src/lib/file-path-display.test.ts | 31 ++ src/lib/file-path-display.ts | 33 ++ src/lib/platform.ts | 12 +- 18 files changed, 885 insertions(+), 178 deletions(-) create mode 100644 src/components/shared/file-path-context-menu.tsx create mode 100644 src/lib/file-path-actions.test.ts create mode 100644 src/lib/file-path-actions.ts diff --git a/src/components/message/conversation-message-nav.tsx b/src/components/message/conversation-message-nav.tsx index 7e01b078a..99f54a5aa 100644 --- a/src/components/message/conversation-message-nav.tsx +++ b/src/components/message/conversation-message-nav.tsx @@ -17,6 +17,7 @@ import { CommitFileAdditions, CommitFileDeletions, } from "@/components/ai-elements/commit" +import { FilePathContextMenu } from "@/components/shared/file-path-context-menu" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { @@ -217,62 +218,69 @@ export const ConversationMessageNav = memo(function ConversationMessageNav({ folder?.path ) const isRemoved = isRemovedFileDiff(file.diff) + const openInCodeg = () => + handleFileClick( + file.path, + file.diff, + entry.ordinal, + fileIndex + ) return (
  • - + {isRemoved ? ( + + {t("remove")} + + ) : ( + + + + + )} + +
  • ) })} diff --git a/src/components/message/reply-artifacts.tsx b/src/components/message/reply-artifacts.tsx index d687b44d6..e2a063e18 100644 --- a/src/components/message/reply-artifacts.tsx +++ b/src/components/message/reply-artifacts.tsx @@ -15,6 +15,7 @@ import { CommitFileAdditions, CommitFileDeletions, } from "@/components/ai-elements/commit" +import { FilePathContextMenu } from "@/components/shared/file-path-context-menu" import { Tooltip, TooltipContent, @@ -171,63 +172,67 @@ export const ReplyArtifacts = memo(function ReplyArtifacts({ ) return ( -
    openInTabs(file)} > - - - - - - {t("openInEditor")} - - - - {isLocalDesktop() && ( +
    - {t("revealInFolder")} + {t("openInEditor")} - )} -
    + + {isLocalDesktop() && ( + + + + + + {t("revealInFolder")} + + + )} +
    + ) })} @@ -289,112 +294,122 @@ export const ReplyArtifacts = memo(function ReplyArtifacts({ ) const isRemoved = isRemovedFileDiff(file.diff) - // Removed files no longer exist on disk — there is nothing - // to open or reveal, so render a static (non-interactive) - // card that keeps the destructive accent and remove badge. + // Removed files no longer exist on disk — keep copy-path + // via the context menu, but disable external open and skip + // the Codeg/reveal click affordances. if (isRemoved) { return ( -
    - - - - {name} - - {dir && ( - - {dir} +
    + + + + {name} - )} - - - {t("remove")} - -
    + {dir && ( + + {dir} + + )} +
    + + {t("remove")} + +
    + ) } return ( -
    openInTabs(file)} > - - - - - - {t("openInEditor")} - - - - - - - - - {tCommon("viewDiff")} - - + {dir && ( + + {dir} + + )} + + + + + + + + + {t("openInEditor")} + + - {isLocalDesktop() && ( - {t("revealInFolder")} + {tCommon("viewDiff")} - )} -
    + + {isLocalDesktop() && ( + + + + + + {t("revealInFolder")} + + + )} + + ) })} diff --git a/src/components/shared/file-path-context-menu.tsx b/src/components/shared/file-path-context-menu.tsx new file mode 100644 index 000000000..e426a276b --- /dev/null +++ b/src/components/shared/file-path-context-menu.tsx @@ -0,0 +1,295 @@ +"use client" + +/** + * VS Code-style right-click menu for a workspace / session file path. + * + * Used by the per-conversation message navigator and the per-reply artifacts + * card so both surfaces share the same copy / open / mention actions. + * + * Menu shape (items appear only when applicable): + * - Open in Codeg (optional `onOpenInCodeg`) + * - Add to chat (insert `@file` badge into the active composer) + * - Reveal in Finder / Explorer (local desktop) + * - Open with → Default app / VS Code / Cursor (local desktop) + * - Copy Relative Path + * - Copy Absolute Path + * - Copy File Name + */ + +import { + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + type ReactNode, + useCallback, + useMemo, +} from "react" +import { + AppWindow, + AtSign, + ClipboardCopy, + Code2, + Copy, + ExternalLink, + FileCode, + FileType, + FolderOpen, + TextCursorInput, +} from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "@/components/ui/context-menu" +import { useTabStore } from "@/contexts/tab-context" +import { + copyPathText, + openFileWithDefaultApp, + openFileWithExternalEditor, + resolveFilePathTargets, + revealFileInManager, + systemExplorerLabelKey, + type ExternalEditorId, +} from "@/lib/file-path-actions" +import { isLocalDesktop } from "@/lib/platform" +import { toErrorMessage } from "@/lib/app-error" +import { emitAttachFileToSession } from "@/lib/session-attachment-events" + +export interface FilePathContextMenuProps { + /** Agent-reported path (absolute or workspace-relative). */ + filePath: string + /** Active workspace folder; needed for absolute-path resolve & relative copy. */ + folderPath?: string + /** + * When true, reveal / open-with-external actions are disabled (e.g. a deleted + * file has no on-disk target). Copy, open-in-Codeg, and add-to-chat stay + * available (chat still accepts a path mention for deleted files). + */ + externalOpenDisabled?: boolean + /** Primary open action (Codeg tab / session diff). Omitted = hide the item. */ + onOpenInCodeg?: () => void + children: ReactNode +} + +export function FilePathContextMenu({ + filePath, + folderPath, + externalOpenDisabled = false, + onOpenInCodeg, + children, +}: FilePathContextMenuProps) { + const t = useTranslations("Folder.chat.filePathMenu") + const localDesktop = isLocalDesktop() + + const activeSessionTabId = useTabStore((s) => { + const active = s.tabs.find((tab) => tab.id === s.activeTabId) + if (!active || active.kind !== "conversation") return null + return active.id + }) + + const { relativePath, absolutePath, fileName } = useMemo( + () => resolveFilePathTargets(filePath, folderPath), + [filePath, folderPath] + ) + + const explorerLabel = t(systemExplorerLabelKey()) + const canOpenExternally = + localDesktop && !!absolutePath && !externalOpenDisabled + // Composer attach expects a resolvable filesystem path (absolute preferred). + const attachPath = absolutePath ?? relativePath + const canAddToChat = Boolean(activeSessionTabId && attachPath) + + const notifyCopy = useCallback( + async (text: string, successKey: "pathCopied" | "fileNameCopied") => { + const ok = await copyPathText(text) + if (ok) { + toast.success(t(successKey)) + } else { + toast.error(t("copyFailed")) + } + }, + [t] + ) + + const runExternal = useCallback( + async (action: () => Promise) => { + try { + await action() + } catch (error) { + toast.error(t("openFailed"), { + description: toErrorMessage(error), + }) + } + }, + [t] + ) + + const handleReveal = useCallback(() => { + if (!absolutePath) return + void runExternal(() => revealFileInManager(absolutePath)) + }, [absolutePath, runExternal]) + + const handleOpenDefault = useCallback(() => { + if (!absolutePath) return + void runExternal(() => openFileWithDefaultApp(absolutePath)) + }, [absolutePath, runExternal]) + + const handleOpenEditor = useCallback( + (editor: ExternalEditorId) => { + if (!absolutePath) return + void runExternal(() => openFileWithExternalEditor(absolutePath, editor)) + }, + [absolutePath, runExternal] + ) + + /** + * Insert an inline `@file` reference badge into the active conversation + * composer — same event path as the file-tree "Add to session" action. + */ + const handleAddToChat = useCallback(() => { + if (!activeSessionTabId || !attachPath) { + toast.error(t("noActiveConversation")) + return + } + emitAttachFileToSession({ + tabId: activeSessionTabId, + path: attachPath, + }) + toast.success(t("addToChatDone", { label: fileName })) + }, [activeSessionTabId, attachPath, fileName, t]) + + // ConversationDetailPanel wraps the whole chat surface in its own + // ContextMenu (copy selection / export / …). Nested Radix context menus + // both listen on bubble — without stopPropagation the outer menu steals + // the right-click and this file menu never appears. Stop the event on the + // trigger so only this menu opens (same pattern as chat-input). + const stopOuterContextMenu = useCallback((event: ReactMouseEvent) => { + event.stopPropagation() + }, []) + + const stopOuterRightPointer = useCallback((event: ReactPointerEvent) => { + // Parent panel also intercepts right-button pointerdown when text is + // selected; keep the event local so nested file rows stay responsive. + if (event.button === 2) event.stopPropagation() + }, []) + + return ( + + + {children} + + + {onOpenInCodeg && ( + onOpenInCodeg()}> + + {t("openInCodeg")} + + )} + + { + if (!canAddToChat) return + handleAddToChat() + }} + > + + {t("addToChat")} + + + {localDesktop && ( + { + if (!canOpenExternally) return + handleReveal() + }} + > + + {explorerLabel} + + )} + + {localDesktop && ( + + + + {t("openWith")} + + + { + if (!canOpenExternally) return + handleOpenDefault() + }} + > + + {t("openWithDefault")} + + { + if (!canOpenExternally) return + handleOpenEditor("vscode") + }} + > + + {t("openWithVsCode")} + + { + if (!canOpenExternally) return + handleOpenEditor("cursor") + }} + > + + {t("openWithCursor")} + + + + )} + + + + { + void notifyCopy(relativePath, "pathCopied") + }} + > + + {t("copyRelativePath")} + + { + if (!absolutePath) return + void notifyCopy(absolutePath, "pathCopied") + }} + > + + {t("copyAbsolutePath")} + + { + void notifyCopy(fileName, "fileNameCopied") + }} + > + + {t("copyFileName")} + + + + ) +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 414dac485..71945c61c 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2726,6 +2726,26 @@ "remove": "إزالة", "noDiffDataAvailable": "لا توجد بيانات diff متاحة لـ {filePath}" }, + "filePathMenu": { + "openInCodeg": "فتح في Codeg", + "addToChat": "إضافة إلى المحادثة", + "addToChatDone": "تمت إضافة {label} إلى المحادثة", + "noActiveConversation": "لا توجد محادثة نشطة للإضافة إليها", + "openInFinder": "إظهار في Finder", + "openInExplorer": "إظهار في مستكشف الملفات", + "openInFileManager": "إظهار في مدير الملفات", + "openWith": "فتح باستخدام", + "openWithDefault": "التطبيق الافتراضي", + "openWithVsCode": "Visual Studio Code", + "openWithCursor": "Cursor", + "copyRelativePath": "نسخ المسار النسبي", + "copyAbsolutePath": "نسخ المسار المطلق", + "copyFileName": "نسخ اسم الملف", + "pathCopied": "تم نسخ المسار", + "fileNameCopied": "تم نسخ اسم الملف", + "copyFailed": "فشل النسخ", + "openFailed": "فشل فتح الملف" + }, "askQuestion": { "title": "يحتاج الوكيل إلى اختيارك", "subtitle": "أجب ثم أرسل. يمكنك التخطّي في أي وقت.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index ad467e1f6..688f1bcb4 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2726,6 +2726,26 @@ "remove": "Entfernen", "noDiffDataAvailable": "Keine Diff-Daten verfügbar für {filePath}" }, + "filePathMenu": { + "openInCodeg": "In Codeg öffnen", + "addToChat": "Zum Chat hinzufügen", + "addToChatDone": "{label} zum Chat hinzugefügt", + "noActiveConversation": "Kein aktiver Chat zum Hinzufügen", + "openInFinder": "Im Finder anzeigen", + "openInExplorer": "Im Explorer anzeigen", + "openInFileManager": "Im Dateimanager anzeigen", + "openWith": "Öffnen mit", + "openWithDefault": "Standardanwendung", + "openWithVsCode": "Visual Studio Code", + "openWithCursor": "Cursor", + "copyRelativePath": "Relativen Pfad kopieren", + "copyAbsolutePath": "Absoluten Pfad kopieren", + "copyFileName": "Dateinamen kopieren", + "pathCopied": "Pfad kopiert", + "fileNameCopied": "Dateiname kopiert", + "copyFailed": "Kopieren fehlgeschlagen", + "openFailed": "Datei konnte nicht geöffnet werden" + }, "askQuestion": { "title": "Der Agent benötigt deine Auswahl", "subtitle": "Antworten und absenden. Du kannst jederzeit überspringen.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index bdfd937f5..394a54a6b 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2726,6 +2726,26 @@ "remove": "Remove", "noDiffDataAvailable": "No diff data available for {filePath}" }, + "filePathMenu": { + "openInCodeg": "Open in Codeg", + "addToChat": "Add to chat", + "addToChatDone": "Added {label} to chat", + "noActiveConversation": "No active chat to add to", + "openInFinder": "Reveal in Finder", + "openInExplorer": "Reveal in File Explorer", + "openInFileManager": "Reveal in file manager", + "openWith": "Open with", + "openWithDefault": "Default application", + "openWithVsCode": "Visual Studio Code", + "openWithCursor": "Cursor", + "copyRelativePath": "Copy Relative Path", + "copyAbsolutePath": "Copy Absolute Path", + "copyFileName": "Copy File Name", + "pathCopied": "Path copied", + "fileNameCopied": "File name copied", + "copyFailed": "Failed to copy", + "openFailed": "Failed to open file" + }, "askQuestion": { "title": "The agent needs your input", "subtitle": "Answer below, then submit. You can skip anytime.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 26f871e11..133451dbf 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2726,6 +2726,26 @@ "remove": "Quitar", "noDiffDataAvailable": "No hay datos de diff disponibles para {filePath}" }, + "filePathMenu": { + "openInCodeg": "Abrir en Codeg", + "addToChat": "Añadir al chat", + "addToChatDone": "Se añadió {label} al chat", + "noActiveConversation": "No hay un chat activo al que añadir", + "openInFinder": "Mostrar en Finder", + "openInExplorer": "Mostrar en el Explorador", + "openInFileManager": "Mostrar en el gestor de archivos", + "openWith": "Abrir con", + "openWithDefault": "Aplicación predeterminada", + "openWithVsCode": "Visual Studio Code", + "openWithCursor": "Cursor", + "copyRelativePath": "Copiar ruta relativa", + "copyAbsolutePath": "Copiar ruta absoluta", + "copyFileName": "Copiar nombre de archivo", + "pathCopied": "Ruta copiada", + "fileNameCopied": "Nombre de archivo copiado", + "copyFailed": "Error al copiar", + "openFailed": "No se pudo abrir el archivo" + }, "askQuestion": { "title": "El agente necesita tu elección", "subtitle": "Responde y luego envía. Puedes omitir en cualquier momento.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 8b615adab..b356eee69 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2726,6 +2726,26 @@ "remove": "Retirer", "noDiffDataAvailable": "Aucune donnée de diff disponible pour {filePath}" }, + "filePathMenu": { + "openInCodeg": "Ouvrir dans Codeg", + "addToChat": "Ajouter au chat", + "addToChatDone": "{label} ajouté au chat", + "noActiveConversation": "Aucun chat actif auquel ajouter", + "openInFinder": "Afficher dans le Finder", + "openInExplorer": "Afficher dans l'Explorateur", + "openInFileManager": "Afficher dans le gestionnaire de fichiers", + "openWith": "Ouvrir avec", + "openWithDefault": "Application par défaut", + "openWithVsCode": "Visual Studio Code", + "openWithCursor": "Cursor", + "copyRelativePath": "Copier le chemin relatif", + "copyAbsolutePath": "Copier le chemin absolu", + "copyFileName": "Copier le nom du fichier", + "pathCopied": "Chemin copié", + "fileNameCopied": "Nom du fichier copié", + "copyFailed": "Échec de la copie", + "openFailed": "Impossible d'ouvrir le fichier" + }, "askQuestion": { "title": "L'agent attend votre choix", "subtitle": "Répondez puis envoyez. Vous pouvez ignorer à tout moment.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index a4ff328ae..276047515 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2726,6 +2726,26 @@ "remove": "削除", "noDiffDataAvailable": "{filePath} の差分データがありません" }, + "filePathMenu": { + "openInCodeg": "Codeg で開く", + "addToChat": "チャットに追加", + "addToChatDone": "{label} をチャットに追加しました", + "noActiveConversation": "追加先のチャットがありません", + "openInFinder": "Finder で表示", + "openInExplorer": "エクスプローラーで表示", + "openInFileManager": "ファイルマネージャーで表示", + "openWith": "プログラムから開く", + "openWithDefault": "既定のアプリ", + "openWithVsCode": "Visual Studio Code", + "openWithCursor": "Cursor", + "copyRelativePath": "相対パスをコピー", + "copyAbsolutePath": "絶対パスをコピー", + "copyFileName": "ファイル名をコピー", + "pathCopied": "パスをコピーしました", + "fileNameCopied": "ファイル名をコピーしました", + "copyFailed": "コピーに失敗しました", + "openFailed": "ファイルを開けませんでした" + }, "askQuestion": { "title": "エージェントが選択を求めています", "subtitle": "回答したら送信してください。いつでもスキップできます", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index b99b77753..1e542b612 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2726,6 +2726,26 @@ "remove": "제거", "noDiffDataAvailable": "{filePath}에 대한 diff 데이터가 없습니다" }, + "filePathMenu": { + "openInCodeg": "Codeg에서 열기", + "addToChat": "대화에 추가", + "addToChatDone": "{label}을(를) 대화에 추가했습니다", + "noActiveConversation": "추가할 활성 대화가 없습니다", + "openInFinder": "Finder에서 보기", + "openInExplorer": "탐색기에서 보기", + "openInFileManager": "파일 관리자에서 보기", + "openWith": "연결 프로그램", + "openWithDefault": "기본 앱", + "openWithVsCode": "Visual Studio Code", + "openWithCursor": "Cursor", + "copyRelativePath": "상대 경로 복사", + "copyAbsolutePath": "절대 경로 복사", + "copyFileName": "파일 이름 복사", + "pathCopied": "경로가 복사되었습니다", + "fileNameCopied": "파일 이름이 복사되었습니다", + "copyFailed": "복사에 실패했습니다", + "openFailed": "파일을 열 수 없습니다" + }, "askQuestion": { "title": "에이전트가 선택을 요청합니다", "subtitle": "답변 후 제출하세요. 언제든 건너뛸 수 있습니다", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 572880228..540d3defa 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2726,6 +2726,26 @@ "remove": "Remover", "noDiffDataAvailable": "Nenhum dado de diff disponível para {filePath}" }, + "filePathMenu": { + "openInCodeg": "Abrir no Codeg", + "addToChat": "Adicionar ao chat", + "addToChatDone": "{label} adicionado à conversa", + "noActiveConversation": "Nenhuma conversa ativa para adicionar", + "openInFinder": "Mostrar no Finder", + "openInExplorer": "Mostrar no Explorador", + "openInFileManager": "Mostrar no gerenciador de arquivos", + "openWith": "Abrir com", + "openWithDefault": "Aplicativo padrão", + "openWithVsCode": "Visual Studio Code", + "openWithCursor": "Cursor", + "copyRelativePath": "Copiar caminho relativo", + "copyAbsolutePath": "Copiar caminho absoluto", + "copyFileName": "Copiar nome do arquivo", + "pathCopied": "Caminho copiado", + "fileNameCopied": "Nome do arquivo copiado", + "copyFailed": "Falha ao copiar", + "openFailed": "Falha ao abrir o arquivo" + }, "askQuestion": { "title": "O agente precisa da sua escolha", "subtitle": "Responda e envie. Você pode pular a qualquer momento.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f96243f57..161234879 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2726,6 +2726,26 @@ "remove": "移除", "noDiffDataAvailable": "未找到 {filePath} 的差异数据" }, + "filePathMenu": { + "openInCodeg": "在 Codeg 中打开", + "addToChat": "添加到会话", + "addToChatDone": "已将 {label} 加入会话", + "noActiveConversation": "当前没有可添加的会话", + "openInFinder": "在访达中显示", + "openInExplorer": "在资源管理器中显示", + "openInFileManager": "在文件管理器中显示", + "openWith": "打开方式", + "openWithDefault": "默认应用", + "openWithVsCode": "Visual Studio Code", + "openWithCursor": "Cursor", + "copyRelativePath": "复制相对路径", + "copyAbsolutePath": "复制绝对路径", + "copyFileName": "复制文件名", + "pathCopied": "已复制路径", + "fileNameCopied": "已复制文件名", + "copyFailed": "复制失败", + "openFailed": "打开文件失败" + }, "askQuestion": { "title": "智能体需要你的选择", "subtitle": "回答后点击提交,可随时跳过", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 16b45e45e..067ba64c3 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2726,6 +2726,26 @@ "remove": "移除", "noDiffDataAvailable": "找不到 {filePath} 的差異資料" }, + "filePathMenu": { + "openInCodeg": "在 Codeg 中開啟", + "addToChat": "加入對話", + "addToChatDone": "已將 {label} 加入對話", + "noActiveConversation": "目前沒有可加入的對話", + "openInFinder": "在 Finder 中顯示", + "openInExplorer": "在檔案總管中顯示", + "openInFileManager": "在檔案管理員中顯示", + "openWith": "開啟方式", + "openWithDefault": "預設應用程式", + "openWithVsCode": "Visual Studio Code", + "openWithCursor": "Cursor", + "copyRelativePath": "複製相對路徑", + "copyAbsolutePath": "複製絕對路徑", + "copyFileName": "複製檔案名稱", + "pathCopied": "已複製路徑", + "fileNameCopied": "已複製檔案名稱", + "copyFailed": "複製失敗", + "openFailed": "開啟檔案失敗" + }, "askQuestion": { "title": "智能體需要你的選擇", "subtitle": "回答後點擊提交,可隨時跳過", diff --git a/src/lib/file-path-actions.test.ts b/src/lib/file-path-actions.test.ts new file mode 100644 index 000000000..debd36cf2 --- /dev/null +++ b/src/lib/file-path-actions.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest" +import { + EXTERNAL_EDITOR_OPEN_WITH, + resolveFilePathTargets, + systemExplorerLabelKey, +} from "./file-path-actions" + +describe("resolveFilePathTargets", () => { + it("resolves relative and absolute forms for a workspace file", () => { + const targets = resolveFilePathTargets("src/a.ts", "/repo") + expect(targets.relativePath).toBe("src/a.ts") + expect(targets.absolutePath).toBe("/repo/src/a.ts") + expect(targets.fileName).toBe("a.ts") + }) + + it("uses native separators on Windows folders", () => { + const targets = resolveFilePathTargets("src/a.ts", "C:\\repo") + expect(targets.relativePath).toBe("src/a.ts") + expect(targets.absolutePath).toBe("C:\\repo\\src\\a.ts") + expect(targets.fileName).toBe("a.ts") + }) + + it("keeps absolute agent paths and strips folder prefix when inside", () => { + const targets = resolveFilePathTargets("/repo/src/a.ts", "/repo") + expect(targets.relativePath).toBe("src/a.ts") + expect(targets.absolutePath).toBe("/repo/src/a.ts") + }) +}) + +describe("systemExplorerLabelKey", () => { + it("picks platform-specific keys", () => { + expect(systemExplorerLabelKey("MacIntel Macintosh")).toBe("openInFinder") + expect(systemExplorerLabelKey("Win32 Windows NT")).toBe("openInExplorer") + expect(systemExplorerLabelKey("Linux x86_64")).toBe("openInFileManager") + }) +}) + +describe("EXTERNAL_EDITOR_OPEN_WITH", () => { + it("maps editors to CLI names the opener plugin understands", () => { + expect(EXTERNAL_EDITOR_OPEN_WITH.vscode).toBe("code") + expect(EXTERNAL_EDITOR_OPEN_WITH.cursor).toBe("cursor") + }) +}) diff --git a/src/lib/file-path-actions.ts b/src/lib/file-path-actions.ts new file mode 100644 index 000000000..7b09542e7 --- /dev/null +++ b/src/lib/file-path-actions.ts @@ -0,0 +1,78 @@ +/** + * Shared actions for VS Code-style path context menus on changed-file rows + * (message navigator + reply artifacts, and any future call sites). + */ + +import { + fileNameOf, + toFolderRelativePath, + toNativeAbsoluteFilePath, +} from "@/lib/file-path-display" +import { isLocalDesktop, openPath, revealItemInDir } from "@/lib/platform" +import { copyTextFromMenu } from "@/lib/utils" + +export type ExternalEditorId = "vscode" | "cursor" + +/** CLI / opener names passed to `openPath(path, openWith)`. */ +export const EXTERNAL_EDITOR_OPEN_WITH: Record = { + vscode: "code", + cursor: "cursor", +} + +export function resolveFilePathTargets( + filePath: string, + folderPath?: string +): { + relativePath: string + absolutePath: string | null + fileName: string +} { + const relativePath = toFolderRelativePath(filePath, folderPath) + const absolutePath = toNativeAbsoluteFilePath(filePath, folderPath) + return { + relativePath, + absolutePath, + fileName: fileNameOf(relativePath), + } +} + +export async function copyPathText(text: string): Promise { + // Defer until the context menu closes so the execCommand fallback works in + // non-secure web contexts (same pattern as the file-tree copy action). + return copyTextFromMenu(text) +} + +export async function revealFileInManager(absolutePath: string): Promise { + if (!isLocalDesktop()) return + await revealItemInDir(absolutePath) +} + +export async function openFileWithDefaultApp( + absolutePath: string +): Promise { + if (!isLocalDesktop()) return + await openPath(absolutePath) +} + +export async function openFileWithExternalEditor( + absolutePath: string, + editor: ExternalEditorId +): Promise { + if (!isLocalDesktop()) return + await openPath(absolutePath, EXTERNAL_EDITOR_OPEN_WITH[editor]) +} + +/** Platform-aware label key suffix for the system file manager entry. */ +export function systemExplorerLabelKey( + platformHint?: string +): "openInFinder" | "openInExplorer" | "openInFileManager" { + const platform = ( + platformHint ?? + (typeof navigator !== "undefined" + ? `${navigator.platform} ${navigator.userAgent}` + : "") + ).toLowerCase() + if (platform.includes("mac")) return "openInFinder" + if (platform.includes("win")) return "openInExplorer" + return "openInFileManager" +} diff --git a/src/lib/file-path-display.test.ts b/src/lib/file-path-display.test.ts index a2f9aa0ef..b0e47a657 100644 --- a/src/lib/file-path-display.test.ts +++ b/src/lib/file-path-display.test.ts @@ -6,6 +6,7 @@ import { isRemovedFileDiff, toAbsoluteFilePath, toFolderRelativePath, + toNativeAbsoluteFilePath, } from "./file-path-display" describe("isAddedFileDiff", () => { @@ -105,6 +106,36 @@ describe("toAbsoluteFilePath", () => { }) }) +describe("toNativeAbsoluteFilePath", () => { + it("joins relative paths with the folder's native separator", () => { + expect(toNativeAbsoluteFilePath("src/a.ts", "/repo")).toBe("/repo/src/a.ts") + expect(toNativeAbsoluteFilePath("src/a.ts", "C:\\repo")).toBe( + "C:\\repo\\src\\a.ts" + ) + expect(toNativeAbsoluteFilePath("./src/a.ts", "C:\\repo\\")).toBe( + "C:\\repo\\src\\a.ts" + ) + }) + + it("normalizes absolute Windows paths to backslashes when appropriate", () => { + expect(toNativeAbsoluteFilePath("C:/repo/a.ts", "C:\\repo")).toBe( + "C:\\repo\\a.ts" + ) + expect(toNativeAbsoluteFilePath("C:\\repo\\a.ts")).toBe("C:\\repo\\a.ts") + }) + + it("keeps POSIX absolute paths slash-normalized", () => { + expect(toNativeAbsoluteFilePath("/repo/src/a.ts", "/repo")).toBe( + "/repo/src/a.ts" + ) + }) + + it("returns null when a relative path has no folder", () => { + expect(toNativeAbsoluteFilePath("src/a.ts")).toBeNull() + expect(toNativeAbsoluteFilePath("src/a.ts", "")).toBeNull() + }) +}) + describe("toFolderRelativePath / fileNameOf", () => { it("strips the folder prefix and extracts the file name", () => { const rel = toFolderRelativePath("/repo/src/a.ts", "/repo") diff --git a/src/lib/file-path-display.ts b/src/lib/file-path-display.ts index 32c0758c5..47042407e 100644 --- a/src/lib/file-path-display.ts +++ b/src/lib/file-path-display.ts @@ -4,6 +4,8 @@ * card). Extracted so both call sites stay in sync. */ +import { joinFsPath } from "@/lib/path-utils" + /** True when a unified diff represents a file deletion. */ export function isRemovedFileDiff(diff: string | null): boolean { if (!diff) return false @@ -109,3 +111,34 @@ export function fileNameOf(displayPath: string): string { const lastSlash = displayPath.lastIndexOf("/") return lastSlash >= 0 ? displayPath.slice(lastSlash + 1) : displayPath } + +/** + * Absolute path using the OS's native separators — for clipboard copy and for + * handing paths to the shell / opener. Relative agent paths are joined onto + * `folderPath` via {@link joinFsPath} so Windows workspaces keep `\`. + * + * Unlike {@link toAbsoluteFilePath} (always slash-normalized for UI compare), + * this preserves Windows backslashes when the workspace path uses them. + * Returns null when the result cannot be made absolute. + */ +export function toNativeAbsoluteFilePath( + filePath: string, + folderPath?: string +): string | null { + const trimmed = filePath.trim() + if (!trimmed) return null + + if (isAbsoluteFilePath(trimmed)) { + // Prefer the folder's separator style when known so clipboard matches + // Explorer/Finder paths users already copy from the OS. + if (folderPath?.includes("\\") || WINDOWS_ABSOLUTE_PATH.test(trimmed)) { + return trimmed.replace(/\//g, "\\") + } + return normalizeSlashPath(trimmed) + } + + if (!folderPath) return null + const rel = normalizeSlashPath(trimmed).replace(/^\.\/+/, "") + if (!rel) return null + return joinFsPath(folderPath, rel) +} diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 2a7f58598..3911a649c 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -81,14 +81,18 @@ export async function openUrl(url: string): Promise { } /** - * Open a path in the system file manager (desktop only). - * No-op in web mode. + * Open a path with the system default app, or a specific app when `openWith` + * is set (desktop local only). No-op in web / remote-desktop modes. + * + * `openWith` is the program name passed to the OS opener (e.g. `"code"`, + * `"cursor"`, `"vlc"`). When omitted, the default handler for the path type + * is used. */ -export async function openPath(path: string): Promise { +export async function openPath(path: string, openWith?: string): Promise { if (isDesktop() && getActiveRemoteConnectionId() === null) { const { openPath: tauriOpenPath } = await import("@tauri-apps/plugin-opener") - await tauriOpenPath(path) + await tauriOpenPath(path, openWith) } } From beb47a13e0eaf29e0a5c797c64de4d9618a413fe Mon Sep 17 00:00:00 2001 From: "2859521944@qq.com" <2859521944@qq.com> Date: Thu, 23 Jul 2026 01:33:22 +0800 Subject: [PATCH 2/8] fix(chat): file-path menu on tool rows and Read title tooltips Follow-up to the changed-file context menu: claim right-clicks on tool headers so the conversation panel menu no longer steals them, show full absolute paths on Read/Edit title hover, and restore path links plus the same menu inside expanded Read/Write cards via shared path lookup. --- .../ai-elements/link-safety.test.tsx | 9 + src/components/ai-elements/link-safety.tsx | 29 +- src/components/ai-elements/tool.tsx | 15 +- .../conversation-detail-panel.tsx | 32 ++ .../message/content-parts-renderer.tsx | 387 +++++++++++++++--- .../message/conversation-message-nav.tsx | 2 +- .../shared/file-path-context-menu.tsx | 63 +-- 7 files changed, 443 insertions(+), 94 deletions(-) diff --git a/src/components/ai-elements/link-safety.test.tsx b/src/components/ai-elements/link-safety.test.tsx index f1701227d..f87308fc7 100644 --- a/src/components/ai-elements/link-safety.test.tsx +++ b/src/components/ai-elements/link-safety.test.tsx @@ -28,6 +28,7 @@ vi.mock("sonner", () => ({ vi.mock("@/lib/platform", () => ({ openUrl: mocks.openUrl, + isLocalDesktop: () => false, })) vi.mock("@/lib/transport", () => ({ @@ -49,6 +50,14 @@ vi.mock("@/contexts/workspace-context", () => ({ }), })) +// FilePathLink wraps FilePathContextMenu, which reads the active conversation +// tab for "Add to chat". Stub a stable empty store so unit tests stay pure. +vi.mock("@/contexts/tab-context", () => ({ + useTabStore: ( + selector: (s: { tabs: never[]; activeTabId: null }) => unknown + ) => selector({ tabs: [], activeTabId: null }), +})) + function LinkSafetyHarness({ url }: { url: string }) { const linkSafety = useStreamdownLinkSafety() const [open, setOpen] = useState(false) diff --git a/src/components/ai-elements/link-safety.tsx b/src/components/ai-elements/link-safety.tsx index c532670eb..833e0a542 100644 --- a/src/components/ai-elements/link-safety.tsx +++ b/src/components/ai-elements/link-safety.tsx @@ -8,10 +8,14 @@ import { getActiveRemoteConnectionId, isDesktop } from "@/lib/transport" import { toErrorMessage } from "@/lib/app-error" import type { LinkSafetyConfig, LinkSafetyModalProps } from "streamdown" import { toast } from "sonner" +import { FilePathContextMenu } from "@/components/shared/file-path-context-menu" import { useActiveFolder } from "@/contexts/active-folder-context" import { useWorkspaceActions } from "@/contexts/workspace-context" import { isHomeRelativePath } from "@/lib/file-open-target" -import { isAbsoluteFilePath } from "@/lib/file-path-display" +import { + isAbsoluteFilePath, + toNativeAbsoluteFilePath, +} from "@/lib/file-path-display" import { cn } from "@/lib/utils" interface LocalFileTarget { @@ -388,6 +392,9 @@ function resolveToolFilePath(rawPath: string): string | null { /** * Clickable file-path label that routes the file into the workspace file panel. + * Right-click opens the shared VS Code-style path menu (copy / reveal / open + * with / add-to-chat) — same affordance as the message-nav and reply-artifact + * changed-file rows, so tool reads/writes and diff headers stay consistent. */ export function FilePathLink({ filePath, @@ -443,14 +450,26 @@ export function FilePathLink({ }) }, [filePath, folderPath, line, openFilePreview, t]) + // Always prefer a native absolute path for hover tooltips (matches Edit headers). + const absoluteTitle = + title ?? + toNativeAbsoluteFilePath(filePath, folderPath ?? undefined) ?? + filePath + return ( - + - + ) } diff --git a/src/components/ai-elements/tool.tsx b/src/components/ai-elements/tool.tsx index a6bacf4d9..16a66622d 100644 --- a/src/components/ai-elements/tool.tsx +++ b/src/components/ai-elements/tool.tsx @@ -38,6 +38,11 @@ export type ToolPart = ToolUIPart | DynamicToolUIPart export type ToolHeaderProps = { title?: ReactNode + /** + * Native HTML tooltip for the title span (e.g. absolute file path for + * Read/Edit/Write tools). Falls back to string `title` when omitted. + */ + titleTooltip?: string titleSuffix?: ReactNode icon?: ReactNode className?: string @@ -70,6 +75,7 @@ export const getStatusBadge = (status: ToolPart["state"], label: string) => ( export const ToolHeader = ({ className, title, + titleTooltip, titleSuffix, icon, type, @@ -94,9 +100,16 @@ export const ToolHeader = ({ : state === "output-denied" ? t("status.outputDenied") : t("status.outputError") + const resolvedTitle = title ?? derivedName + const nativeTitle = + titleTooltip ?? + (typeof resolvedTitle === "string" ? resolvedTitle : undefined) return ( } - {title ?? derivedName} + {resolvedTitle} {titleSuffix ? {titleSuffix} : null} {getStatusBadge(state, statusLabel)} diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index db2e299eb..cd9f3bea3 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -7,6 +7,7 @@ import { useMemo, useRef, useState, + type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, } from "react" import { @@ -1804,6 +1805,14 @@ export function ConversationDetailPanel() { const handleContextMenuTriggerPointerDown = useCallback( (event: ReactPointerEvent) => { if (event.button !== 2) return + // File-path menus (message nav / reply artifacts) own right-clicks on + // their rows — don't let selection-preservation logic interfere. + if ( + event.target instanceof Element && + event.target.closest("[data-file-path-menu]") + ) { + return + } const selection = window.getSelection() if (selection && !selection.isCollapsed) { event.preventDefault() @@ -1812,6 +1821,28 @@ export function ConversationDetailPanel() { [] ) + /** + * Nested file-path ContextMenus sit inside this panel menu. Radix runs the + * trigger's user `onContextMenu` first and skips its own open when + * `defaultPrevented` — so yield ownership when the event originated under + * `[data-file-path-menu]` (message navigator + reply artifact rows). + */ + const handlePanelContextMenu = useCallback( + (event: ReactMouseEvent) => { + if ( + event.target instanceof Element && + event.target.closest("[data-file-path-menu]") + ) { + // Radix runs this user handler before opening the panel menu; when + // defaultPrevented it skips open. Also stop bubble so nothing else + // competes with the nested file-path menu. + event.preventDefault() + event.stopPropagation() + } + }, + [] + ) + useEffect(() => { const handler = () => { if (!isContextMenuOpenRef.current) return @@ -2064,6 +2095,7 @@ export function ConversationDetailPanel() {
    {/* Stable wrapper across canTile flip — otherwise sibling tabs remount and a live streaming response is torn down. */} diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 941d5edc3..d360def0b 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -1,5 +1,13 @@ -import { memo, useMemo, useState, type ReactNode } from "react" +import { memo, useCallback, useMemo, useState, type ReactNode } from "react" +import { useActiveFolder } from "@/contexts/active-folder-context" +import { useWorkspaceActions } from "@/contexts/workspace-context" import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" +import { FilePathContextMenu } from "@/components/shared/file-path-context-menu" +import { + normalizeSlashPath, + toFolderRelativePath, + toNativeAbsoluteFilePath, +} from "@/lib/file-path-display" import { classifyToolKind, TOOL_KIND_ORDER, @@ -217,9 +225,94 @@ function isLikelyIdField(key: string): boolean { ) } -/** Shorten an absolute path to its last 2 segments. */ +/** Shorten a path to its last 2 segments (handles `/` and `\`). */ function shortPath(p: string): string { - return p.split("/").slice(-2).join("/") + return p.replace(/\\/g, "/").split("/").filter(Boolean).slice(-2).join("/") +} + +/** + * Strip a tool-title prefix (English or localized) to recover the file path + * segment, e.g. "Read AGENTS.md" / "读取 AGENTS.md" → "AGENTS.md". + */ +function pathFromToolTitle(title: string | null | undefined): string | null { + if (!title) return null + const trimmed = title.trim() + // English prefixes from deriveToolTitle + const en = trimmed.match(/^(?:Read|Edit|Write|NotebookEdit)\s+(.+)$/i) + if (en?.[1]?.trim()) return en[1].trim() + // Common localized prefixes (zh-CN / zh-TW / ja / ko …) + const localized = trimmed.match( + /^(?:读取|讀取|编辑|編輯|写入|寫入|読み取り|읽기)\s+(.+)$/u + ) + if (localized?.[1]?.trim()) return localized[1].trim() + // i18n templates sometimes put the path only: still a plausible file path + if (/[/\\]/.test(trimmed) || /\.[A-Za-z0-9]{1,12}$/.test(trimmed)) { + // Avoid matching pure status words + if (!/^(Read|Edit|Write|Command|TodoWrite)$/i.test(trimmed)) { + return trimmed + } + } + return null +} + +/** + * Pull the primary file path out of a tool input/output envelope (same fields + * as {@link deriveToolTitle}). Used for full-path tooltips on truncated titles + * and for the outer tool-row context menu. + * + * `displayTitle` must be the **pre-localization** English title when possible + * (e.g. "Read AGENTS.md"), because localized titles vary by language. + */ +function normalizeToolPathValue(direct: string): string { + if (/^file:\/\//i.test(direct)) { + try { + const u = new URL(direct) + let p = decodeURIComponent(u.pathname) + // Windows: /C:/Users/... → C:/Users/... + if (/^\/[A-Za-z]:\//.test(p)) p = p.slice(1) + return p + } catch { + return direct.replace(/^file:\/\//i, "") + } + } + return direct +} + +function extractToolFilePath( + toolName: string, + input: string | null, + output?: string | null, + displayTitle?: string | null +): string | null { + const titleSource = input ?? output ?? null + const parsedInput = input ? tryParseJson(input) : null + const parsedOutput = output ? tryParseJson(output) : null + const parsed = parsedInput ?? parsedOutput + const name = toolName.toLowerCase() + + const direct = lookupToolFilePathField(parsed, input, output) + if (direct) return normalizeToolPathValue(direct) + + if ( + titleSource && + (name === "apply_patch" || + name === "edit" || + name === "edit_file" || + name === "editfile") + ) { + const files = parseApplyPatchFilesFromUnknownInput(titleSource, parsed) + if (files.length === 1) { + const file = files[0] + const path = + file.op === "move" && file.to + ? file.to + : (file.from ?? file.to ?? file.path) + if (path) return path + } + const fromDiff = extractPathFromDiffText(titleSource) + if (fromDiff) return fromDiff + } + return pathFromToolTitle(displayTitle) } /** Truncate text to maxLen, appending "…" if truncated. */ @@ -867,6 +960,75 @@ function getToolIcon( // ── title derivation ────────────────────────────────────────────────── +/** True for tools that primarily target a single file path. */ +function isFilePathToolName(toolName: string): boolean { + const name = toolName.toLowerCase() + return ( + name === "read" || + name === "read file" || + name === "read_file" || + name === "read_text_file" || + name === "readfile" || + name === "view" || + name === "write" || + name === "write_file" || + name === "writefile" || + name === "notebookedit" || + name === "edit" || + name === "edit_file" || + name === "editfile" || + name === "apply_patch" + ) +} + +/** + * Shared field lookup used by both title derivation and path tooltips / menus. + * Keep a single implementation so "Read AGENTS.md" can never appear without a + * resolvable path for hover + right-click. + */ +function lookupToolStringField( + key: string, + parsed: Record | null, + input: string | null, + output?: string | null +): string | null { + const nested = findStringFieldDeep(parsed, key) + if (nested) return nested + if (input) { + const fromInput = extractJsonField(input, key) + if (fromInput) return fromInput + } + if (output) { + const fromOutput = extractJsonField(output, key) + if (fromOutput) return fromOutput + } + return null +} + +const TOOL_FILE_PATH_KEYS = [ + "file_path", + "filePath", + "target_file", + "targetFile", + "filename", + "file", + "path", + "notebook_path", + "uri", +] as const + +function lookupToolFilePathField( + parsed: Record | null, + input: string | null, + output?: string | null +): string | null { + for (const key of TOOL_FILE_PATH_KEYS) { + const value = lookupToolStringField(key, parsed, input, output) + if (value) return value + } + return null +} + function deriveToolTitle( toolName: string, input: string | null, @@ -879,19 +1041,8 @@ function deriveToolTitle( const parsedOutput = output ? tryParseJson(output) : null const parsed = parsedInput ?? parsedOutput - const getField = (key: string): string | null => { - const nested = findStringFieldDeep(parsed, key) - if (nested) return nested - if (input) { - const fromInput = extractJsonField(input, key) - if (fromInput) return fromInput - } - if (output) { - const fromOutput = extractJsonField(output, key) - if (fromOutput) return fromOutput - } - return null - } + const getField = (key: string): string | null => + lookupToolStringField(key, parsed, input, output) // Cline: attempt_completion — show result summary as title if (name === "attempt_completion") { @@ -903,20 +1054,26 @@ function deriveToolTitle( return "Completion" } - // File-based tools - const filePath = - getField("file_path") ?? - getField("filePath") ?? - getField("target_file") ?? - getField("targetFile") ?? - getField("filename") ?? - getField("path") ?? - getField("notebook_path") + // File-based tools — use the shared path lookup (same as extractToolFilePath) + const filePath = lookupToolFilePathField(parsed, input, output) if (filePath) { const sp = shortPath(filePath) - if (name === "read" || name === "read file") return `Read ${sp}` - if (name === "edit") return `Edit ${sp}` - if (name === "write") return `Write ${sp}` + if ( + name === "read" || + name === "read file" || + name === "read_file" || + name === "read_text_file" || + name === "readfile" || + name === "view" + ) { + return `Read ${sp}` + } + if (name === "edit" || name === "edit_file" || name === "editfile") { + return `Edit ${sp}` + } + if (name === "write" || name === "write_file" || name === "writefile") { + return `Write ${sp}` + } if (name === "notebookedit") return `NotebookEdit ${sp}` } @@ -1396,15 +1553,25 @@ function FileToolInput({ toolName, input, output, + rawInput, }: { toolName: string input: Record output?: string | null + /** Original JSON string — used when path is only recoverable via regex. */ + rawInput?: string | null }) { const t = useTranslations("Folder.chat.contentParts") + const { activeFolder: folder } = useActiveFolder() + const folderPath = folder?.path const name = toolName.toLowerCase() + // Same deep lookup as tool-row title / context menu (top-level str() missed + // nested `{ input: { file_path } }` envelopes used by some read tools). const filePath = - str(input, "file_path") ?? str(input, "path") ?? str(input, "notebook_path") + lookupToolFilePathField(input, rawInput ?? null, output) ?? + str(input, "file_path") ?? + str(input, "path") ?? + str(input, "notebook_path") const content = str(input, "content") const newSource = str(input, "new_source") const offset = num(input, "offset") @@ -1412,7 +1579,21 @@ function FileToolInput({ const pages = str(input, "pages") const cellType = str(input, "cell_type") const editMode = str(input, "edit_mode") - const isRead = name === "read" || name === "read file" + const isRead = + name === "read" || + name === "read file" || + name === "read_file" || + name === "read_text_file" || + name === "readfile" || + name === "view" + + // Match edit/diff headers: short display path + absolute native path tooltip. + const displayPath = filePath + ? toFolderRelativePath(filePath, folderPath) + : null + const absoluteTitle = filePath + ? (toNativeAbsoluteFilePath(filePath, folderPath) ?? filePath) + : null const badges: string[] = [] if (offset != null) badges.push(t("offset", { offset })) @@ -1441,9 +1622,10 @@ function FileToolInput({ {filePath ? ( - {filePath} + {displayPath ?? filePath} ) : ( @@ -1784,6 +1966,32 @@ function StructuredToolInput({ } } + // Read / write file tools: always try the file card (even when JSON is + // truncated / unparsable) so path link + context menu still work. + if (isFilePathToolName(name) && name !== "edit" && name !== "apply_patch") { + if (parsed) { + return ( + + ) + } + const recoveredPath = extractToolFilePath(toolName, input, output) + if (recoveredPath) { + return ( + + ) + } + } + if (!parsed) { return (
    @@ -1835,13 +2043,7 @@ function StructuredToolInput({
       }
       if (name === "bash" || name === "exec_command")
         return 
    -  if (
    -    name === "read" ||
    -    name === "read file" ||
    -    name === "write" ||
    -    name === "notebookedit"
    -  )
    -    return 
    +  // read/write already handled above (with rawInput recovery)
       if (name === "glob" || name === "grep")
         return 
       if (name === "webfetch" || name === "websearch")
    @@ -2103,6 +2305,8 @@ const ToolCallPart = memo(function ToolCallPart({
       part: Extract
     }) {
       const t = useTranslations("Folder.chat.contentParts")
    +  const { activeFolder: folder } = useActiveFolder()
    +  const { openFilePreview } = useWorkspaceActions()
       const [manualOpen, setManualOpen] = useState(false)
       const normalizedToolName = useMemo(
         () => normalizeToolName(part.toolName),
    @@ -2125,28 +2329,65 @@ const ToolCallPart = memo(function ToolCallPart({
             : null,
         [isCommandTool, part.output, part.errorText]
       )
    -  const title = useMemo(() => {
    -    const rawTitle =
    +  // Keep the English-derived title for path extraction; localization is
    +  // display-only and must not feed extractToolFilePath (zh "读取 x" breaks
    +  // English-only fallbacks and previously left the tooltip as "Read x").
    +  const rawToolTitle = useMemo(
    +    () =>
           deriveToolTitle(
             normalizedToolName,
             part.input,
             part.output ?? part.errorText ?? null
           ) ??
           sanitizeLiveTitle(part.displayTitle) ??
    -      null
    -    return localizeDerivedToolTitle(rawTitle, ((key, values) =>
    -      t(key as never, values as never)) as (
    -      key: string,
    -      values?: Record
    -    ) => string)
    -  }, [
    -    normalizedToolName,
    -    part.input,
    -    part.output,
    -    part.errorText,
    -    part.displayTitle,
    -    t,
    -  ])
    +      null,
    +    [
    +      normalizedToolName,
    +      part.input,
    +      part.output,
    +      part.errorText,
    +      part.displayTitle,
    +    ]
    +  )
    +  const title = useMemo(
    +    () =>
    +      localizeDerivedToolTitle(rawToolTitle, ((key, values) =>
    +        t(key as never, values as never)) as (
    +        key: string,
    +        values?: Record
    +      ) => string),
    +    [rawToolTitle, t]
    +  )
    +  // Path for tool-row context menu + full-path tooltip on the collapsed header.
    +  const toolFilePath = useMemo(
    +    () =>
    +      extractToolFilePath(
    +        normalizedToolName,
    +        part.input,
    +        part.output ?? part.errorText ?? null,
    +        rawToolTitle ?? part.displayTitle ?? null
    +      ),
    +    [
    +      normalizedToolName,
    +      part.input,
    +      part.output,
    +      part.errorText,
    +      rawToolTitle,
    +      part.displayTitle,
    +    ]
    +  )
    +  const titleTooltip = useMemo(() => {
    +    if (!toolFilePath) return undefined
    +    // Prefer absolute native path so Read rows match Edit's hover behavior.
    +    const abs = toNativeAbsoluteFilePath(toolFilePath, folder?.path)
    +    if (abs) return abs
    +    return toFolderRelativePath(toolFilePath, folder?.path) || toolFilePath
    +  }, [toolFilePath, folder?.path])
    +
    +  const openToolFileInCodeg = useCallback(() => {
    +    if (!toolFilePath) return
    +    void openFilePreview(normalizeSlashPath(toolFilePath))
    +  }, [openFilePreview, toolFilePath])
       const lineChangeStats = useMemo(() => {
         if (toolNameLower !== "edit" && toolNameLower !== "apply_patch") {
           return null
    @@ -2470,16 +2711,38 @@ const ToolCallPart = memo(function ToolCallPart({
     
       const open = (isRunning && (isCommandTool || hasLiveOutput)) || manualOpen
     
    +  const toolHeader = (
    +    
    +  )
    +
       return (
         
    -      
    +      {toolFilePath ? (
    +        // Outer native `title` div: browsers only show the element under the
    +        // cursor's own title attribute (not ancestors). Put the absolute path
    +        // on BOTH this div and the context-menu trigger + CollapsibleTrigger.
    +        
    + + {toolHeader} + +
    + ) : ( + toolHeader + )} {part.input && (!isCommandTool || !shouldRenderCommandTerminal) && ( +
  • void + /** + * Native HTML tooltip for the trigger surface (e.g. absolute path on a + * tool-row wrapper). Applied on the trigger element that receives hover. + */ + title?: string + /** + * Merge trigger props onto `children` (must be a single element that accepts + * a ref). Use for tool headers (`CollapsibleTrigger`) so the whole row owns + * the right-click without an extra wrapping span. + */ + asChild?: boolean + className?: string children: ReactNode } @@ -82,6 +94,9 @@ export function FilePathContextMenu({ folderPath, externalOpenDisabled = false, onOpenInCodeg, + title, + asChild = false, + className, children, }: FilePathContextMenuProps) { const t = useTranslations("Folder.chat.filePathMenu") @@ -101,7 +116,6 @@ export function FilePathContextMenu({ const explorerLabel = t(systemExplorerLabelKey()) const canOpenExternally = localDesktop && !!absolutePath && !externalOpenDisabled - // Composer attach expects a resolvable filesystem path (absolute preferred). const attachPath = absolutePath ?? relativePath const canAddToChat = Boolean(activeSessionTabId && attachPath) @@ -148,10 +162,6 @@ export function FilePathContextMenu({ [absolutePath, runExternal] ) - /** - * Insert an inline `@file` reference badge into the active conversation - * composer — same event path as the file-tree "Add to session" action. - */ const handleAddToChat = useCallback(() => { if (!activeSessionTabId || !attachPath) { toast.error(t("noActiveConversation")) @@ -164,27 +174,30 @@ export function FilePathContextMenu({ toast.success(t("addToChatDone", { label: fileName })) }, [activeSessionTabId, attachPath, fileName, t]) - // ConversationDetailPanel wraps the whole chat surface in its own - // ContextMenu (copy selection / export / …). Nested Radix context menus - // both listen on bubble — without stopPropagation the outer menu steals - // the right-click and this file menu never appears. Stop the event on the - // trigger so only this menu opens (same pattern as chat-input). - const stopOuterContextMenu = useCallback((event: ReactMouseEvent) => { + // Own the gesture before it reaches ConversationDetailPanel's ContextMenu. + // Always stamp `data-file-path-menu` so the panel can `preventDefault` its + // own open via composeEventHandlers when the event still bubbles. + const claimContextMenu = useCallback((event: ReactMouseEvent) => { event.stopPropagation() }, []) - const stopOuterRightPointer = useCallback((event: ReactPointerEvent) => { - // Parent panel also intercepts right-button pointerdown when text is - // selected; keep the event local so nested file rows stay responsive. + const claimRightPointer = useCallback((event: ReactPointerEvent) => { if (event.button === 2) event.stopPropagation() }, []) + // Prefer an explicit title, otherwise the resolved absolute path so hover on + // the wrapper (not only the inner button) still shows the full path. + const triggerTitle = title ?? absolutePath ?? relativePath + return ( {children} From ff8fb7267df2fee7931926817b5b3e7479e0bcbd Mon Sep 17 00:00:00 2001 From: "2859521944@qq.com" <2859521944@qq.com> Date: Thu, 23 Jul 2026 08:50:49 +0800 Subject: [PATCH 3/8] fix(chat):auto-dismiss-file-path-menu-toasts Use-short-duration-for-copy-and-add-to-chat-success-toasts --- src/components/shared/file-path-context-menu.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/shared/file-path-context-menu.tsx b/src/components/shared/file-path-context-menu.tsx index 142b4f936..5b2555a98 100644 --- a/src/components/shared/file-path-context-menu.tsx +++ b/src/components/shared/file-path-context-menu.tsx @@ -61,6 +61,11 @@ import { cn } from "@/lib/utils" /** Attribute the conversation-panel ContextMenu looks for to yield ownership. */ export const FILE_PATH_MENU_ATTR = "data-file-path-menu" +/** Short-lived feedback for copy / add-to-chat (workspace Toaster defaults to 15s). */ +const TOAST_SUCCESS_MS = 2500 +/** Slightly longer for failures so the user can read the reason. */ +const TOAST_ERROR_MS = 5000 + export interface FilePathContextMenuProps { /** Agent-reported path (absolute or workspace-relative). */ filePath: string @@ -123,9 +128,9 @@ export function FilePathContextMenu({ async (text: string, successKey: "pathCopied" | "fileNameCopied") => { const ok = await copyPathText(text) if (ok) { - toast.success(t(successKey)) + toast.success(t(successKey), { duration: TOAST_SUCCESS_MS }) } else { - toast.error(t("copyFailed")) + toast.error(t("copyFailed"), { duration: TOAST_ERROR_MS }) } }, [t] @@ -138,6 +143,7 @@ export function FilePathContextMenu({ } catch (error) { toast.error(t("openFailed"), { description: toErrorMessage(error), + duration: TOAST_ERROR_MS, }) } }, @@ -164,14 +170,16 @@ export function FilePathContextMenu({ const handleAddToChat = useCallback(() => { if (!activeSessionTabId || !attachPath) { - toast.error(t("noActiveConversation")) + toast.error(t("noActiveConversation"), { duration: TOAST_ERROR_MS }) return } emitAttachFileToSession({ tabId: activeSessionTabId, path: attachPath, }) - toast.success(t("addToChatDone", { label: fileName })) + toast.success(t("addToChatDone", { label: fileName }), { + duration: TOAST_SUCCESS_MS, + }) }, [activeSessionTabId, attachPath, fileName, t]) // Own the gesture before it reaches ConversationDetailPanel's ContextMenu. From 35c58283964179cfaf7c785a3242618bc0b8361e Mon Sep 17 00:00:00 2001 From: "2859521944@qq.com" <2859521944@qq.com> Date: Thu, 23 Jul 2026 22:53:41 +0800 Subject: [PATCH 4/8] fix(chat): gate file-path menu and harden open-with / touch - Only wrap Read/Edit/Write tool rows when isFilePathToolName matches - Tighten pathFromToolTitle; disable title shortPath fallback for open/tooltip - Multi-file edit returns no single path - macOS openWith uses Visual Studio Code / Cursor app names - Stop touch/pen long-press from opening the panel context menu - Catch openFilePreview rejections on tool-row open - Unit tests for WebFetch/Glob/multi-file false positives --- .../conversation-detail-panel.tsx | 8 +- .../message/content-parts-renderer.tsx | 92 ++++++------------- .../shared/file-path-context-menu.tsx | 6 +- src/lib/file-path-actions.test.ts | 17 +++- src/lib/file-path-actions.ts | 24 ++++- src/lib/tool-file-path-guard.test.ts | 39 ++++++++ src/lib/tool-file-path-guard.ts | 52 +++++++++++ 7 files changed, 160 insertions(+), 78 deletions(-) create mode 100644 src/lib/tool-file-path-guard.test.ts create mode 100644 src/lib/tool-file-path-guard.ts diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index cd9f3bea3..ce01da430 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -1804,15 +1804,15 @@ export function ConversationDetailPanel() { const handleContextMenuTriggerPointerDown = useCallback( (event: ReactPointerEvent) => { - if (event.button !== 2) return - // File-path menus (message nav / reply artifacts) own right-clicks on - // their rows — don't let selection-preservation logic interfere. + // File-path menus own right-click *and* touch/pen long-press. if ( event.target instanceof Element && - event.target.closest("[data-file-path-menu]") + event.target.closest("[data-file-path-menu]") && + (event.button === 2 || event.pointerType !== "mouse") ) { return } + if (event.button !== 2) return const selection = window.getSelection() if (selection && !selection.isCollapsed) { event.preventDefault() diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index d360def0b..d7e8ef9b6 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -8,6 +8,12 @@ import { toFolderRelativePath, toNativeAbsoluteFilePath, } from "@/lib/file-path-display" +import { + isFilePathToolName, + pathFromToolTitle, +} from "@/lib/tool-file-path-guard" +import { toast } from "sonner" +import { toErrorMessage } from "@/lib/app-error" import { classifyToolKind, TOOL_KIND_ORDER, @@ -234,26 +240,7 @@ function shortPath(p: string): string { * Strip a tool-title prefix (English or localized) to recover the file path * segment, e.g. "Read AGENTS.md" / "读取 AGENTS.md" → "AGENTS.md". */ -function pathFromToolTitle(title: string | null | undefined): string | null { - if (!title) return null - const trimmed = title.trim() - // English prefixes from deriveToolTitle - const en = trimmed.match(/^(?:Read|Edit|Write|NotebookEdit)\s+(.+)$/i) - if (en?.[1]?.trim()) return en[1].trim() - // Common localized prefixes (zh-CN / zh-TW / ja / ko …) - const localized = trimmed.match( - /^(?:读取|讀取|编辑|編輯|写入|寫入|読み取り|읽기)\s+(.+)$/u - ) - if (localized?.[1]?.trim()) return localized[1].trim() - // i18n templates sometimes put the path only: still a plausible file path - if (/[/\\]/.test(trimmed) || /\.[A-Za-z0-9]{1,12}$/.test(trimmed)) { - // Avoid matching pure status words - if (!/^(Read|Edit|Write|Command|TodoWrite)$/i.test(trimmed)) { - return trimmed - } - } - return null -} +// pathFromToolTitle: @/lib/tool-file-path-guard (strict; unit-tested) /** * Pull the primary file path out of a tool input/output envelope (same fields @@ -282,7 +269,8 @@ function extractToolFilePath( toolName: string, input: string | null, output?: string | null, - displayTitle?: string | null + displayTitle?: string | null, + opts?: { allowTitleFallback?: boolean } ): string | null { const titleSource = input ?? output ?? null const parsedInput = input ? tryParseJson(input) : null @@ -301,6 +289,8 @@ function extractToolFilePath( name === "editfile") ) { const files = parseApplyPatchFilesFromUnknownInput(titleSource, parsed) + // Multi-file edits: no single path for menu / open / abs tooltip + if (files.length > 1) return null if (files.length === 1) { const file = files[0] const path = @@ -312,6 +302,8 @@ function extractToolFilePath( const fromDiff = extractPathFromDiffText(titleSource) if (fromDiff) return fromDiff } + + if (opts?.allowTitleFallback === false) return null return pathFromToolTitle(displayTitle) } @@ -960,26 +952,7 @@ function getToolIcon( // ── title derivation ────────────────────────────────────────────────── -/** True for tools that primarily target a single file path. */ -function isFilePathToolName(toolName: string): boolean { - const name = toolName.toLowerCase() - return ( - name === "read" || - name === "read file" || - name === "read_file" || - name === "read_text_file" || - name === "readfile" || - name === "view" || - name === "write" || - name === "write_file" || - name === "writefile" || - name === "notebookedit" || - name === "edit" || - name === "edit_file" || - name === "editfile" || - name === "apply_patch" - ) -} +// isFilePathToolName: @/lib/tool-file-path-guard /** * Shared field lookup used by both title derivation and path tooltips / menus. @@ -2358,27 +2331,20 @@ const ToolCallPart = memo(function ToolCallPart({ ) => string), [rawToolTitle, t] ) - // Path for tool-row context menu + full-path tooltip on the collapsed header. - const toolFilePath = useMemo( - () => - extractToolFilePath( - normalizedToolName, - part.input, - part.output ?? part.errorText ?? null, - rawToolTitle ?? part.displayTitle ?? null - ), - [ + // Payload-only + isFilePathToolName: never hang menus on WebFetch/Glob/Grep, + // never re-absolutize shortPath titles to the wrong file. + const toolFilePath = useMemo(() => { + if (!isFilePathToolName(normalizedToolName)) return null + return extractToolFilePath( normalizedToolName, part.input, - part.output, - part.errorText, - rawToolTitle, - part.displayTitle, - ] - ) + part.output ?? part.errorText ?? null, + null, + { allowTitleFallback: false } + ) + }, [normalizedToolName, part.input, part.output, part.errorText]) const titleTooltip = useMemo(() => { if (!toolFilePath) return undefined - // Prefer absolute native path so Read rows match Edit's hover behavior. const abs = toNativeAbsoluteFilePath(toolFilePath, folder?.path) if (abs) return abs return toFolderRelativePath(toolFilePath, folder?.path) || toolFilePath @@ -2386,7 +2352,9 @@ const ToolCallPart = memo(function ToolCallPart({ const openToolFileInCodeg = useCallback(() => { if (!toolFilePath) return - void openFilePreview(normalizeSlashPath(toolFilePath)) + void openFilePreview(normalizeSlashPath(toolFilePath)).catch((error) => { + toast.error(toErrorMessage(error)) + }) }, [openFilePreview, toolFilePath]) const lineChangeStats = useMemo(() => { if (toolNameLower !== "edit" && toolNameLower !== "apply_patch") { @@ -2725,10 +2693,8 @@ const ToolCallPart = memo(function ToolCallPart({ return ( - {toolFilePath ? ( - // Outer native `title` div: browsers only show the element under the - // cursor's own title attribute (not ancestors). Put the absolute path - // on BOTH this div and the context-menu trigger + CollapsibleTrigger. + {toolFilePath && isFilePathToolName(normalizedToolName) ? ( + // Gated by isFilePathToolName so WebFetch/Glob/Grep never get a file menu.
    { - if (event.button === 2) event.stopPropagation() + if (event.button === 2 || event.pointerType !== "mouse") { + event.stopPropagation() + } }, []) // Prefer an explicit title, otherwise the resolved absolute path so hover on diff --git a/src/lib/file-path-actions.test.ts b/src/lib/file-path-actions.test.ts index debd36cf2..fca03040f 100644 --- a/src/lib/file-path-actions.test.ts +++ b/src/lib/file-path-actions.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest" import { - EXTERNAL_EDITOR_OPEN_WITH, + getExternalEditorOpenWith, resolveFilePathTargets, systemExplorerLabelKey, } from "./file-path-actions" @@ -35,9 +35,16 @@ describe("systemExplorerLabelKey", () => { }) }) -describe("EXTERNAL_EDITOR_OPEN_WITH", () => { - it("maps editors to CLI names the opener plugin understands", () => { - expect(EXTERNAL_EDITOR_OPEN_WITH.vscode).toBe("code") - expect(EXTERNAL_EDITOR_OPEN_WITH.cursor).toBe("cursor") +describe("getExternalEditorOpenWith", () => { + it("uses full app names on macOS for open -a", () => { + expect(getExternalEditorOpenWith("vscode", "macos")).toBe( + "Visual Studio Code" + ) + expect(getExternalEditorOpenWith("cursor", "macos")).toBe("Cursor") + }) + + it("uses CLI shims on Windows and Linux", () => { + expect(getExternalEditorOpenWith("vscode", "windows")).toBe("code") + expect(getExternalEditorOpenWith("cursor", "linux")).toBe("cursor") }) }) diff --git a/src/lib/file-path-actions.ts b/src/lib/file-path-actions.ts index 7b09542e7..6f8f9f335 100644 --- a/src/lib/file-path-actions.ts +++ b/src/lib/file-path-actions.ts @@ -9,11 +9,28 @@ import { toNativeAbsoluteFilePath, } from "@/lib/file-path-display" import { isLocalDesktop, openPath, revealItemInDir } from "@/lib/platform" +import { detectPlatform, type PlatformType } from "@/hooks/use-platform" import { copyTextFromMenu } from "@/lib/utils" export type ExternalEditorId = "vscode" | "cursor" -/** CLI / opener names passed to `openPath(path, openWith)`. */ +/** + * Resolve the `openWith` argument for Tauri opener. + * + * macOS `open -a` matches application names (short CLI shims like `code` + * fail). Windows/Linux keep CLI shims when those are on PATH. + */ +export function getExternalEditorOpenWith( + editor: ExternalEditorId, + platform: PlatformType = detectPlatform() +): string { + if (platform === "macos") { + return editor === "vscode" ? "Visual Studio Code" : "Cursor" + } + return editor === "vscode" ? "code" : "cursor" +} + +/** CLI shim defaults (non-macOS). Prefer {@link getExternalEditorOpenWith}. */ export const EXTERNAL_EDITOR_OPEN_WITH: Record = { vscode: "code", cursor: "cursor", @@ -37,8 +54,6 @@ export function resolveFilePathTargets( } export async function copyPathText(text: string): Promise { - // Defer until the context menu closes so the execCommand fallback works in - // non-secure web contexts (same pattern as the file-tree copy action). return copyTextFromMenu(text) } @@ -59,10 +74,9 @@ export async function openFileWithExternalEditor( editor: ExternalEditorId ): Promise { if (!isLocalDesktop()) return - await openPath(absolutePath, EXTERNAL_EDITOR_OPEN_WITH[editor]) + await openPath(absolutePath, getExternalEditorOpenWith(editor)) } -/** Platform-aware label key suffix for the system file manager entry. */ export function systemExplorerLabelKey( platformHint?: string ): "openInFinder" | "openInExplorer" | "openInFileManager" { diff --git a/src/lib/tool-file-path-guard.test.ts b/src/lib/tool-file-path-guard.test.ts new file mode 100644 index 000000000..76cde65a6 --- /dev/null +++ b/src/lib/tool-file-path-guard.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest" +import { isFilePathToolName, pathFromToolTitle } from "./tool-file-path-guard" + +describe("isFilePathToolName", () => { + it("accepts read/edit/write family names", () => { + expect(isFilePathToolName("read")).toBe(true) + expect(isFilePathToolName("Read File")).toBe(true) + expect(isFilePathToolName("edit")).toBe(true) + expect(isFilePathToolName("apply_patch")).toBe(true) + expect(isFilePathToolName("write")).toBe(true) + }) + + it("rejects non-file tools", () => { + expect(isFilePathToolName("webfetch")).toBe(false) + expect(isFilePathToolName("WebFetch")).toBe(false) + expect(isFilePathToolName("glob")).toBe(false) + expect(isFilePathToolName("grep")).toBe(false) + expect(isFilePathToolName("todowrite")).toBe(false) + expect(isFilePathToolName("bash")).toBe(false) + }) +}) + +describe("pathFromToolTitle", () => { + it("extracts path after Read/Edit prefixes", () => { + expect(pathFromToolTitle("Read AGENTS.md")).toBe("AGENTS.md") + expect(pathFromToolTitle("Edit src/a.ts")).toBe("src/a.ts") + expect(pathFromToolTitle("读取 nested/foo.ts")).toBe("nested/foo.ts") + }) + + it("rejects WebFetch / Glob / multi-file / URL / glob-like titles", () => { + expect(pathFromToolTitle("WebFetch https://example.com/a")).toBeNull() + expect(pathFromToolTitle("Glob foo/bar.ts")).toBeNull() + expect(pathFromToolTitle("Grep foo/bar")).toBeNull() + expect(pathFromToolTitle("Edit (3 files)")).toBeNull() + expect(pathFromToolTitle("Todos (2/5)")).toBeNull() + expect(pathFromToolTitle("Read https://x.test/a")).toBeNull() + expect(pathFromToolTitle("Read foo/*.md")).toBeNull() + }) +}) diff --git a/src/lib/tool-file-path-guard.ts b/src/lib/tool-file-path-guard.ts new file mode 100644 index 000000000..83aa18caf --- /dev/null +++ b/src/lib/tool-file-path-guard.ts @@ -0,0 +1,52 @@ +/** + * Pure guards for deciding when a tool row should get a filesystem path menu. + * Kept free of React so unit tests can lock false-positive regressions + * (WebFetch / Glob / multi-file Edit titles). + */ + +/** True for tools that primarily target a single file path. */ +export function isFilePathToolName(toolName: string): boolean { + const name = toolName.toLowerCase() + return ( + name === "read" || + name === "read file" || + name === "read_file" || + name === "read_text_file" || + name === "readfile" || + name === "view" || + name === "write" || + name === "write_file" || + name === "writefile" || + name === "notebookedit" || + name === "edit" || + name === "edit_file" || + name === "editfile" || + name === "apply_patch" + ) +} + +/** + * Strip a tool-title prefix to recover a file path segment. + * Intentionally strict — never treat URLs, globs, or aggregate titles as paths. + */ +export function pathFromToolTitle( + title: string | null | undefined +): string | null { + if (!title) return null + const trimmed = title.trim() + const en = trimmed.match(/^(?:Read|Edit|Write|NotebookEdit)\s+(.+)$/i) + const localized = trimmed.match( + /^(?:读取|讀取|编辑|編輯|写入|寫入|読み取り|읽기)\s+(.+)$/u + ) + const target = (en?.[1] ?? localized?.[1])?.trim() + if (!target) return null + + if (/^\(\d+\s+files?\)$/i.test(target)) return null + if (/[()]/.test(target)) return null + if (/^https?:\/\//i.test(target)) return null + if (/[*?[\]{}]/.test(target)) return null + // Spaces usually mean prose (allow Windows drive paths with spaces only) + if (/\s/.test(target) && !/^[A-Za-z]:[\\/]/.test(target)) return null + + return target +} From bd98d98bdb3fc0f35e971dfdbf29f44b53b2d158 Mon Sep 17 00:00:00 2001 From: "2859521944@qq.com" <2859521944@qq.com> Date: Thu, 23 Jul 2026 23:15:55 +0800 Subject: [PATCH 5/8] fix(desktop): allow opener paths outside HOME on all platforms Expand opener open-path and reveal scopes beyond $HOME so workspace files on Windows drives (E:/dev/...), macOS /Volumes, and Linux paths outside home can open with VS Code/Cursor. Normalize separators for glob matching. openWith stays platform-specific. --- src-tauri/capabilities/default.json | 50 +++++++++++++++++++++++++++++ src/lib/file-path-actions.ts | 8 +++-- src/lib/platform.ts | 17 +++++++--- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e2758ba5f..867982a35 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -40,6 +40,56 @@ "allow": [ { "path": "$HOME/**" + }, + { + "path": "$DOCUMENT/**" + }, + { + "path": "$DESKTOP/**" + }, + { + "path": "$DOWNLOAD/**" + }, + { + "path": "$PUBLIC/**" + }, + { + "path": "$TEMP/**" + }, + { + "path": "/**" + }, + { + "path": "**" + } + ] + }, + { + "identifier": "opener:allow-reveal-item-in-dir", + "allow": [ + { + "path": "$HOME/**" + }, + { + "path": "$DOCUMENT/**" + }, + { + "path": "$DESKTOP/**" + }, + { + "path": "$DOWNLOAD/**" + }, + { + "path": "$PUBLIC/**" + }, + { + "path": "$TEMP/**" + }, + { + "path": "/**" + }, + { + "path": "**" } ] }, diff --git a/src/lib/file-path-actions.ts b/src/lib/file-path-actions.ts index 6f8f9f335..0bf94b62f 100644 --- a/src/lib/file-path-actions.ts +++ b/src/lib/file-path-actions.ts @@ -15,10 +15,11 @@ import { copyTextFromMenu } from "@/lib/utils" export type ExternalEditorId = "vscode" | "cursor" /** - * Resolve the `openWith` argument for Tauri opener. + * Resolve the `openWith` argument for Tauri opener across platforms. * - * macOS `open -a` matches application names (short CLI shims like `code` - * fail). Windows/Linux keep CLI shims when those are on PATH. + * - **macOS**: full application names for `open -a` (`code`/`Code` fail) + * - **Windows**: CLI shims on PATH (`code.cmd` / `cursor.cmd` via `code`/`cursor`) + * - **Linux**: same CLI shims, or desktop-file basenames when available */ export function getExternalEditorOpenWith( editor: ExternalEditorId, @@ -27,6 +28,7 @@ export function getExternalEditorOpenWith( if (platform === "macos") { return editor === "vscode" ? "Visual Studio Code" : "Cursor" } + // Windows + Linux + unknown: CLI entry points (must be on PATH) return editor === "vscode" ? "code" : "cursor" } diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 3911a649c..f4954a43f 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -84,15 +84,21 @@ export async function openUrl(url: string): Promise { * Open a path with the system default app, or a specific app when `openWith` * is set (desktop local only). No-op in web / remote-desktop modes. * - * `openWith` is the program name passed to the OS opener (e.g. `"code"`, - * `"cursor"`, `"vlc"`). When omitted, the default handler for the path type - * is used. + * `openWith` is platform-specific: + * - macOS: application name for `open -a` (e.g. `"Visual Studio Code"`) + * - Windows/Linux: CLI/app id on PATH (e.g. `"code"`, `"cursor"`) + * + * Paths must be allowed by `opener:allow-open-path` in capabilities + * (home, documents, and recursive `/**` / `**` for workspaces outside $HOME + * such as `E:\\dev\\…` on Windows or `/Volumes/…` on macOS). */ export async function openPath(path: string, openWith?: string): Promise { if (isDesktop() && getActiveRemoteConnectionId() === null) { const { openPath: tauriOpenPath } = await import("@tauri-apps/plugin-opener") - await tauriOpenPath(path, openWith) + // Normalize separators so scope globs match Windows drive paths too. + const normalized = path.replace(/\\/g, "/") + await tauriOpenPath(normalized, openWith) } } @@ -104,7 +110,8 @@ export async function revealItemInDir(path: string): Promise { if (isDesktop() && getActiveRemoteConnectionId() === null) { const { revealItemInDir: tauriReveal } = await import("@tauri-apps/plugin-opener") - await tauriReveal(path) + const normalized = path.replace(/\\/g, "/") + await tauriReveal(normalized) } } From b8466a445cfe8b8d8194d2c53628071fde8d12bd Mon Sep 17 00:00:00 2001 From: "2859521944@qq.com" <2859521944@qq.com> Date: Thu, 23 Jul 2026 23:35:54 +0800 Subject: [PATCH 6/8] fix(desktop): allow openPath with external apps on all platforms opener scope entries default to Application::Default which only matches openPath without `with`. Opening with "code"/"cursor"/app names requires explicit "app": true (or a specific app name) on each allow entry. Also keep broad path globs ($HOME, user dirs, /**, **) so workspaces outside the home directory work on Windows drives, macOS volumes, and Linux paths. --- src-tauri/capabilities/default.json | 72 ++++++++++------------------- 1 file changed, 24 insertions(+), 48 deletions(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 867982a35..0cea42301 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -38,59 +38,35 @@ { "identifier": "opener:allow-open-path", "allow": [ - { - "path": "$HOME/**" - }, - { - "path": "$DOCUMENT/**" - }, - { - "path": "$DESKTOP/**" - }, - { - "path": "$DOWNLOAD/**" - }, - { - "path": "$PUBLIC/**" - }, - { - "path": "$TEMP/**" - }, - { - "path": "/**" - }, - { - "path": "**" - } + { "path": "$HOME/**" }, + { "path": "$HOME/**", "app": true }, + { "path": "$DOCUMENT/**" }, + { "path": "$DOCUMENT/**", "app": true }, + { "path": "$DESKTOP/**" }, + { "path": "$DESKTOP/**", "app": true }, + { "path": "$DOWNLOAD/**" }, + { "path": "$DOWNLOAD/**", "app": true }, + { "path": "$PUBLIC/**" }, + { "path": "$PUBLIC/**", "app": true }, + { "path": "$TEMP/**" }, + { "path": "$TEMP/**", "app": true }, + { "path": "/**" }, + { "path": "/**", "app": true }, + { "path": "**" }, + { "path": "**", "app": true } ] }, { "identifier": "opener:allow-reveal-item-in-dir", "allow": [ - { - "path": "$HOME/**" - }, - { - "path": "$DOCUMENT/**" - }, - { - "path": "$DESKTOP/**" - }, - { - "path": "$DOWNLOAD/**" - }, - { - "path": "$PUBLIC/**" - }, - { - "path": "$TEMP/**" - }, - { - "path": "/**" - }, - { - "path": "**" - } + { "path": "$HOME/**" }, + { "path": "$DOCUMENT/**" }, + { "path": "$DESKTOP/**" }, + { "path": "$DOWNLOAD/**" }, + { "path": "$PUBLIC/**" }, + { "path": "$TEMP/**" }, + { "path": "/**" }, + { "path": "**" } ] }, "dialog:default", From 56e34e94501f440628a1510f5a8693da2422f117 Mon Sep 17 00:00:00 2001 From: "2859521944@qq.com" <2859521944@qq.com> Date: Fri, 24 Jul 2026 00:01:45 +0800 Subject: [PATCH 7/8] fix(desktop): open files with Cursor.exe instead of install dir On Windows, open::with(path, "cursor") can ShellExecute-resolve to the Cursor install directory. Prefer %LOCALAPPDATA%\\Programs\\...\\Cursor.exe (and Code.exe for VS Code), then .cmd shims, then bare PATH names. --- .../conversation-detail-panel.tsx | 28 +---- .../message/content-parts-renderer.tsx | 21 ++-- .../shared/file-path-context-menu.tsx | 30 ++--- src/lib/file-path-actions.test.ts | 33 ++++++ src/lib/file-path-actions.ts | 103 ++++++++++++++++-- 5 files changed, 159 insertions(+), 56 deletions(-) diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index ce01da430..8b3eae2a2 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -7,7 +7,6 @@ import { useMemo, useRef, useState, - type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, } from "react" import { @@ -1802,9 +1801,11 @@ export function ConversationDetailPanel() { : null }, []) + // File-path menus claim right-click / long-press via stopPropagation on the + // trigger. Here we only skip selection-preservation so we do not + // preventDefault those gestures before the nested menu can open. const handleContextMenuTriggerPointerDown = useCallback( (event: ReactPointerEvent) => { - // File-path menus own right-click *and* touch/pen long-press. if ( event.target instanceof Element && event.target.closest("[data-file-path-menu]") && @@ -1821,28 +1822,6 @@ export function ConversationDetailPanel() { [] ) - /** - * Nested file-path ContextMenus sit inside this panel menu. Radix runs the - * trigger's user `onContextMenu` first and skips its own open when - * `defaultPrevented` — so yield ownership when the event originated under - * `[data-file-path-menu]` (message navigator + reply artifact rows). - */ - const handlePanelContextMenu = useCallback( - (event: ReactMouseEvent) => { - if ( - event.target instanceof Element && - event.target.closest("[data-file-path-menu]") - ) { - // Radix runs this user handler before opening the panel menu; when - // defaultPrevented it skips open. Also stop bubble so nothing else - // competes with the nested file-path menu. - event.preventDefault() - event.stopPropagation() - } - }, - [] - ) - useEffect(() => { const handler = () => { if (!isContextMenuOpenRef.current) return @@ -2095,7 +2074,6 @@ export function ConversationDetailPanel() {
    {/* Stable wrapper across canTile flip — otherwise sibling tabs remount and a live streaming response is torn down. */} diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index d7e8ef9b6..5adf860b5 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -2695,17 +2695,18 @@ const ToolCallPart = memo(function ToolCallPart({ {toolFilePath && isFilePathToolName(normalizedToolName) ? ( // Gated by isFilePathToolName so WebFetch/Glob/Grep never get a file menu. -
    - + // Default asChild slots onto the div (no illegal span>div wrapper). + +
    {toolHeader} - -
    +
    + ) : ( toolHeader )} diff --git a/src/components/shared/file-path-context-menu.tsx b/src/components/shared/file-path-context-menu.tsx index d3ed9ef85..2c8b98d5b 100644 --- a/src/components/shared/file-path-context-menu.tsx +++ b/src/components/shared/file-path-context-menu.tsx @@ -3,13 +3,15 @@ /** * VS Code-style right-click menu for a workspace / session file path. * - * Used by the per-conversation message navigator and the per-reply artifacts - * card so both surfaces share the same copy / open / mention actions. + * Used by the message navigator, reply artifacts, tool rows, and FilePathLink. * - * Nested under ConversationDetailPanel's full-surface ContextMenu — that - * ancestor steals right-clicks unless we (1) stopPropagation on our trigger - * and (2) mark `[data-file-path-menu]` so the panel can suppress its own open - * when the event still bubbles (see conversation-detail-panel). + * Nested under ConversationDetailPanel's full-surface ContextMenu. Ownership + * is claimed by stopPropagation on contextmenu / non-mouse pointerdown, and by + * stamping `data-file-path-menu` so the panel skips selection-preservation on + * those gestures (see conversation-detail-panel). + * + * Default `asChild`: Slot merges the trigger onto a single DOM child (button + * or card div) so we never render an illegal span>div wrapper. */ import { @@ -81,13 +83,13 @@ export interface FilePathContextMenuProps { onOpenInCodeg?: () => void /** * Native HTML tooltip for the trigger surface (e.g. absolute path on a - * tool-row wrapper). Applied on the trigger element that receives hover. + * tool-row wrapper). Applied on the trigger / slotted child. */ title?: string /** - * Merge trigger props onto `children` (must be a single element that accepts - * a ref). Use for tool headers (`CollapsibleTrigger`) so the whole row owns - * the right-click without an extra wrapping span. + * When true (default), merge trigger props onto a single child via Slot. + * Child must accept a ref (native button/div). Set false only for rare + * non-element children that need the built-in block wrapper. */ asChild?: boolean className?: string @@ -100,7 +102,7 @@ export function FilePathContextMenu({ externalOpenDisabled = false, onOpenInCodeg, title, - asChild = false, + asChild = true, className, children, }: FilePathContextMenuProps) { @@ -182,9 +184,9 @@ export function FilePathContextMenu({ }) }, [activeSessionTabId, attachPath, fileName, t]) - // Own the gesture before it reaches ConversationDetailPanel's ContextMenu. - // Always stamp `data-file-path-menu` so the panel can `preventDefault` its - // own open via composeEventHandlers when the event still bubbles. + // Claim the gesture so ConversationDetailPanel's ContextMenu does not open. + // stopPropagation is the primary mechanism; data-file-path-menu is only for + // the panel's pointerdown selection-preservation early-return. const claimContextMenu = useCallback((event: ReactMouseEvent) => { event.stopPropagation() }, []) diff --git a/src/lib/file-path-actions.test.ts b/src/lib/file-path-actions.test.ts index fca03040f..7f97d3be4 100644 --- a/src/lib/file-path-actions.test.ts +++ b/src/lib/file-path-actions.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest" import { getExternalEditorOpenWith, + resolveExternalEditorOpenWithCandidates, resolveFilePathTargets, systemExplorerLabelKey, } from "./file-path-actions" @@ -48,3 +49,35 @@ describe("getExternalEditorOpenWith", () => { expect(getExternalEditorOpenWith("cursor", "linux")).toBe("cursor") }) }) + +describe("resolveExternalEditorOpenWithCandidates", () => { + it("returns macOS application names only", async () => { + await expect( + resolveExternalEditorOpenWithCandidates("cursor", "macos") + ).resolves.toEqual(["Cursor"]) + await expect( + resolveExternalEditorOpenWithCandidates("vscode", "macos") + ).resolves.toEqual(["Visual Studio Code"]) + }) + + it("ends Windows cursor candidates with .cmd then bare name (exe first when LocalAppData works)", async () => { + const candidates = await resolveExternalEditorOpenWithCandidates( + "cursor", + "windows" + ) + expect(candidates.length).toBeGreaterThanOrEqual(2) + expect(candidates.at(-2)).toBe("cursor.cmd") + expect(candidates.at(-1)).toBe("cursor") + // Prefer real EXE when path API is available (Tauri); otherwise PATH only. + const exe = candidates.find((c) => /Cursor\.exe$/i.test(c)) + if (exe) { + expect(exe).toMatch(/Programs/i) + } + }) + + it("lists Linux CLI shims", async () => { + await expect( + resolveExternalEditorOpenWithCandidates("vscode", "linux") + ).resolves.toEqual(["code", "code-insiders"]) + }) +}) diff --git a/src/lib/file-path-actions.ts b/src/lib/file-path-actions.ts index 0bf94b62f..18eb9d899 100644 --- a/src/lib/file-path-actions.ts +++ b/src/lib/file-path-actions.ts @@ -15,11 +15,10 @@ import { copyTextFromMenu } from "@/lib/utils" export type ExternalEditorId = "vscode" | "cursor" /** - * Resolve the `openWith` argument for Tauri opener across platforms. - * - * - **macOS**: full application names for `open -a` (`code`/`Code` fail) - * - **Windows**: CLI shims on PATH (`code.cmd` / `cursor.cmd` via `code`/`cursor`) - * - **Linux**: same CLI shims, or desktop-file basenames when available + * Resolve a single preferred `openWith` name for the platform (macOS app name + * or CLI shim). Windows should prefer {@link resolveExternalEditorOpenWithCandidates} + * so we open `Cursor.exe` / `Code.exe` instead of a bare name that ShellExecute + * can resolve to the install directory. */ export function getExternalEditorOpenWith( editor: ExternalEditorId, @@ -28,10 +27,88 @@ export function getExternalEditorOpenWith( if (platform === "macos") { return editor === "vscode" ? "Visual Studio Code" : "Cursor" } - // Windows + Linux + unknown: CLI entry points (must be on PATH) + // Windows + Linux: CLI entry points (must be on PATH). On Windows bare + // "cursor" is unreliable — see resolveExternalEditorOpenWithCandidates. return editor === "vscode" ? "code" : "cursor" } +/** + * Ordered openWith candidates for the current platform. + * + * On Windows, `open::with(path, "cursor")` often launches the Cursor *install + * folder* (ShellExecute name lookup) instead of opening `path` in the editor. + * Prefer the real `.exe` under `%LOCALAPPDATA%\\Programs\\...` first, then + * `.cmd` shims that forward arguments correctly. + */ +export async function resolveExternalEditorOpenWithCandidates( + editor: ExternalEditorId, + platform: PlatformType = detectPlatform() +): Promise { + if (platform === "macos") { + return [getExternalEditorOpenWith(editor, "macos")] + } + + if (platform === "linux") { + // CLI shims first; some distros also register desktop ids. + return editor === "vscode" + ? ["code", "code-insiders"] + : ["cursor", "cursor.AppImage"] + } + + // Windows + let localAppData = "" + try { + const { localDataDir } = await import("@tauri-apps/api/path") + localAppData = await localDataDir() + } catch { + // Fall through to PATH-only candidates + } + + const join = (base: string, ...parts: string[]) => + [base.replace(/[\\/]+$/, ""), ...parts].join("\\") + + if (editor === "vscode") { + const candidates: string[] = [] + if (localAppData) { + candidates.push( + join(localAppData, "Programs", "Microsoft VS Code", "Code.exe"), + join(localAppData, "Programs", "Microsoft VS Code", "bin", "code.cmd") + ) + } + candidates.push("code.cmd", "code") + return candidates + } + + // Cursor — install layout varies slightly by version/channel + const candidates: string[] = [] + if (localAppData) { + candidates.push( + join(localAppData, "Programs", "cursor", "Cursor.exe"), + join(localAppData, "Programs", "Cursor", "Cursor.exe"), + join( + localAppData, + "Programs", + "cursor", + "resources", + "app", + "bin", + "cursor.cmd" + ), + join( + localAppData, + "Programs", + "Cursor", + "resources", + "app", + "bin", + "cursor.cmd" + ) + ) + } + candidates.push("cursor.cmd", "cursor") + return candidates +} + /** CLI shim defaults (non-macOS). Prefer {@link getExternalEditorOpenWith}. */ export const EXTERNAL_EDITOR_OPEN_WITH: Record = { vscode: "code", @@ -76,7 +153,19 @@ export async function openFileWithExternalEditor( editor: ExternalEditorId ): Promise { if (!isLocalDesktop()) return - await openPath(absolutePath, getExternalEditorOpenWith(editor)) + const candidates = await resolveExternalEditorOpenWithCandidates(editor) + let lastError: unknown + for (const app of candidates) { + try { + await openPath(absolutePath, app) + return + } catch (error) { + lastError = error + } + } + throw lastError instanceof Error + ? lastError + : new Error(`Failed to open with ${editor}`) } export function systemExplorerLabelKey( From cbb6c28dc245f5e255bb91a6a88927515cce06f6 Mon Sep 17 00:00:00 2001 From: "2859521944@qq.com" <2859521944@qq.com> Date: Fri, 24 Jul 2026 00:24:19 +0800 Subject: [PATCH 8/8] fix(desktop):silent-open-in-editor-no-windows-dialogs --- src-tauri/src/commands/file_io.rs | 243 ++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/lib/file-path-actions.test.ts | 33 ---- src/lib/file-path-actions.ts | 112 ++------------ 4 files changed, 260 insertions(+), 129 deletions(-) diff --git a/src-tauri/src/commands/file_io.rs b/src-tauri/src/commands/file_io.rs index 8f934a7b5..b48924f2d 100644 --- a/src-tauri/src/commands/file_io.rs +++ b/src-tauri/src/commands/file_io.rs @@ -1,3 +1,6 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + use base64::{engine::general_purpose::STANDARD, Engine as _}; use crate::app_error::AppCommandError; @@ -43,10 +46,250 @@ pub async fn save_text_file(path: String, contents: String) -> Result<(), AppCom Ok(()) } +/// Open a filesystem path in VS Code or Cursor without ShellExecute dialogs. +/// +/// The JS opener plugin uses `open::with`, which on Windows pops a system +/// "Windows cannot find …" dialog for every missing candidate path. This +/// command instead: +/// 1. Skips absolute candidates that are not real files (silent) +/// 2. Spawns via `std::process::Command` (no ShellExecute error UI) +/// 3. Suppresses console windows for `.cmd` / bare CLI shims on Windows +/// +/// `editor` is `"vscode"` or `"cursor"`. Works on Windows, macOS, and Linux. +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn open_path_in_editor(path: String, editor: String) -> Result<(), AppCommandError> { + open_path_in_editor_impl(&path, &editor) +} + +#[cfg(feature = "tauri-runtime")] +fn open_path_in_editor_impl(path: &str, editor: &str) -> Result<(), AppCommandError> { + let file = Path::new(path); + if !file.exists() { + return Err(AppCommandError::not_found(format!( + "file does not exist: {path}" + ))); + } + + let editor = editor.trim().to_ascii_lowercase(); + if editor != "vscode" && editor != "cursor" { + return Err(AppCommandError::invalid_input(format!( + "unknown editor: {editor}" + ))); + } + + let candidates = editor_launch_candidates(&editor); + let mut last_err: Option = None; + + for app in candidates { + // Absolute / relative path-like candidates: skip silently if missing so + // Windows never shows "cannot find file" dialogs. + if looks_like_filesystem_path(&app) && !Path::new(&app).is_file() { + continue; + } + + match spawn_editor_silent(&app, path) { + Ok(()) => return Ok(()), + Err(e) => last_err = Some(format!("{app}: {e}")), + } + } + + Err(AppCommandError::not_found(format!( + "could not open with {editor}: {}", + last_err.unwrap_or_else(|| "no candidates".into()) + ))) +} + +#[cfg(feature = "tauri-runtime")] +fn looks_like_filesystem_path(app: &str) -> bool { + app.contains('/') + || app.contains('\\') + || Path::new(app).is_absolute() + || (app.len() >= 2 && app.as_bytes()[1] == b':') +} + +#[cfg(feature = "tauri-runtime")] +fn editor_launch_candidates(editor: &str) -> Vec { + #[cfg(target_os = "macos")] + { + return if editor == "vscode" { + vec!["Visual Studio Code".into()] + } else { + vec!["Cursor".into()] + }; + } + + #[cfg(target_os = "linux")] + { + return if editor == "vscode" { + vec!["code".into(), "code-insiders".into()] + } else { + vec!["cursor".into()] + }; + } + + #[cfg(target_os = "windows")] + { + let mut out = Vec::new(); + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + let local = PathBuf::from(local); + if editor == "vscode" { + out.push( + local + .join("Programs") + .join("Microsoft VS Code") + .join("Code.exe") + .to_string_lossy() + .into_owned(), + ); + out.push( + local + .join("Programs") + .join("Microsoft VS Code") + .join("bin") + .join("code.cmd") + .to_string_lossy() + .into_owned(), + ); + } else { + for brand in ["cursor", "Cursor"] { + out.push( + local + .join("Programs") + .join(brand) + .join("Cursor.exe") + .to_string_lossy() + .into_owned(), + ); + out.push( + local + .join("Programs") + .join(brand) + .join("resources") + .join("app") + .join("bin") + .join("cursor.cmd") + .to_string_lossy() + .into_owned(), + ); + } + } + } + if editor == "vscode" { + out.push("code.cmd".into()); + out.push("code".into()); + } else { + out.push("cursor.cmd".into()); + out.push("cursor".into()); + } + return out; + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = editor; + Vec::new() + } +} + +/// Spawn an editor without ShellExecute dialogs or console flashes. +#[cfg(feature = "tauri-runtime")] +fn spawn_editor_silent(app: &str, file_path: &str) -> Result<(), std::io::Error> { + #[cfg(target_os = "macos")] + { + // `open -a "App Name" -- /path/to/file` — no Finder error dialog if we + // only call this with real app names (macOS candidates are names only). + let status = Command::new("open") + .args(["-a", app, "--", file_path]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!( + "open -a {app} exited with {status}" + ))) + } + } + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + // Hide console host for .cmd / PATH shims. Do NOT put CREATE_NO_WINDOW + // on GUI .exe launches — only suppress the cmd.exe console flash. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + const DETACHED_PROCESS: u32 = 0x0000_0008; + + let lower = app.to_ascii_lowercase(); + let is_batch = lower.ends_with(".cmd") || lower.ends_with(".bat"); + let mut cmd = if is_batch { + let mut c = Command::new("cmd.exe"); + c.args(["/C", app, file_path]); + c + } else { + let mut c = Command::new(app); + c.arg(file_path); + c + }; + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let flags = if is_batch || !looks_like_filesystem_path(app) { + CREATE_NO_WINDOW | DETACHED_PROCESS + } else { + DETACHED_PROCESS + }; + cmd.creation_flags(flags); + cmd.spawn()?; + Ok(()) + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + Command::new(app) + .arg(file_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + Ok(()) + } + + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + all(unix, not(target_os = "macos")) + )))] + { + let _ = (app, file_path); + Err(std::io::Error::other("unsupported platform")) + } +} + #[cfg(all(test, feature = "tauri-runtime"))] mod tests { use super::*; + #[test] + fn looks_like_filesystem_path_detects_absolute_and_drive_paths() { + assert!(looks_like_filesystem_path(r"C:\Users\a\Code.exe")); + assert!(looks_like_filesystem_path("/usr/bin/code")); + assert!(looks_like_filesystem_path(r"Programs\cursor\Cursor.exe")); + assert!(!looks_like_filesystem_path("code")); + assert!(!looks_like_filesystem_path("cursor.cmd")); + } + + #[test] + fn open_path_in_editor_rejects_unknown_editor() { + let err = open_path_in_editor_impl("/tmp/x", "notepad").expect_err("unknown"); + assert!(matches!( + err.code, + crate::app_error::AppErrorCode::InvalidInput + )); + } + #[tokio::test] async fn save_text_file_writes_utf8_payload() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e48efa3c..66616cbc3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1211,6 +1211,7 @@ mod tauri_app { notification::send_notification, file_io::save_binary_file, file_io::save_text_file, + file_io::open_path_in_editor, backup::backup_create, backup::backup_inspect, backup::backup_scan_external_conflicts, diff --git a/src/lib/file-path-actions.test.ts b/src/lib/file-path-actions.test.ts index 7f97d3be4..fca03040f 100644 --- a/src/lib/file-path-actions.test.ts +++ b/src/lib/file-path-actions.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from "vitest" import { getExternalEditorOpenWith, - resolveExternalEditorOpenWithCandidates, resolveFilePathTargets, systemExplorerLabelKey, } from "./file-path-actions" @@ -49,35 +48,3 @@ describe("getExternalEditorOpenWith", () => { expect(getExternalEditorOpenWith("cursor", "linux")).toBe("cursor") }) }) - -describe("resolveExternalEditorOpenWithCandidates", () => { - it("returns macOS application names only", async () => { - await expect( - resolveExternalEditorOpenWithCandidates("cursor", "macos") - ).resolves.toEqual(["Cursor"]) - await expect( - resolveExternalEditorOpenWithCandidates("vscode", "macos") - ).resolves.toEqual(["Visual Studio Code"]) - }) - - it("ends Windows cursor candidates with .cmd then bare name (exe first when LocalAppData works)", async () => { - const candidates = await resolveExternalEditorOpenWithCandidates( - "cursor", - "windows" - ) - expect(candidates.length).toBeGreaterThanOrEqual(2) - expect(candidates.at(-2)).toBe("cursor.cmd") - expect(candidates.at(-1)).toBe("cursor") - // Prefer real EXE when path API is available (Tauri); otherwise PATH only. - const exe = candidates.find((c) => /Cursor\.exe$/i.test(c)) - if (exe) { - expect(exe).toMatch(/Programs/i) - } - }) - - it("lists Linux CLI shims", async () => { - await expect( - resolveExternalEditorOpenWithCandidates("vscode", "linux") - ).resolves.toEqual(["code", "code-insiders"]) - }) -}) diff --git a/src/lib/file-path-actions.ts b/src/lib/file-path-actions.ts index 18eb9d899..f960f1ed9 100644 --- a/src/lib/file-path-actions.ts +++ b/src/lib/file-path-actions.ts @@ -15,10 +15,8 @@ import { copyTextFromMenu } from "@/lib/utils" export type ExternalEditorId = "vscode" | "cursor" /** - * Resolve a single preferred `openWith` name for the platform (macOS app name - * or CLI shim). Windows should prefer {@link resolveExternalEditorOpenWithCandidates} - * so we open `Cursor.exe` / `Code.exe` instead of a bare name that ShellExecute - * can resolve to the install directory. + * Platform display/CLI name for an editor (used by tests and docs). + * Actual launch goes through the silent Rust command `open_path_in_editor`. */ export function getExternalEditorOpenWith( editor: ExternalEditorId, @@ -27,88 +25,9 @@ export function getExternalEditorOpenWith( if (platform === "macos") { return editor === "vscode" ? "Visual Studio Code" : "Cursor" } - // Windows + Linux: CLI entry points (must be on PATH). On Windows bare - // "cursor" is unreliable — see resolveExternalEditorOpenWithCandidates. return editor === "vscode" ? "code" : "cursor" } -/** - * Ordered openWith candidates for the current platform. - * - * On Windows, `open::with(path, "cursor")` often launches the Cursor *install - * folder* (ShellExecute name lookup) instead of opening `path` in the editor. - * Prefer the real `.exe` under `%LOCALAPPDATA%\\Programs\\...` first, then - * `.cmd` shims that forward arguments correctly. - */ -export async function resolveExternalEditorOpenWithCandidates( - editor: ExternalEditorId, - platform: PlatformType = detectPlatform() -): Promise { - if (platform === "macos") { - return [getExternalEditorOpenWith(editor, "macos")] - } - - if (platform === "linux") { - // CLI shims first; some distros also register desktop ids. - return editor === "vscode" - ? ["code", "code-insiders"] - : ["cursor", "cursor.AppImage"] - } - - // Windows - let localAppData = "" - try { - const { localDataDir } = await import("@tauri-apps/api/path") - localAppData = await localDataDir() - } catch { - // Fall through to PATH-only candidates - } - - const join = (base: string, ...parts: string[]) => - [base.replace(/[\\/]+$/, ""), ...parts].join("\\") - - if (editor === "vscode") { - const candidates: string[] = [] - if (localAppData) { - candidates.push( - join(localAppData, "Programs", "Microsoft VS Code", "Code.exe"), - join(localAppData, "Programs", "Microsoft VS Code", "bin", "code.cmd") - ) - } - candidates.push("code.cmd", "code") - return candidates - } - - // Cursor — install layout varies slightly by version/channel - const candidates: string[] = [] - if (localAppData) { - candidates.push( - join(localAppData, "Programs", "cursor", "Cursor.exe"), - join(localAppData, "Programs", "Cursor", "Cursor.exe"), - join( - localAppData, - "Programs", - "cursor", - "resources", - "app", - "bin", - "cursor.cmd" - ), - join( - localAppData, - "Programs", - "Cursor", - "resources", - "app", - "bin", - "cursor.cmd" - ) - ) - } - candidates.push("cursor.cmd", "cursor") - return candidates -} - /** CLI shim defaults (non-macOS). Prefer {@link getExternalEditorOpenWith}. */ export const EXTERNAL_EDITOR_OPEN_WITH: Record = { vscode: "code", @@ -148,24 +67,25 @@ export async function openFileWithDefaultApp( await openPath(absolutePath) } +/** + * Open a file in VS Code / Cursor without Windows "cannot find file" dialogs + * or flashing cmd windows. + * + * Uses a dedicated Rust command that: + * - skips missing absolute candidate paths silently + * - spawns via CreateProcess / `open -a` (not ShellExecute-with-app) + * - hides console hosts for .cmd shims + */ export async function openFileWithExternalEditor( absolutePath: string, editor: ExternalEditorId ): Promise { if (!isLocalDesktop()) return - const candidates = await resolveExternalEditorOpenWithCandidates(editor) - let lastError: unknown - for (const app of candidates) { - try { - await openPath(absolutePath, app) - return - } catch (error) { - lastError = error - } - } - throw lastError instanceof Error - ? lastError - : new Error(`Failed to open with ${editor}`) + const { invoke } = await import("@tauri-apps/api/core") + await invoke("open_path_in_editor", { + path: absolutePath, + editor, + }) } export function systemExplorerLabelKey(