Skip to content
32 changes: 29 additions & 3 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
243 changes: 243 additions & 0 deletions src-tauri/src/commands/file_io.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<String> = 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<String> {
#[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");
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/components/ai-elements/link-safety.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ vi.mock("sonner", () => ({

vi.mock("@/lib/platform", () => ({
openUrl: mocks.openUrl,
isLocalDesktop: () => false,
}))

vi.mock("@/lib/transport", () => ({
Expand All @@ -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)
Expand Down
29 changes: 24 additions & 5 deletions src/components/ai-elements/link-safety.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -443,21 +450,33 @@ 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 (
<span className={cn("block min-w-0", className)}>
<FilePathContextMenu
filePath={filePath}
folderPath={folderPath ?? undefined}
onOpenInCodeg={handleOpen}
title={absoluteTitle}
className={cn("min-w-0", className)}
>
<button
type="button"
title={title ?? filePath}
title={absoluteTitle}
aria-busy={opening}
disabled={opening}
className="max-w-full cursor-pointer truncate text-left align-bottom hover:underline focus-visible:underline focus-visible:outline-none disabled:cursor-wait disabled:opacity-70 disabled:hover:no-underline"
className="block max-w-full min-w-0 cursor-pointer truncate text-left align-bottom hover:underline focus-visible:underline focus-visible:outline-none disabled:cursor-wait disabled:opacity-70 disabled:hover:no-underline"
onClick={(e) => {
e.stopPropagation()
handleOpen()
}}
>
{children}
</button>
</span>
</FilePathContextMenu>
)
}
Loading
Loading