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
132 changes: 100 additions & 32 deletions apps/staged/src-tauri/src/util_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -115,7 +133,7 @@ pub async fn get_available_openers() -> Result<Vec<OpenerApp>, 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()
Expand All @@ -127,6 +145,7 @@ pub async fn get_available_openers() -> Result<Vec<OpenerApp>, String> {
available.push(OpenerApp {
id: id.to_string(),
name: prettify_app_name(id),
kind: kind.clone(),
});
}
}
Expand Down Expand Up @@ -173,24 +192,73 @@ 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 <bundle_id>`
/// 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<String>,
) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
use std::process::Command;

// 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)
Expand Down
2 changes: 2 additions & 0 deletions apps/staged/src/lib/features/branches/BranchCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@
showMoreMenu = false;
showOpenInSubmenu = false;
if (branch.worktreePath) {
await openInApp(branch.worktreePath, appId);
await openInApp(branch.worktreePath, appId, branch.worktreePath);
}
}

Expand Down
15 changes: 11 additions & 4 deletions apps/staged/src/lib/features/branches/branch.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,10 +25,13 @@ export async function getAvailableOpeners(): Promise<OpenerApp[]> {
}

/**
* 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<void> {
return invoke<void>('open_in_app', { path, appId });
export async function openInApp(path: string, appId: string, projectPath?: string): Promise<void> {
return invoke<void>('open_in_app', { path, appId, projectPath: projectPath ?? null });
}

/**
Expand Down
10 changes: 10 additions & 0 deletions apps/staged/src/lib/features/diff/DiffModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. */
Expand All @@ -92,6 +95,7 @@
commits,
baseBranchLabel,
branchLabel,
worktreePath = null,
projectName,
githubRepo,
subpath,
Expand Down Expand Up @@ -769,6 +773,11 @@
<div class="modal-body">
<!-- Diff viewer -->
<div class="diff-viewer-container">
{#snippet openInSnippet(afterPath: string | null)}
{#if worktreePath && afterPath}
<OpenInDropdown filePath="{worktreePath}/{afterPath}" projectPath={worktreePath} />
{/if}
{/snippet}
<DiffViewer
diff={currentDiff}
comments={readonly ? [] : currentComments}
Expand All @@ -780,6 +789,7 @@
annotations={revealedAnnotations}
{annotationsRevealed}
searchState={searchState.state}
afterHeaderExtra={worktreePath ? openInSnippet : undefined}
onAddComment={readonly ? undefined : handleAddComment}
onUpdateComment={readonly ? undefined : handleUpdateComment}
onDeleteComment={readonly ? undefined : handleDeleteCommentFromViewer}
Expand Down
Loading