diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e2758ba5f..0cea42301 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -38,9 +38,35 @@ { "identifier": "opener:allow-open-path", "allow": [ - { - "path": "$HOME/**" - } + { "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": "**" } ] }, "dialog:default", 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/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..8b3eae2a2 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -1801,8 +1801,18 @@ 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) => { + if ( + event.target instanceof Element && + 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) { diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 941d5edc3..5adf860b5 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -1,5 +1,19 @@ -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 { + isFilePathToolName, + pathFromToolTitle, +} from "@/lib/tool-file-path-guard" +import { toast } from "sonner" +import { toErrorMessage } from "@/lib/app-error" import { classifyToolKind, TOOL_KIND_ORDER, @@ -217,9 +231,80 @@ 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". + */ +// pathFromToolTitle: @/lib/tool-file-path-guard (strict; unit-tested) + +/** + * 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, + opts?: { allowTitleFallback?: boolean } +): 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) + // 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 = + 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 + } + + if (opts?.allowTitleFallback === false) return null + return pathFromToolTitle(displayTitle) } /** Truncate text to maxLen, appending "…" if truncated. */ @@ -867,6 +952,56 @@ function getToolIcon( // ── title derivation ────────────────────────────────────────────────── +// isFilePathToolName: @/lib/tool-file-path-guard + +/** + * 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 +1014,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 +1027,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 +1526,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 +1552,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 +1595,10 @@ function FileToolInput({ {filePath ? ( - {filePath} + {displayPath ?? filePath} ) : ( @@ -1784,6 +1939,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 +2016,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 +2278,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 +2302,60 @@ 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]
+  )
+  // 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 ?? null,
+      null,
+      { allowTitleFallback: false }
+    )
+  }, [normalizedToolName, part.input, part.output, part.errorText])
+  const titleTooltip = useMemo(() => {
+    if (!toolFilePath) return undefined
+    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)).catch((error) => {
+      toast.error(toErrorMessage(error))
+    })
+  }, [openFilePreview, toolFilePath])
   const lineChangeStats = useMemo(() => {
     if (toolNameLower !== "edit" && toolNameLower !== "apply_patch") {
       return null
@@ -2470,16 +2679,37 @@ const ToolCallPart = memo(function ToolCallPart({
 
   const open = (isRunning && (isCommandTool || hasLiveOutput)) || manualOpen
 
+  const toolHeader = (
+    
+  )
+
   return (
     
-      
+      {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 + )} {part.input && (!isCommandTool || !shouldRenderCommandTerminal) && ( + 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..2c8b98d5b --- /dev/null +++ b/src/components/shared/file-path-context-menu.tsx @@ -0,0 +1,322 @@ +"use client" + +/** + * VS Code-style right-click menu for a workspace / session file path. + * + * Used by the message navigator, reply artifacts, tool rows, and FilePathLink. + * + * 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 { + 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" +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 + /** 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 + /** + * Native HTML tooltip for the trigger surface (e.g. absolute path on a + * tool-row wrapper). Applied on the trigger / slotted child. + */ + title?: string + /** + * 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 + children: ReactNode +} + +export function FilePathContextMenu({ + filePath, + folderPath, + externalOpenDisabled = false, + onOpenInCodeg, + title, + asChild = true, + className, + 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 + 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), { duration: TOAST_SUCCESS_MS }) + } else { + toast.error(t("copyFailed"), { duration: TOAST_ERROR_MS }) + } + }, + [t] + ) + + const runExternal = useCallback( + async (action: () => Promise) => { + try { + await action() + } catch (error) { + toast.error(t("openFailed"), { + description: toErrorMessage(error), + duration: TOAST_ERROR_MS, + }) + } + }, + [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] + ) + + const handleAddToChat = useCallback(() => { + if (!activeSessionTabId || !attachPath) { + toast.error(t("noActiveConversation"), { duration: TOAST_ERROR_MS }) + return + } + emitAttachFileToSession({ + tabId: activeSessionTabId, + path: attachPath, + }) + toast.success(t("addToChatDone", { label: fileName }), { + duration: TOAST_SUCCESS_MS, + }) + }, [activeSessionTabId, attachPath, fileName, t]) + + // 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() + }, []) + + // Touch / pen long-press uses button 0 + Radix's 700ms path; only stopping + // button === 2 left both menus open and the outer modal ate pointer events. + const claimRightPointer = useCallback((event: ReactPointerEvent) => { + if (event.button === 2 || event.pointerType !== "mouse") { + 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} + + + {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..fca03040f --- /dev/null +++ b/src/lib/file-path-actions.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest" +import { + getExternalEditorOpenWith, + 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("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 new file mode 100644 index 000000000..f960f1ed9 --- /dev/null +++ b/src/lib/file-path-actions.ts @@ -0,0 +1,103 @@ +/** + * 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 { detectPlatform, type PlatformType } from "@/hooks/use-platform" +import { copyTextFromMenu } from "@/lib/utils" + +export type ExternalEditorId = "vscode" | "cursor" + +/** + * 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, + 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", +} + +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 { + 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) +} + +/** + * 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 { invoke } = await import("@tauri-apps/api/core") + await invoke("open_path_in_editor", { + path: absolutePath, + editor, + }) +} + +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..f4954a43f 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -81,14 +81,24 @@ 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 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): 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) + // Normalize separators so scope globs match Windows drive paths too. + const normalized = path.replace(/\\/g, "/") + await tauriOpenPath(normalized, openWith) } } @@ -100,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) } } 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 +}