diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 3a2cd0ce3..a306f1ceb 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -980,8 +980,10 @@ impl DaemonState { app: Option, args: Vec, command: Option, + line: Option, + column: Option, ) -> Result<(), String> { - workspaces_core::open_workspace_in_core(path, app, args, command).await + workspaces_core::open_workspace_in_core(path, app, args, command, line, column).await } async fn get_open_app_icon(&self, app_name: String) -> Result, String> { diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs index 093a6baf7..3f18527f7 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs @@ -253,6 +253,8 @@ pub(super) async fn try_handle( request.app, request.args, request.command, + request.line, + request.column, )) .await, ) diff --git a/src-tauri/src/shared/workspace_rpc.rs b/src-tauri/src/shared/workspace_rpc.rs index e1e9b5af1..85acbf46e 100644 --- a/src-tauri/src/shared/workspace_rpc.rs +++ b/src-tauri/src/shared/workspace_rpc.rs @@ -99,6 +99,8 @@ pub(crate) struct OpenWorkspaceInRequest { pub(crate) app: Option, pub(crate) args: Vec, pub(crate) command: Option, + pub(crate) line: Option, + pub(crate) column: Option, } #[derive(Debug, Serialize, Deserialize)] diff --git a/src-tauri/src/shared/workspaces_core/io.rs b/src-tauri/src/shared/workspaces_core/io.rs index 4445007c8..28c6778e4 100644 --- a/src-tauri/src/shared/workspaces_core/io.rs +++ b/src-tauri/src/shared/workspaces_core/io.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; -#[cfg(target_os = "windows")] -use std::path::Path; -use std::path::PathBuf; +use std::env; +use std::path::{Path, PathBuf}; use tokio::sync::Mutex; @@ -12,11 +11,154 @@ use crate::types::WorkspaceEntry; use super::helpers::resolve_workspace_root; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LineAwareLaunchStrategy { + GotoFlag, + PathWithLineColumn, +} + +fn normalize_open_location(line: Option, column: Option) -> Option<(u32, Option)> { + let line = line.filter(|value| *value > 0)?; + let column = column.filter(|value| *value > 0); + Some((line, column)) +} + +fn format_path_with_location(path: &str, line: u32, column: Option) -> String { + match column { + Some(column) => format!("{path}:{line}:{column}"), + None => format!("{path}:{line}"), + } +} + +fn command_identifier(command: &str) -> String { + let trimmed = command.trim(); + let file_name = Path::new(trimmed) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(trimmed); + let stem = Path::new(file_name) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(file_name); + stem.trim().to_ascii_lowercase() +} + +fn command_launch_strategy(command: &str) -> Option { + let identifier = command_identifier(command); + if identifier == "code" + || identifier == "code-insiders" + || identifier == "cursor" + || identifier == "cursor-insiders" + { + return Some(LineAwareLaunchStrategy::GotoFlag); + } + if identifier == "zed" || identifier == "zed-preview" { + return Some(LineAwareLaunchStrategy::PathWithLineColumn); + } + None +} + +fn app_launch_strategy(app: &str) -> Option { + let normalized = normalize_app_identifier(app); + if normalized.contains("visual studio code") || normalized.starts_with("cursor") { + return Some(LineAwareLaunchStrategy::GotoFlag); + } + if normalized == "zed" || normalized.starts_with("zed ") { + return Some(LineAwareLaunchStrategy::PathWithLineColumn); + } + None +} + +fn app_cli_command(app: &str) -> Option<&'static str> { + let normalized = normalize_app_identifier(app); + if normalized.contains("visual studio code insiders") { + return Some("code-insiders"); + } + if normalized.contains("visual studio code") { + return Some("code"); + } + if normalized.starts_with("cursor") { + return Some("cursor"); + } + if normalized == "zed" || normalized.starts_with("zed ") { + return Some("zed"); + } + None +} + +fn normalize_app_identifier(app: &str) -> String { + app.trim() + .chars() + .map(|value| { + if value.is_ascii_alphanumeric() { + value.to_ascii_lowercase() + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn find_executable_in_path(program: &str) -> Option { + let trimmed = program.trim(); + if trimmed.is_empty() { + return None; + } + + let path = PathBuf::from(trimmed); + if path.is_file() { + return Some(path); + } + + let path_var = env::var_os("PATH")?; + for dir in env::split_paths(&path_var) { + let candidate = dir.join(trimmed); + if candidate.is_file() { + return Some(candidate); + } + } + + None +} + +fn build_launch_args( + path: &str, + args: &[String], + line: Option, + column: Option, + strategy: Option, +) -> Vec { + let mut launch_args = args.to_vec(); + if let Some((line, column)) = normalize_open_location(line, column) { + let located_path = format_path_with_location(path, line, column); + match strategy { + Some(LineAwareLaunchStrategy::GotoFlag) => { + launch_args.push("--goto".to_string()); + launch_args.push(located_path); + } + Some(LineAwareLaunchStrategy::PathWithLineColumn) => { + launch_args.push(located_path); + } + None => { + launch_args.push(path.to_string()); + } + } + return launch_args; + } + launch_args.push(path.to_string()); + launch_args +} + pub(crate) async fn open_workspace_in_core( path: String, app: Option, args: Vec, command: Option, + line: Option, + column: Option, ) -> Result<(), String> { fn output_snippet(bytes: &[u8]) -> Option { const MAX_CHARS: usize = 240; @@ -44,6 +186,13 @@ pub(crate) async fn open_workspace_in_core( if trimmed.is_empty() { return Err("Missing app or command".to_string()); } + let launch_args = build_launch_args( + &path, + &args, + line, + column, + command_launch_strategy(trimmed), + ); #[cfg(target_os = "windows")] let mut cmd = { @@ -56,9 +205,7 @@ pub(crate) async fn open_workspace_in_core( if matches!(ext.as_deref(), Some("cmd") | Some("bat")) { let mut cmd = tokio_command("cmd"); - let mut command_args = args.clone(); - command_args.push(path.clone()); - let command_line = build_cmd_c_command(resolved_path, &command_args)?; + let command_line = build_cmd_c_command(resolved_path, &launch_args)?; cmd.arg("/D"); cmd.arg("/S"); cmd.arg("/C"); @@ -66,7 +213,7 @@ pub(crate) async fn open_workspace_in_core( cmd } else { let mut cmd = tokio_command(resolved_path); - cmd.args(&args).arg(&path); + cmd.args(&launch_args); cmd } }; @@ -74,7 +221,7 @@ pub(crate) async fn open_workspace_in_core( #[cfg(not(target_os = "windows"))] let mut cmd = { let mut cmd = tokio_command(trimmed); - cmd.args(&args).arg(&path); + cmd.args(&launch_args); cmd }; @@ -86,27 +233,43 @@ pub(crate) async fn open_workspace_in_core( if trimmed.is_empty() { return Err("Missing app or command".to_string()); } + let app_strategy = app_launch_strategy(trimmed); #[cfg(target_os = "macos")] - let mut cmd = { - let mut cmd = tokio_command("open"); - cmd.arg("-a").arg(trimmed).arg(&path); - if !args.is_empty() { - cmd.arg("--args").args(&args); + { + if let (Some(strategy), Some(cli_program)) = ( + app_strategy, + normalize_open_location(line, column) + .and_then(|_| app_cli_command(trimmed)) + .and_then(find_executable_in_path), + ) { + let launch_args = build_launch_args(&path, &args, line, column, Some(strategy)); + let mut cmd = tokio_command(cli_program); + cmd.args(&launch_args); + cmd.output() + .await + .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? + } else { + let mut cmd = tokio_command("open"); + cmd.arg("-a").arg(trimmed).arg(&path); + if !args.is_empty() { + cmd.arg("--args").args(&args); + } + cmd.output() + .await + .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? } - cmd - }; + } #[cfg(not(target_os = "macos"))] - let mut cmd = { + { + let launch_args = build_launch_args(&path, &args, line, column, app_strategy); let mut cmd = tokio_command(trimmed); - cmd.args(&args).arg(&path); - cmd - }; - - cmd.output() - .await - .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? + cmd.args(&launch_args); + cmd.output() + .await + .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? + } } else { return Err("Missing app or command".to_string()); }; @@ -140,6 +303,116 @@ pub(crate) async fn open_workspace_in_core( } } +#[cfg(test)] +mod tests { + use super::{ + app_cli_command, app_launch_strategy, build_launch_args, command_launch_strategy, + LineAwareLaunchStrategy, + }; + + #[test] + fn matches_line_aware_command_targets() { + assert_eq!( + command_launch_strategy("/usr/local/bin/code"), + Some(LineAwareLaunchStrategy::GotoFlag) + ); + assert_eq!( + command_launch_strategy("cursor.cmd"), + Some(LineAwareLaunchStrategy::GotoFlag) + ); + assert_eq!( + command_launch_strategy("zed"), + Some(LineAwareLaunchStrategy::PathWithLineColumn) + ); + assert_eq!(command_launch_strategy("vim"), None); + } + + #[test] + fn matches_line_aware_app_targets() { + assert_eq!( + app_launch_strategy("Visual Studio Code"), + Some(LineAwareLaunchStrategy::GotoFlag) + ); + assert_eq!( + app_launch_strategy("Cursor"), + Some(LineAwareLaunchStrategy::GotoFlag) + ); + assert_eq!( + app_launch_strategy("Zed Preview"), + Some(LineAwareLaunchStrategy::PathWithLineColumn) + ); + assert_eq!(app_launch_strategy("Ghostty"), None); + } + + #[test] + fn maps_known_apps_to_cli_commands() { + assert_eq!(app_cli_command("Visual Studio Code"), Some("code")); + assert_eq!( + app_cli_command("Visual Studio Code Insiders"), + Some("code-insiders") + ); + assert_eq!( + app_cli_command("Visual Studio Code - Insiders"), + Some("code-insiders") + ); + assert_eq!(app_cli_command("Cursor"), Some("cursor")); + assert_eq!(app_cli_command("Zed Preview"), Some("zed")); + assert_eq!(app_cli_command("Ghostty"), None); + } + + #[test] + fn builds_goto_args_for_code_family_targets() { + let args = build_launch_args( + "/tmp/project/src/App.tsx", + &["--reuse-window".to_string()], + Some(33), + Some(7), + Some(LineAwareLaunchStrategy::GotoFlag), + ); + + assert_eq!( + args, + vec![ + "--reuse-window".to_string(), + "--goto".to_string(), + "/tmp/project/src/App.tsx:33:7".to_string(), + ] + ); + } + + #[test] + fn builds_line_suffixed_path_for_zed_targets() { + let args = build_launch_args( + "/tmp/project/src/App.tsx", + &[], + Some(33), + None, + Some(LineAwareLaunchStrategy::PathWithLineColumn), + ); + + assert_eq!(args, vec!["/tmp/project/src/App.tsx:33".to_string()]); + } + + #[test] + fn falls_back_to_plain_path_for_unknown_targets() { + let args = build_launch_args( + "/tmp/project/src/App.tsx", + &["--foreground".to_string()], + Some(33), + Some(7), + None, + ); + + assert_eq!( + args, + vec![ + "--foreground".to_string(), + "/tmp/project/src/App.tsx".to_string(), + ] + ); + } +} + #[cfg(target_os = "macos")] pub(crate) async fn get_open_app_icon_core( app_name: String, diff --git a/src-tauri/src/workspaces/commands.rs b/src-tauri/src/workspaces/commands.rs index 7a54ae0aa..4f8166e75 100644 --- a/src-tauri/src/workspaces/commands.rs +++ b/src-tauri/src/workspaces/commands.rs @@ -645,8 +645,10 @@ pub(crate) async fn open_workspace_in( app: Option, args: Vec, command: Option, + line: Option, + column: Option, ) -> Result<(), String> { - workspaces_core::open_workspace_in_core(path, app, args, command).await + workspaces_core::open_workspace_in_core(path, app, args, command, line, column).await } #[tauri::command] diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx new file mode 100644 index 000000000..5a76f69a7 --- /dev/null +++ b/src/features/messages/components/Markdown.test.tsx @@ -0,0 +1,276 @@ +// @vitest-environment jsdom +import { cleanup, createEvent, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Markdown } from "./Markdown"; + +describe("Markdown file-like href behavior", () => { + afterEach(() => { + cleanup(); + }); + + it("prevents file-like href navigation when no file opener is provided", () => { + render( + , + ); + + const link = screen.getByText("setup").closest("a"); + expect(link?.getAttribute("href")).toBe("./docs/setup.md"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + }); + + it("intercepts file-like href clicks when a file opener is provided", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("setup").closest("a"); + expect(link?.getAttribute("href")).toBe("./docs/setup.md"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("./docs/setup.md"); + }); + + it("prevents bare relative link navigation without treating it as a file", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("setup").closest("a"); + expect(link?.getAttribute("href")).toBe("docs/setup.md"); + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); + + it("still intercepts explicit workspace file hrefs when a file opener is provided", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("example").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/src/example.ts"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/src/example.ts"); + }); + + it("still intercepts dotless workspace file hrefs when a file opener is provided", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("license").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/CodexMonitor/LICENSE"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/CodexMonitor/LICENSE"); + }); + + it("intercepts mounted workspace links outside the old root allowlist", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("workflows").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/.github/workflows"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/.github/workflows"); + }); + + it("intercepts mounted workspace directory links that resolve relative to the workspace", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("assets").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/dist/assets"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspace/dist/assets"); + }); + + it("keeps generic workspace routes as normal markdown links", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("overview").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/reviews/overview"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); + + it("keeps nested workspaces routes as normal markdown links", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("overview").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspaces/team/reviews/overview"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); + + it("still intercepts nested workspace file hrefs when a file opener is provided", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("src").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspaces/team/CodexMonitor/src"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("/workspaces/team/CodexMonitor/src"); + }); + + it("intercepts file hrefs that use #L line anchors", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("markdown").closest("a"); + expect(link?.getAttribute("href")).toBe("./docs/setup.md#L12"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).toHaveBeenCalledWith("./docs/setup.md:12"); + }); + + it("prevents unsupported route fragments without treating them as file links", () => { + const onOpenFileLink = vi.fn(); + render( + , + ); + + const link = screen.getByText("profile").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings/profile#details"); + + const clickEvent = createEvent.click(link as Element, { + bubbles: true, + cancelable: true, + }); + fireEvent(link as Element, clickEvent); + expect(clickEvent.defaultPrevented).toBe(true); + expect(onOpenFileLink).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx index ec450531d..503aef0b0 100644 --- a/src/features/messages/components/Markdown.tsx +++ b/src/features/messages/components/Markdown.tsx @@ -9,6 +9,7 @@ import { remarkFileLinks, toFileLink, } from "../../../utils/remarkFileLinks"; +import { resolveMountedWorkspacePath } from "../utils/mountedWorkspacePaths"; type MarkdownProps = { value: string; @@ -180,6 +181,242 @@ function normalizeUrlLine(line: string) { return withoutBullet; } +function safeDecodeURIComponent(value: string) { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +function safeDecodeFileLink(url: string) { + try { + return decodeFileLink(url); + } catch { + return null; + } +} + +const FILE_LINE_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; +const FILE_HASH_LINE_SUFFIX_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; +const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [ + "/Users/", + "/home/", + "/tmp/", + "/var/", + "/opt/", + "/etc/", + "/private/", + "/Volumes/", + "/mnt/", + "/usr/", + "/workspace/", + "/workspaces/", + "/root/", + "/srv/", + "/data/", +]; +const WORKSPACE_ROUTE_PREFIXES = ["/workspace/", "/workspaces/"]; +const LOCAL_WORKSPACE_ROUTE_SEGMENTS = new Set(["reviews", "settings"]); + +function stripPathLineSuffix(value: string) { + return value.replace(FILE_LINE_SUFFIX_PATTERN, ""); +} + +function hasLikelyFileName(path: string) { + const normalizedPath = stripPathLineSuffix(path).replace(/[\\/]+$/, ""); + const lastSegment = normalizedPath.split(/[\\/]/).pop() ?? ""; + if (!lastSegment || lastSegment === "." || lastSegment === "..") { + return false; + } + if (lastSegment.startsWith(".") && lastSegment.length > 1) { + return true; + } + return lastSegment.includes("."); +} + +function hasLikelyLocalAbsolutePrefix(path: string) { + const normalizedPath = path.replace(/\\/g, "/"); + return LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES.some((prefix) => + normalizedPath.startsWith(prefix), + ); +} + +function splitWorkspaceRoutePath(path: string) { + const normalizedPath = path.replace(/\\/g, "/"); + if (normalizedPath.startsWith("/workspace/")) { + return { + segments: normalizedPath.slice("/workspace/".length).split("/").filter(Boolean), + prefix: "/workspace/", + }; + } + if (normalizedPath.startsWith("/workspaces/")) { + return { + segments: normalizedPath.slice("/workspaces/".length).split("/").filter(Boolean), + prefix: "/workspaces/", + }; + } + return null; +} + +function hasLikelyWorkspaceNameSegment(segment: string) { + return /[A-Z]/.test(segment) || /[._-]/.test(segment); +} + +function isKnownLocalWorkspaceRoutePath(path: string) { + const mountedPath = splitWorkspaceRoutePath(path); + if (!mountedPath || mountedPath.segments.length === 0) { + return false; + } + + const routeSegment = + mountedPath.prefix === "/workspace/" + ? mountedPath.segments[0] + : mountedPath.segments[1]; + return Boolean(routeSegment) && LOCAL_WORKSPACE_ROUTE_SEGMENTS.has(routeSegment); +} + +function isLikelyMountedWorkspaceFilePath( + path: string, + workspacePath?: string | null, +) { + if (isKnownLocalWorkspaceRoutePath(path)) { + return false; + } + if (resolveMountedWorkspacePath(path, workspacePath) !== null) { + return true; + } + + const mountedPath = splitWorkspaceRoutePath(path); + return Boolean( + mountedPath?.prefix === "/workspace/" && + mountedPath.segments.length >= 2 && + hasLikelyWorkspaceNameSegment(mountedPath.segments[0]), + ); +} + +function usesAbsolutePathDepthFallback( + path: string, + workspacePath?: string | null, +) { + const normalizedPath = path.replace(/\\/g, "/"); + if ( + WORKSPACE_ROUTE_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix)) && + !isLikelyMountedWorkspaceFilePath(normalizedPath, workspacePath) + ) { + return false; + } + return hasLikelyLocalAbsolutePrefix(normalizedPath) && pathSegmentCount(normalizedPath) >= 3; +} + +function pathSegmentCount(path: string) { + return path.split("/").filter(Boolean).length; +} + +function toPathFromFileHashAnchor( + url: string, + workspacePath?: string | null, +) { + const hashIndex = url.indexOf("#"); + if (hashIndex <= 0) { + return null; + } + const basePath = url.slice(0, hashIndex).trim(); + const hash = url.slice(hashIndex).trim(); + const match = hash.match(FILE_HASH_LINE_SUFFIX_PATTERN); + if (!basePath || !match || !isLikelyFileHref(basePath, workspacePath)) { + return null; + } + const [, line, column] = match; + return `${basePath}:${line}${column ? `:${column}` : ""}`; +} + +function isLikelyFileHref( + url: string, + workspacePath?: string | null, +) { + const trimmed = url.trim(); + if (!trimmed) { + return false; + } + if (trimmed.startsWith("file://")) { + return true; + } + if ( + trimmed.startsWith("http://") || + trimmed.startsWith("https://") || + trimmed.startsWith("mailto:") + ) { + return false; + } + if (trimmed.startsWith("thread://") || trimmed.startsWith("/thread/")) { + return false; + } + if (trimmed.startsWith("#")) { + return false; + } + if (/[?#]/.test(trimmed)) { + return false; + } + if (/^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.startsWith("\\\\")) { + return true; + } + if (trimmed.startsWith("/")) { + if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { + return true; + } + if (hasLikelyFileName(trimmed)) { + return true; + } + return usesAbsolutePathDepthFallback(trimmed, workspacePath); + } + if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) { + return true; + } + if (trimmed.startsWith("~/")) { + return true; + } + if (trimmed.startsWith("./") || trimmed.startsWith("../")) { + return FILE_LINE_SUFFIX_PATTERN.test(trimmed) || hasLikelyFileName(trimmed); + } + if (hasLikelyFileName(trimmed)) { + return pathSegmentCount(trimmed) >= 3; + } + return false; +} + +function toPathFromFileUrl(url: string) { + if (!url.toLowerCase().startsWith("file://")) { + return null; + } + + try { + const parsed = new URL(url); + if (parsed.protocol !== "file:") { + return null; + } + + const decodedPath = safeDecodeURIComponent(parsed.pathname) ?? parsed.pathname; + let path = decodedPath; + if (parsed.host && parsed.host !== "localhost") { + const normalizedPath = decodedPath.startsWith("/") + ? decodedPath + : `/${decodedPath}`; + path = `//${parsed.host}${normalizedPath}`; + } + if (/^\/[A-Za-z]:\//.test(path)) { + path = path.slice(1); + } + return path; + } catch { + const manualPath = url.slice("file://".length).trim(); + if (!manualPath) { + return null; + } + return safeDecodeURIComponent(manualPath) ?? manualPath; + } +} + function extractUrlLines(value: string) { const lines = value.split(/\r?\n/); const urls = lines @@ -453,6 +690,10 @@ export function Markdown({ event.stopPropagation(); onOpenFileLink?.(path); }; + const handleLocalLinkClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; const handleFileLinkContextMenu = ( event: React.MouseEvent, path: string, @@ -474,9 +715,48 @@ export function Markdown({ } return trimmed; }; + const resolveHrefFilePath = (url: string) => { + const hashAnchorPath = toPathFromFileHashAnchor(url, workspacePath); + if (hashAnchorPath) { + const anchoredPath = getLinkablePath(hashAnchorPath); + if (anchoredPath) { + return safeDecodeURIComponent(anchoredPath) ?? anchoredPath; + } + } + if (isLikelyFileHref(url, workspacePath)) { + const directPath = getLinkablePath(url); + if (directPath) { + return safeDecodeURIComponent(directPath) ?? directPath; + } + } + const decodedUrl = safeDecodeURIComponent(url); + if (decodedUrl) { + const decodedHashAnchorPath = toPathFromFileHashAnchor( + decodedUrl, + workspacePath, + ); + if (decodedHashAnchorPath) { + const anchoredPath = getLinkablePath(decodedHashAnchorPath); + if (anchoredPath) { + return anchoredPath; + } + } + } + if (decodedUrl && isLikelyFileHref(decodedUrl, workspacePath)) { + const decodedPath = getLinkablePath(decodedUrl); + if (decodedPath) { + return decodedPath; + } + } + const fileUrlPath = toPathFromFileUrl(url); + if (!fileUrlPath) { + return null; + } + return getLinkablePath(fileUrlPath); + }; const components: Components = { a: ({ href, children }) => { - const url = href ?? ""; + const url = (href ?? "").trim(); const threadId = url.startsWith("thread://") ? url.slice("thread://".length).trim() : url.startsWith("/thread/") @@ -497,7 +777,20 @@ export function Markdown({ ); } if (isFileLinkUrl(url)) { - const path = decodeFileLink(url); + const path = safeDecodeFileLink(url); + if (!path) { + return ( + { + event.preventDefault(); + event.stopPropagation(); + }} + > + {children} + + ); + } return ( ); } + const hrefFilePath = resolveHrefFilePath(url); + if (hrefFilePath) { + const clickHandler = (event: React.MouseEvent) => + handleFileLinkClick(event, hrefFilePath); + const contextMenuHandler = onOpenFileLinkMenu + ? (event: React.MouseEvent) => handleFileLinkContextMenu(event, hrefFilePath) + : undefined; + return ( + + {children} + + ); + } const isExternal = url.startsWith("http://") || url.startsWith("https://") || url.startsWith("mailto:"); if (!isExternal) { - return {children}; + if (url.startsWith("#")) { + return {children}; + } + return ( + + {children} + + ); } return ( diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx index efd3990d2..0718e08c5 100644 --- a/src/features/messages/components/Messages.test.tsx +++ b/src/features/messages/components/Messages.test.tsx @@ -234,6 +234,272 @@ describe("Messages", () => { ); }); + it("routes markdown href file paths through the file opener", () => { + const linkedPath = + "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx:244"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-link", + kind: "message", + role: "assistant", + text: `Open [this file](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("this file")); + expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); + }); + + it("routes absolute non-whitelisted file href paths through the file opener", () => { + const linkedPath = "/custom/project/src/App.tsx:12"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-absolute-non-whitelisted-link", + kind: "message", + role: "assistant", + text: `Open [app file](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("app file")); + expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); + }); + + it("decodes percent-encoded href file paths before opening", () => { + const items: ConversationItem[] = [ + { + id: "msg-file-href-encoded-link", + kind: "message", + role: "assistant", + text: "Open [guide](./docs/My%20Guide.md)", + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("guide")); + expect(openFileLinkMock).toHaveBeenCalledWith("./docs/My Guide.md"); + }); + + it("routes absolute href file paths with #L anchors through the file opener", () => { + const linkedPath = + "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx#L244"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-anchor-link", + kind: "message", + role: "assistant", + text: `Open [this file](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("this file")); + expect(openFileLinkMock).toHaveBeenCalledWith( + "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx:244", + ); + }); + + it("routes dotless workspace href file paths through the file opener", () => { + const linkedPath = "/workspace/CodexMonitor/LICENSE"; + const items: ConversationItem[] = [ + { + id: "msg-file-href-workspace-dotless-link", + kind: "message", + role: "assistant", + text: `Open [license](${linkedPath})`, + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("license")); + expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath); + }); + + it("keeps non-file relative links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-href-link", + kind: "message", + role: "assistant", + text: "See [Help](/help/getting-started)", + }, + ]; + + render( + , + ); + + const helpLink = screen.getByText("Help").closest("a"); + expect(helpLink?.getAttribute("href")).toBe("/help/getting-started"); + fireEvent.click(screen.getByText("Help")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + + it("keeps route-like absolute links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-workspace-route-link", + kind: "message", + role: "assistant", + text: "See [Workspace Home](/workspace/settings)", + }, + ]; + + render( + , + ); + + const link = screen.getByText("Workspace Home").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings"); + fireEvent.click(screen.getByText("Workspace Home")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + + it("keeps deep workspace route links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-workspace-route-link-deep", + kind: "message", + role: "assistant", + text: "See [Profile](/workspace/settings/profile)", + }, + ]; + + render( + , + ); + + const link = screen.getByText("Profile").closest("a"); + expect(link?.getAttribute("href")).toBe("/workspace/settings/profile"); + fireEvent.click(screen.getByText("Profile")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + + it("keeps dot-relative non-file links as normal markdown links", () => { + const items: ConversationItem[] = [ + { + id: "msg-help-dot-relative-href-link", + kind: "message", + role: "assistant", + text: "See [Help](./help/getting-started)", + }, + ]; + + render( + , + ); + + const helpLink = screen.getByText("Help").closest("a"); + expect(helpLink?.getAttribute("href")).toBe("./help/getting-started"); + fireEvent.click(screen.getByText("Help")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + + it("does not crash or navigate on malformed codex-file links", () => { + const items: ConversationItem[] = [ + { + id: "msg-malformed-file-link", + kind: "message", + role: "assistant", + text: "Bad [path](codex-file:%E0%A4%A)", + }, + ]; + + render( + , + ); + + fireEvent.click(screen.getByText("path")); + expect(openFileLinkMock).not.toHaveBeenCalled(); + }); + it("hides file parent paths when message file path display is disabled", () => { const items: ConversationItem[] = [ { diff --git a/src/features/messages/hooks/useFileLinkOpener.test.tsx b/src/features/messages/hooks/useFileLinkOpener.test.tsx new file mode 100644 index 000000000..42b6816a9 --- /dev/null +++ b/src/features/messages/hooks/useFileLinkOpener.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { openWorkspaceIn } from "../../../services/tauri"; +import { useFileLinkOpener } from "./useFileLinkOpener"; + +vi.mock("../../../services/tauri", () => ({ + openWorkspaceIn: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-opener", () => ({ + revealItemInDir: vi.fn(), +})); + +vi.mock("@tauri-apps/api/menu", () => ({ + Menu: { new: vi.fn() }, + MenuItem: { new: vi.fn() }, + PredefinedMenuItem: { new: vi.fn() }, +})); + +vi.mock("@tauri-apps/api/dpi", () => ({ + LogicalPosition: vi.fn(), +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: vi.fn(), +})); + +vi.mock("@sentry/react", () => ({ + captureException: vi.fn(), +})); + +vi.mock("../../../services/toasts", () => ({ + pushErrorToast: vi.fn(), +})); + +describe("useFileLinkOpener", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("maps /workspace root-relative paths to the active workspace path", async () => { + const workspacePath = "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"; + const openWorkspaceInMock = vi.mocked(openWorkspaceIn); + const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); + + await act(async () => { + await result.current.openFileLink("/workspace/src/features/messages/components/Markdown.tsx"); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor/src/features/messages/components/Markdown.tsx", + expect.objectContaining({ appName: "Visual Studio Code", args: [] }), + ); + }); + + it("maps /workspace//... paths to the active workspace path", async () => { + const workspacePath = "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"; + const openWorkspaceInMock = vi.mocked(openWorkspaceIn); + const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); + + await act(async () => { + await result.current.openFileLink("/workspace/CodexMonitor/LICENSE"); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor/LICENSE", + expect.objectContaining({ appName: "Visual Studio Code", args: [] }), + ); + }); + + it("maps nested /workspaces/...//... paths to the active workspace path", async () => { + const workspacePath = "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"; + const openWorkspaceInMock = vi.mocked(openWorkspaceIn); + const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); + + await act(async () => { + await result.current.openFileLink("/workspaces/team/CodexMonitor/src"); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor/src", + expect.objectContaining({ appName: "Visual Studio Code", args: [] }), + ); + }); + + it("preserves file link line and column metadata for editor opens", async () => { + const workspacePath = "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"; + const openWorkspaceInMock = vi.mocked(openWorkspaceIn); + const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); + + await act(async () => { + await result.current.openFileLink( + "/workspace/src/features/messages/components/Markdown.tsx:33:7", + ); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor/src/features/messages/components/Markdown.tsx", + expect.objectContaining({ + appName: "Visual Studio Code", + args: [], + line: 33, + column: 7, + }), + ); + }); + + it("parses #L line anchors before opening the editor", async () => { + const workspacePath = "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"; + const openWorkspaceInMock = vi.mocked(openWorkspaceIn); + const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); + + await act(async () => { + await result.current.openFileLink("/workspace/src/App.tsx#L33"); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor/src/App.tsx", + expect.objectContaining({ + appName: "Visual Studio Code", + args: [], + line: 33, + }), + ); + }); + + it("normalizes line ranges to the starting line before opening the editor", async () => { + const workspacePath = "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor"; + const openWorkspaceInMock = vi.mocked(openWorkspaceIn); + const { result } = renderHook(() => useFileLinkOpener(workspacePath, [], "")); + + await act(async () => { + await result.current.openFileLink( + "/workspace/src/features/messages/components/Markdown.tsx:366-369", + ); + }); + + expect(openWorkspaceInMock).toHaveBeenCalledWith( + "/Users/sotiriskaniras/Documents/Development/Forks/CodexMonitor/src/features/messages/components/Markdown.tsx", + expect.objectContaining({ + appName: "Visual Studio Code", + args: [], + line: 366, + }), + ); + }); +}); diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index 879d5259b..65ea91bdd 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -13,6 +13,7 @@ import { joinWorkspacePath, revealInFileManagerLabel, } from "../../../utils/platformPaths"; +import { resolveMountedWorkspacePath } from "../utils/mountedWorkspacePaths"; type OpenTarget = { id: string; @@ -34,6 +35,7 @@ const DEFAULT_OPEN_TARGET: OpenTarget = { const resolveAppName = (target: OpenTarget) => (target.appName ?? "").trim(); const resolveCommand = (target: OpenTarget) => (target.command ?? "").trim(); + const canOpenTarget = (target: OpenTarget) => { if (target.kind === "finder") { return true; @@ -49,15 +51,94 @@ function resolveFilePath(path: string, workspacePath?: string | null) { if (!workspacePath) { return trimmed; } + const mountedWorkspacePath = resolveMountedWorkspacePath(trimmed, workspacePath); + if (mountedWorkspacePath) { + return mountedWorkspacePath; + } if (isAbsolutePath(trimmed)) { return trimmed; } return joinWorkspacePath(workspacePath, trimmed); } -function stripLineSuffix(path: string) { - const match = path.match(/^(.*?)(?::\d+(?::\d+)?)?$/); - return match ? match[1] : path; +type ParsedFileLocation = { + path: string; + line: number | null; + column: number | null; +}; + +const FILE_LOCATION_SUFFIX_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; +const FILE_LOCATION_RANGE_SUFFIX_PATTERN = /^(.*?):(\d+)-(\d+)$/; +const FILE_LOCATION_HASH_PATTERN = /^(.*?)#L(\d+)(?:C(\d+))?$/i; + +function parsePositiveInteger(value?: string) { + if (!value) { + return null; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function parseFileLocation(rawPath: string): ParsedFileLocation { + const trimmed = rawPath.trim(); + const hashMatch = trimmed.match(FILE_LOCATION_HASH_PATTERN); + if (hashMatch) { + const [, path, lineValue, columnValue] = hashMatch; + const line = parsePositiveInteger(lineValue); + if (line !== null) { + return { + path, + line, + column: parsePositiveInteger(columnValue), + }; + } + } + + const match = trimmed.match(FILE_LOCATION_SUFFIX_PATTERN); + if (match) { + const [, path, lineValue, columnValue] = match; + const line = parsePositiveInteger(lineValue); + if (line === null) { + return { + path: trimmed, + line: null, + column: null, + }; + } + + return { + path, + line, + column: parsePositiveInteger(columnValue), + }; + } + + const rangeMatch = trimmed.match(FILE_LOCATION_RANGE_SUFFIX_PATTERN); + if (rangeMatch) { + const [, path, startLineValue] = rangeMatch; + const startLine = parsePositiveInteger(startLineValue); + if (startLine !== null) { + return { + path, + line: startLine, + column: null, + }; + } + } + + return { + path: trimmed, + line: null, + column: null, + }; +} + +function toFileUrl(path: string, line: number | null, column: number | null) { + const base = path.startsWith("/") ? `file://${path}` : path; + if (line === null) { + return base; + } + return `${base}#L${line}${column !== null ? `C${column}` : ""}`; } export function useFileLinkOpener( @@ -93,7 +174,12 @@ export function useFileLinkOpener( ...(openTargets.find((entry) => entry.id === selectedOpenAppId) ?? openTargets[0]), }; - const resolvedPath = resolveFilePath(stripLineSuffix(rawPath), workspacePath); + const fileLocation = parseFileLocation(rawPath); + const resolvedPath = resolveFilePath(fileLocation.path, workspacePath); + const openLocation = { + ...(fileLocation.line !== null ? { line: fileLocation.line } : {}), + ...(fileLocation.column !== null ? { column: fileLocation.column } : {}), + }; try { if (!canOpenTarget(target)) { @@ -112,6 +198,7 @@ export function useFileLinkOpener( await openWorkspaceIn(resolvedPath, { command, args: target.args, + ...openLocation, }); return; } @@ -123,6 +210,7 @@ export function useFileLinkOpener( await openWorkspaceIn(resolvedPath, { appName, args: target.args, + ...openLocation, }); } catch (error) { reportOpenError(error, { @@ -148,7 +236,8 @@ export function useFileLinkOpener( ...(openTargets.find((entry) => entry.id === selectedOpenAppId) ?? openTargets[0]), }; - const resolvedPath = resolveFilePath(stripLineSuffix(rawPath), workspacePath); + const fileLocation = parseFileLocation(rawPath); + const resolvedPath = resolveFilePath(fileLocation.path, workspacePath); const appName = resolveAppName(target); const command = resolveCommand(target); const canOpen = canOpenTarget(target); @@ -199,8 +288,7 @@ export function useFileLinkOpener( await MenuItem.new({ text: "Copy Link", action: async () => { - const link = - resolvedPath.startsWith("/") ? `file://${resolvedPath}` : resolvedPath; + const link = toFileUrl(resolvedPath, fileLocation.line, fileLocation.column); try { await navigator.clipboard.writeText(link); } catch { diff --git a/src/features/messages/utils/mountedWorkspacePaths.ts b/src/features/messages/utils/mountedWorkspacePaths.ts new file mode 100644 index 000000000..15963fe8d --- /dev/null +++ b/src/features/messages/utils/mountedWorkspacePaths.ts @@ -0,0 +1,70 @@ +import { joinWorkspacePath } from "../../../utils/platformPaths"; + +const WORKSPACE_MOUNT_PREFIX = "/workspace/"; +const WORKSPACES_MOUNT_PREFIX = "/workspaces/"; + +function normalizePathSeparators(path: string) { + return path.replace(/\\/g, "/"); +} + +function trimTrailingSeparators(path: string) { + return path.replace(/[\\/]+$/, ""); +} + +function pathBaseName(path: string) { + return trimTrailingSeparators(normalizePathSeparators(path.trim())) + .split("/") + .filter(Boolean) + .pop() ?? ""; +} + +export function resolveMountedWorkspacePath( + path: string, + workspacePath?: string | null, +) { + const trimmed = path.trim(); + const trimmedWorkspace = workspacePath?.trim() ?? ""; + if (!trimmedWorkspace) { + return null; + } + + const normalizedPath = normalizePathSeparators(trimmed); + const workspaceName = pathBaseName(trimmedWorkspace); + if (!workspaceName) { + return null; + } + + const resolveFromSegments = (segments: string[], allowDirectRelative: boolean) => { + if (segments.length === 0) { + return trimTrailingSeparators(trimmedWorkspace); + } + const workspaceIndex = segments.findIndex((segment) => segment === workspaceName); + if (workspaceIndex >= 0) { + const relativePath = segments.slice(workspaceIndex + 1).join("/"); + return relativePath + ? joinWorkspacePath(trimmedWorkspace, relativePath) + : trimTrailingSeparators(trimmedWorkspace); + } + if (allowDirectRelative) { + return joinWorkspacePath(trimmedWorkspace, segments.join("/")); + } + return null; + }; + + if (normalizedPath.startsWith(WORKSPACE_MOUNT_PREFIX)) { + return resolveFromSegments( + normalizedPath.slice(WORKSPACE_MOUNT_PREFIX.length).split("/").filter(Boolean), + true, + ); + } + if (normalizedPath.startsWith(WORKSPACES_MOUNT_PREFIX)) { + return resolveFromSegments( + normalizedPath + .slice(WORKSPACES_MOUNT_PREFIX.length) + .split("/") + .filter(Boolean), + false, + ); + } + return null; +} diff --git a/src/services/tauri.test.ts b/src/services/tauri.test.ts index 808cfd213..06fb4e08f 100644 --- a/src/services/tauri.test.ts +++ b/src/services/tauri.test.ts @@ -358,6 +358,29 @@ describe("tauri invoke wrappers", () => { app: "Xcode", command: null, args: ["--reuse-window"], + line: null, + column: null, + }); + }); + + it("passes line-aware openWorkspaceIn options", async () => { + const invokeMock = vi.mocked(invoke); + invokeMock.mockResolvedValueOnce({}); + + await openWorkspaceIn("/tmp/project/src/App.tsx", { + command: "code", + args: ["--reuse-window"], + line: 33, + column: 7, + }); + + expect(invokeMock).toHaveBeenCalledWith("open_workspace_in", { + path: "/tmp/project/src/App.tsx", + app: null, + command: "code", + args: ["--reuse-window"], + line: 33, + column: 7, }); }); diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 07919a72f..afc920574 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -342,6 +342,8 @@ export async function openWorkspaceIn( appName?: string | null; command?: string | null; args?: string[]; + line?: number | null; + column?: number | null; }, ): Promise { return invoke("open_workspace_in", { @@ -349,6 +351,8 @@ export async function openWorkspaceIn( app: options.appName ?? null, command: options.command ?? null, args: options.args ?? [], + line: options.line ?? null, + column: options.column ?? null, }); }