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 (
@@ -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(