diff --git a/apps/staged/src-tauri/src/util_commands.rs b/apps/staged/src-tauri/src/util_commands.rs index cc4e1d1a..bcdef8cf 100644 --- a/apps/staged/src-tauri/src/util_commands.rs +++ b/apps/staged/src-tauri/src/util_commands.rs @@ -4,42 +4,60 @@ use crate::blox; use serde::Serialize; use std::path::Path; -/// An application that can open directories. +/// An application that can open directories or files. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct OpenerApp { id: String, name: String, + kind: OpenerKind, } -/// Known applications with their bundle IDs (macOS). +/// The kind of opener application. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum OpenerKind { + Editor, + Terminal, + FileBrowser, +} + +/// Known applications with their bundle IDs and kinds (macOS). #[cfg(target_os = "macos")] -const KNOWN_OPENERS: &[(&str, &str)] = &[ +const KNOWN_OPENERS: &[(&str, &str, OpenerKind)] = &[ // Terminals - ("terminal", "com.apple.Terminal"), - ("warp", "dev.warp.Warp-Stable"), - ("iterm", "com.googlecode.iterm2"), - ("hyper", "co.zeit.hyper"), - ("kitty", "net.kovidgoyal.kitty"), - ("alacritty", "org.alacritty"), + ("terminal", "com.apple.Terminal", OpenerKind::Terminal), + ("warp", "dev.warp.Warp-Stable", OpenerKind::Terminal), + ("iterm", "com.googlecode.iterm2", OpenerKind::Terminal), + ("hyper", "co.zeit.hyper", OpenerKind::Terminal), + ("kitty", "net.kovidgoyal.kitty", OpenerKind::Terminal), + ("alacritty", "org.alacritty", OpenerKind::Terminal), // Editors - ("vscode", "com.microsoft.VSCode"), - ("vscode-insiders", "com.microsoft.VSCodeInsiders"), - ("cursor", "com.todesktop.230313mzl4w4u92"), - ("sublime", "com.sublimetext.4"), - ("atom", "com.github.atom"), - ("textmate", "com.macromates.TextMate"), - ("nova", "com.panic.Nova"), - ("bbedit", "com.barebones.bbedit"), - ("intellij", "com.jetbrains.intellij"), - ("webstorm", "com.jetbrains.WebStorm"), - ("pycharm", "com.jetbrains.pycharm"), - ("rubymine", "com.jetbrains.rubymine"), - ("goland", "com.jetbrains.goland"), - ("fleet", "fleet.app"), - ("zed", "dev.zed.Zed"), + ("vscode", "com.microsoft.VSCode", OpenerKind::Editor), + ( + "vscode-insiders", + "com.microsoft.VSCodeInsiders", + OpenerKind::Editor, + ), + ( + "cursor", + "com.todesktop.230313mzl4w4u92", + OpenerKind::Editor, + ), + ("sublime", "com.sublimetext.4", OpenerKind::Editor), + ("atom", "com.github.atom", OpenerKind::Editor), + ("textmate", "com.macromates.TextMate", OpenerKind::Editor), + ("nova", "com.panic.Nova", OpenerKind::Editor), + ("bbedit", "com.barebones.bbedit", OpenerKind::Editor), + ("intellij", "com.jetbrains.intellij", OpenerKind::Editor), + ("webstorm", "com.jetbrains.WebStorm", OpenerKind::Editor), + ("pycharm", "com.jetbrains.pycharm", OpenerKind::Editor), + ("rubymine", "com.jetbrains.rubymine", OpenerKind::Editor), + ("goland", "com.jetbrains.goland", OpenerKind::Editor), + ("fleet", "fleet.app", OpenerKind::Editor), + ("zed", "dev.zed.Zed", OpenerKind::Editor), // File browsers - ("finder", "com.apple.finder"), + ("finder", "com.apple.finder", OpenerKind::FileBrowser), ]; /// Open a URL in the user's default browser. @@ -115,7 +133,7 @@ pub async fn get_available_openers() -> Result, String> { let mut available = Vec::new(); - for (id, bundle_id) in KNOWN_OPENERS { + for (id, bundle_id, kind) in KNOWN_OPENERS { let output = Command::new("mdfind") .arg(format!("kMDItemCFBundleIdentifier == '{bundle_id}'")) .output() @@ -127,6 +145,7 @@ pub async fn get_available_openers() -> Result, String> { available.push(OpenerApp { id: id.to_string(), name: prettify_app_name(id), + kind: kind.clone(), }); } } @@ -173,13 +192,30 @@ fn prettify_app_name(id: &str) -> String { .to_string() } -/// Open a directory in a specific application. +/// Mapping from app ID to CLI command name for editors that have dedicated +/// CLI tools. These CLIs handle window reuse, IPC, and workspace resolution +/// properly — unlike `open -b` which just launches the Electron binary. +#[cfg(target_os = "macos")] +const CLI_OPENERS: &[(&str, &str)] = &[ + ("vscode", "code"), + ("vscode-insiders", "code-insiders"), + ("cursor", "cursor"), + ("zed", "zed"), +]; + +/// Open a file or directory in a specific application. /// -/// On macOS, uses the `open -b` command with the app's bundle ID. +/// For editors with dedicated CLI tools (VS Code, Cursor, Zed), uses the CLI +/// to open with proper project context. Falls back to `open -b ` +/// when the CLI is not available or for other apps. /// On other platforms, returns an error. -#[tauri::command] +#[tauri::command(rename_all = "camelCase")] #[allow(unused_variables)] -pub async fn open_in_app(path: String, app_id: String) -> Result<(), String> { +pub async fn open_in_app( + path: String, + app_id: String, + project_path: Option, +) -> Result<(), String> { #[cfg(target_os = "macos")] { use std::process::Command; @@ -187,10 +223,42 @@ pub async fn open_in_app(path: String, app_id: String) -> Result<(), String> { // Find the bundle ID for this app let bundle_id = KNOWN_OPENERS .iter() - .find(|(id, _)| *id == app_id) - .map(|(_, bundle)| *bundle) + .find(|(id, _, _)| *id == app_id) + .map(|(_, bundle, _)| *bundle) .ok_or_else(|| format!("Unknown app ID: {app_id}"))?; + // Try CLI tool first for supported editors + if let Some(cli_cmd) = CLI_OPENERS + .iter() + .find(|(id, _)| *id == app_id) + .map(|(_, cmd)| *cmd) + { + // Check if the CLI is available + let has_cli = Command::new("which") + .arg(cli_cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if has_cli { + let mut cmd = Command::new(cli_cmd); + if let Some(ref project) = project_path { + cmd.arg(project); + } + cmd.arg(&path); + + let status = cmd + .status() + .map_err(|e| format!("Failed to run {cli_cmd}: {e}"))?; + + if status.success() { + return Ok(()); + } + // CLI failed — fall through to open -b + } + } + + // Fallback: open via bundle ID (no --args) let status = Command::new("open") .arg("-b") .arg(bundle_id) diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index e3e1a0a8..4a958ca7 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -1189,6 +1189,7 @@ commits={timeline?.commits} baseBranchLabel={formatBaseBranch(branch.baseBranch)} branchLabel={branch.branchName} + worktreePath={branch.worktreePath} {projectName} githubRepo={repoLabel?.headRepo ?? repoLabel?.githubRepo} subpath={repoLabel?.subpath} @@ -1211,6 +1212,7 @@ commits={timeline?.commits} baseBranchLabel={formatBaseBranch(branch.baseBranch)} branchLabel={branch.branchName} + worktreePath={branch.worktreePath} {projectName} githubRepo={repoLabel?.headRepo ?? repoLabel?.githubRepo} subpath={repoLabel?.subpath} diff --git a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte index fd84d385..e1fab89b 100644 --- a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte @@ -429,7 +429,7 @@ showMoreMenu = false; showOpenInSubmenu = false; if (branch.worktreePath) { - await openInApp(branch.worktreePath, appId); + await openInApp(branch.worktreePath, appId, branch.worktreePath); } } diff --git a/apps/staged/src/lib/features/branches/branch.ts b/apps/staged/src/lib/features/branches/branch.ts index a82680ca..abc70538 100644 --- a/apps/staged/src/lib/features/branches/branch.ts +++ b/apps/staged/src/lib/features/branches/branch.ts @@ -1,10 +1,14 @@ import { invoke } from '@tauri-apps/api/core'; import { writeText } from '@tauri-apps/plugin-clipboard-manager'; -/** An application that can open a directory */ +/** The kind of opener application. */ +export type OpenerKind = 'editor' | 'terminal' | 'file-browser'; + +/** An application that can open a directory or file. */ export interface OpenerApp { id: string; name: string; + kind: OpenerKind; } // Cache for performance @@ -21,10 +25,13 @@ export async function getAvailableOpeners(): Promise { } /** - * Open a directory in a specific application. + * Open a file or directory in a specific application. + * + * When `projectPath` is provided, editors that support it (VS Code, Cursor, + * Zed) will open the file within the project workspace context. */ -export async function openInApp(path: string, appId: string): Promise { - return invoke('open_in_app', { path, appId }); +export async function openInApp(path: string, appId: string, projectPath?: string): Promise { + return invoke('open_in_app', { path, appId, projectPath: projectPath ?? null }); } /** diff --git a/apps/staged/src/lib/features/diff/DiffModal.svelte b/apps/staged/src/lib/features/diff/DiffModal.svelte index 0e3fc09a..2913dc8c 100644 --- a/apps/staged/src/lib/features/diff/DiffModal.svelte +++ b/apps/staged/src/lib/features/diff/DiffModal.svelte @@ -24,6 +24,7 @@ import DiffCommitSessionLauncher from './DiffCommitSessionLauncher.svelte'; import DiffReferenceSection from './DiffReferenceSection.svelte'; import ConfirmDialog from '../../shared/ConfirmDialog.svelte'; + import OpenInDropdown from './OpenInDropdown.svelte'; import { createDiffViewerState } from './diffViewerState.svelte'; import { createReviewState } from './reviewState.svelte'; import { createSearchState } from '@builderbot/diff-viewer/state'; @@ -71,6 +72,8 @@ baseBranchLabel?: string; /** Branch name for label display when switching to branch scope. */ branchLabel?: string; + /** Worktree path for "Open In" file actions. */ + worktreePath?: string | null; /** Project name to display in title bar. */ projectName?: string; /** GitHub repo (e.g., "owner/repo") to display in title bar. */ @@ -92,6 +95,7 @@ commits, baseBranchLabel, branchLabel, + worktreePath = null, projectName, githubRepo, subpath, @@ -769,6 +773,11 @@