Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
266 changes: 266 additions & 0 deletions src-tauri/src/commands/file_io.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
// Path / process helpers are only used by open_path_in_editor (desktop).
// PathBuf is Windows-only (LOCALAPPDATA candidate paths). Gate imports so
// clippy -D warnings stays clean on macOS/Linux desktop and server builds.
#[cfg(feature = "tauri-runtime")]
use std::path::Path;
#[cfg(all(feature = "tauri-runtime", target_os = "windows"))]
use std::path::PathBuf;
#[cfg(feature = "tauri-runtime")]
use std::process::{Command, Stdio};

use base64::{engine::general_purpose::STANDARD, Engine as _};

use crate::app_error::AppCommandError;
Expand Down Expand Up @@ -43,10 +53,266 @@ 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> {
// Validate editor first so invalid args do not depend on path existence
// (and unit tests stay cross-platform when using a dummy path).
let editor = editor.trim().to_ascii_lowercase();
if editor != "vscode" && editor != "cursor" {
return Err(AppCommandError::invalid_input(format!(
"unknown editor: {editor}"
)));
}

let file = Path::new(path);
if !file.exists() {
return Err(AppCommandError::not_found(format!(
"file does not exist: {path}"
)));
}

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")]
{
if editor == "vscode" {
vec!["Visual Studio Code".into()]
} else {
vec!["Cursor".into()]
}
}

#[cfg(target_os = "linux")]
{
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());
}
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() {
// Path need not exist: editor is validated first.
let err = open_path_in_editor_impl("/tmp/x", "notepad").expect_err("unknown");
assert!(matches!(
err.code,
crate::app_error::AppErrorCode::InvalidInput
));
}

#[test]
fn open_path_in_editor_rejects_missing_file() {
let err = open_path_in_editor_impl(
"/this/path/definitely/does/not/exist-codeg-open-editor",
"vscode",
)
.expect_err("missing file");
assert!(matches!(
err.code,
crate::app_error::AppErrorCode::NotFound
));
}

#[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
Loading