Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ The build script runs `npm run tauri -- build --bundles app`, then installs comm

The installer writes `ide` as a small macOS `open` launcher for the packaged app bundle and links `ide-dev` to `run.sh`. Use `ide .` when the packaged app should open or focus a workspace window without holding the terminal; use bare `ide` to focus a running app or open the last context; and use `ide-dev .` when the repository dev runner should manage Node dependencies, Vite, stale dev ports, and running-app handoff behavior. Set `IDE_CLI_APP_BUNDLE_PATH=/path/to/ide.app` when installing if the command should target a different packaged bundle.

`ide browse <path>` opens a repo in the default browser instead of a native window. If no instance is running yet, it starts one in the background, HTTP server up but no window on screen, and gives the target repo its own hidden session. If an instance is already running, it reuses it the same way, so several repos can be browsed at once without any of them showing a window or stealing focus. Closing a visible ide window doesn't stop a background browse session; only quitting the app (⌘Q) does.

Install the macOS Finder Quick Action:

```bash
Expand Down
55 changes: 55 additions & 0 deletions scripts/install-cli-command.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,66 @@ activate_running_app() {
open -b "com.gordonbeeming.ide" >/dev/null 2>&1 || true
}

# Percent-encodes a path for the /browse?path= query string (spaces, etc). Pure bash
# so browse doesn't need curl's --data-urlencode just to build a URL.
url_encode() {
# LC_ALL=C forces byte-wise iteration regardless of the caller's locale, and the
# printf-based char-to-int conversion sign-extends bytes >= 0x80 (bash 3.2's builtin
# printf, still the default /bin/bash on macOS) — masking with & 0xFF undoes that so
# multi-byte UTF-8 paths percent-encode correctly.
local LC_ALL=C
local string="\$1"
local length="\${#string}" i char
for (( i = 0; i < length; i++ )); do
char="\${string:i:1}"
case "\$char" in
[a-zA-Z0-9.~_-]) printf '%s' "\$char" ;;
*) printf '%%%02X' "\$(( \$(printf '%d' "'\$char") & 0xFF ))" ;;
esac
done
}

resolve_absolute_path() {
local path="\$1"
if [ -d "\$path" ]; then
(cd "\$path" && pwd -P)
else
printf '%s/%s' "\$(cd "\$(dirname "\$path")" && pwd -P)" "\$(basename "\$path")"
fi
}

if [ "\$#" -eq 0 ] && running_app_reachable; then
activate_running_app
exit 0
fi

if [ "\${1:-}" = "browse" ]; then
if [ "\$#" -lt 2 ] || [ ! -e "\$2" ]; then
echo "Usage: ide browse <path>" >&2
exit 1
fi
browse_path="\$(resolve_absolute_path "\$2")"
browse_url="http://127.0.0.1:17877/browse?path=\$(url_encode "\$browse_path")"

if ! running_app_reachable; then
# Cold start: boot the bundle headless (no visible window — see lib.rs's
# IDE_BROWSE_PATH handling) and wait for the HTTP server before hitting /browse.
IDE_BROWSE_PATH="\$browse_path" open "\$APP_BUNDLE" --args browse "\$browse_path" >/dev/null 2>&1
attempts=0
until running_app_reachable; do
attempts=\$((attempts + 1))
if [ "\$attempts" -ge 50 ]; then
echo "ide browse: timed out waiting for the app to start" >&2
exit 1
fi
sleep 0.2
done
fi

open "\$browse_url" >/dev/null 2>&1 &
exit 0
fi

ARGS=()
for arg in "\$@"; do
if [ -e "\$arg" ]; then
Expand Down
12 changes: 11 additions & 1 deletion scripts/validate-launch-runners.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ try {
assertIncludes(ideCommand, "activate_running_app()");
assertIncludes(ideCommand, 'tell application id "com.gordonbeeming.ide" to activate');
assertIncludes(ideCommand, 'if [ "$#" -eq 0 ] && running_app_reachable; then');
assertIncludes(ideCommand, 'url_encode()');
assertIncludes(ideCommand, 'resolve_absolute_path()');
assertIncludes(ideCommand, 'if [ "${1:-}" = "browse" ]; then');
assertIncludes(ideCommand, 'browse_url="http://127.0.0.1:17877/browse?path=$(url_encode "$browse_path")"');
assertIncludes(ideCommand, 'IDE_BROWSE_PATH="$browse_path" open "$APP_BUNDLE" --args browse "$browse_path"');
assertIncludes(ideCommand, 'until running_app_reachable; do');
assertIncludes(ideCommand, 'open "$browse_url" >/dev/null 2>&1 &');
assertOrdered(ideCommand, 'if [ "${1:-}" = "browse" ]; then', 'ARGS=()');
assertIncludes(ideCommand, 'ARGS=()');
assertIncludes(ideCommand, 'ARGS+=("$(cd "$arg" && pwd -P)")');
assertIncludes(ideCommand, 'if [ "${#ARGS[@]}" -gt 0 ]; then');
Expand All @@ -114,7 +122,9 @@ try {
assertIncludes(ideCommand, 'open "$APP_BUNDLE" --args "${ARGS[@]}" >/dev/null 2>&1 &');
assertIncludes(ideCommand, 'open "$APP_BUNDLE" >/dev/null 2>&1 &');
assertNotIncludes(ideCommand, "open -n");
assertOrdered(ideCommand, '"$APP_BINARY" "${ARGS[@]}"', 'open "$APP_BUNDLE" --args');
// Fully qualified so this doesn't collide with the earlier browse-branch
// `open "$APP_BUNDLE" --args browse ...` line, which shares the same prefix.
assertOrdered(ideCommand, '"$APP_BINARY" "${ARGS[@]}"', 'open "$APP_BUNDLE" --args "${ARGS[@]}"');
assertSymlinkTarget(path.join(cliBinDir, "ide-dev"), runScriptPath, "ide-dev command");

execFileSync("bash", [cliInstallScript], {
Expand Down
75 changes: 73 additions & 2 deletions src-tauri/src/http_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::{fmt, io};
use axum::extract::{Query, State};
use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode, Uri};
use axum::middleware::{self, Next};
use axum::response::{Html, IntoResponse, Response};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum::{Extension, Json, Router};
use serde::{Deserialize, Serialize};
Expand All @@ -27,7 +27,8 @@ use crate::workspace::{
};
use crate::workspace_index::{advance_workspace_index, WorkspaceIndex, WorkspaceIndexAdvanceError};
use crate::{
git_attribution, git_commit, git_sync, workspace_root_hash, AgentContext, WorkspaceSessionState,
git_attribution, git_commit, git_sync, launch_target_for_path, open_launch_target_window,
workspace_root_hash, AgentContext, LaunchTarget, WorkspaceSessionState,
};

#[derive(Clone)]
Expand Down Expand Up @@ -189,6 +190,12 @@ struct OpenPathRequest {
path: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BrowseQuery {
path: String,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct OpenWorkspaceEvent {
Expand Down Expand Up @@ -252,6 +259,7 @@ pub async fn start_http_server(config: HttpServerConfig) -> Result<HttpServerInf
let resolve_state = state.clone();
let open_path_app = app_handle.clone();
let open_path_token = mcp_token.clone();
let browse_app = app_handle.clone();
let app = Router::new()
.route("/api/workspace-root", get(workspace_root))
.route("/api/workspace-display", get(workspace_display))
Expand Down Expand Up @@ -320,6 +328,15 @@ pub async fn start_http_server(config: HttpServerConfig) -> Result<HttpServerInf
.route("/api/lsp", get(lsp_servers))
.route("/api/codex-mcp", get(codex_mcp_status))
.route("/api/workspaces", get(workspaces))
.route(
"/browse",
get(
move |State(state): State<HttpServerState>, Query(query): Query<BrowseQuery>| {
let app = browse_app.clone();
async move { browse(app, state, query).await }
},
),
)
Comment thread
GordonBeeming marked this conversation as resolved.
.route("/mcp", post(codex_mcp).options(cors_preflight))
.route("/", get(index))
.route("/{*path}", get(static_file).options(cors_preflight))
Expand Down Expand Up @@ -1401,6 +1418,60 @@ async fn workspaces(State(state): State<HttpServerState>) -> Json<Vec<WorkspaceS
Json(workspace_summaries(&state.window_sessions).await)
}

/// `GET /browse?path=<abs>` — the landing point for `ide browse`. Ensures a (possibly
/// hidden) session exists for `path`, then redirects into its scoped SPA, so a plain
/// `open <url>` from bash is enough to land a browser tab on the right workspace
/// without the launcher ever needing to reproduce `workspace_root_hash`. On a cold
/// start `path` is usually already open as the hidden `main` session (see
/// `is_browse_launch` in lib.rs), so this only creates a new window for a *second*
/// browsed repo.
///
/// ponytail: one hidden webview per browsed repo — a fully windowless HTTP-only
/// session is a follow-up if browse ever needs to scale past a handful open at once.
Comment thread
GordonBeeming marked this conversation as resolved.
async fn browse(app: tauri::AppHandle, state: HttpServerState, query: BrowseQuery) -> Response {
let path = PathBuf::from(query.path);
if !path.is_absolute() {
return ApiError::bad_request("path must be absolute".to_string()).into_response();
}
let target = match launch_target_for_path(path) {
Ok(target) => target,
Err(error) => return ApiError::bad_request(error.to_string()).into_response(),
};
let hash = workspace_root_hash(&target.workspace_root);

let already_open = resolve_workspace_by_hash(&state.window_sessions, &hash)
.await
.is_some();
if !already_open {
if let Err(error) = open_hidden_workspace_window(app, target).await {
return ApiError::internal(format!("failed to open background session: {error}"))
.into_response();
}
}

Redirect::to(&format!("/{hash}/")).into_response()
}
Comment thread
GordonBeeming marked this conversation as resolved.
Comment thread
GordonBeeming marked this conversation as resolved.

/// Builds the hidden background window for a browse session on the main thread —
/// `WebviewWindowBuilder::build` requires it, and this handler runs on a tokio worker
/// thread, unlike `open_launch_target_window`'s other two callers (the single-instance
/// callback and the menu-event handler), which Tauri already runs on the main thread.
async fn open_hidden_workspace_window(
app: tauri::AppHandle,
target: LaunchTarget,
) -> Result<(), String> {
let (tx, rx) = tokio::sync::oneshot::channel();
let main_thread_app = app.clone();
app.run_on_main_thread(move || {
let result = open_launch_target_window(&main_thread_app, target, false)
.map_err(|error| error.to_string());
let _ = tx.send(result);
})
.map_err(|error| error.to_string())?;
rx.await
.map_err(|_| "main-thread task ended without a result".to_string())?
}

async fn index(
State(state): State<HttpServerState>,
Extension(resolved): Extension<ResolvedWorkspace>,
Expand Down
63 changes: 49 additions & 14 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,7 @@ fn resolve_frontend_dist(resource_dir: Option<PathBuf>, manifest_dist: PathBuf)
pub fn run() {
let explicit_launch_target =
resolve_explicit_launch_target().expect("failed to determine requested launch target");
let browse_launch_requested = is_browse_launch();
let initial_launch_target = explicit_launch_target.clone().unwrap_or_else(|| {
fallback_launch_target_from_process_dir()
.expect("failed to determine current workspace directory")
Expand Down Expand Up @@ -1631,7 +1632,7 @@ pub fn run() {
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
match explicit_launch_target_from_args(&args) {
Ok(Some(target)) => {
if let Err(error) = open_launch_target_window(app, target) {
if let Err(error) = open_launch_target_window(app, target, true) {
let _ = app.emit("app://error", error.to_string());
}
}
Expand Down Expand Up @@ -1749,6 +1750,14 @@ pub fn run() {
rebuild_app_menu(app.handle(), &http_state)
.map_err(|error| std::io::Error::other(error.to_string()))?;

if browse_launch_requested {
// main's session is already rooted at the browsed repo (see
// is_browse_launch's doc comment) — just keep it off-screen.
if let Some(main_window) = app.get_webview_window("main") {
let _ = main_window.hide();
}
}

let workspace_root = http_state.workspace_root.clone();
let tree_scan_limit = http_state.tree_scan_limit.clone();
let max_open_file_bytes = http_state.max_open_file_bytes.clone();
Expand Down Expand Up @@ -2864,27 +2873,42 @@ fn last_segment(path: &Path) -> Option<String> {
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct LaunchTarget {
workspace_root: PathBuf,
initial_file: Option<String>,
pub(crate) struct LaunchTarget {
pub(crate) workspace_root: PathBuf,
pub(crate) initial_file: Option<String>,
}

fn resolve_explicit_launch_target() -> Result<Option<LaunchTarget>, std::io::Error> {
if let Some(path) = std::env::var_os("IDE_OPEN_PATH").filter(|value| !value.is_empty()) {
return launch_target_for_path(PathBuf::from(path)).map(Some);
}

if let Some(path) = std::env::args_os()
.skip(1)
.map(PathBuf::from)
.find(|path| path.exists())
{
let mut args = std::env::args_os().skip(1).peekable();
// A leading "browse" token is the subcommand marker, not a path — skip it so a
// `./browse` directory in cwd can't be mistaken for the target (see is_browse_launch).
if args.peek().map(|arg| arg.as_os_str()) == Some(std::ffi::OsStr::new("browse")) {
args.next();
}
if let Some(path) = args.map(PathBuf::from).find(|path| path.exists()) {
return launch_target_for_path(path).map(Some);
}

Ok(None)
}

/// Whether this cold start is `ide browse <path>` (`IDE_BROWSE_PATH` set, or a
/// leading `browse` argv token) — `run()` uses this to keep the auto-created `main`
/// window hidden. The browsed path itself doesn't need extracting here:
/// `resolve_explicit_launch_target`'s existing `IDE_OPEN_PATH`/argv scan already
/// skips that same leading `browse` token and finds the path after it, rooting
/// `main`'s session there — so hiding `main` is the only extra work a browse cold
/// start needs. `http_server.rs`'s `/browse` route handles session creation for the
/// warm (already-running) case.
fn is_browse_launch() -> bool {
std::env::var_os("IDE_BROWSE_PATH").is_some_and(|value| !value.is_empty())
|| std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("browse"))
}

fn fallback_launch_target_from_process_dir() -> Result<LaunchTarget, std::io::Error> {
let current_dir = std::env::current_dir()?;
let workspace_root = project_root_for_process_dir(&current_dir);
Expand Down Expand Up @@ -2933,7 +2957,7 @@ fn launch_target_for_saved_workspace(path: &str) -> Option<LaunchTarget> {
})
}

fn launch_target_for_path(path: PathBuf) -> Result<LaunchTarget, std::io::Error> {
pub(crate) fn launch_target_for_path(path: PathBuf) -> Result<LaunchTarget, std::io::Error> {
let canonical = path.canonicalize()?;
if canonical.is_dir() {
return Ok(LaunchTarget {
Expand Down Expand Up @@ -3045,13 +3069,21 @@ pub(crate) fn workspace_root_hash(path: &Path) -> String {
format!("{:x}", hasher.finish())
}

fn open_launch_target_window(
/// `visible = false` is how `ide browse` gets a session without a screen presence:
/// same label/session-registration/dedupe path as a normal window open, just built
/// hidden and left unfocused. An existing window is only touched (focused) when the
/// caller wants visibility — re-resolving an already-hidden browse session with
/// `visible = false` is a no-op, so repeat browses don't steal focus or flash a window.
pub(crate) fn open_launch_target_window(
app: &tauri::AppHandle,
target: LaunchTarget,
visible: bool,
) -> Result<(), tauri::Error> {
let label = launch_target_window_label(&target);
if let Some(window) = app.get_webview_window(&label) {
focus_window(&window);
if visible {
focus_window(&window);
}
return Ok(());
}

Expand All @@ -3065,6 +3097,7 @@ fn open_launch_target_window(
.title("ide")
.inner_size(1440.0, 960.0)
.min_inner_size(960.0, 640.0)
.visible(visible)
.build()
{
Ok(window) => window,
Expand All @@ -3073,7 +3106,9 @@ fn open_launch_target_window(
return Err(error);
}
};
focus_window(&window);
if visible {
focus_window(&window);
}
Ok(())
}

Expand All @@ -3092,7 +3127,7 @@ fn open_launch_request_window(app: &tauri::AppHandle, request: OpenLaunchRequest
initial_file: Some(path),
},
};
if let Err(error) = open_launch_target_window(app, target) {
if let Err(error) = open_launch_target_window(app, target, true) {
let _ = app.emit("app://error", error.to_string());
}
}
Expand Down
Loading
Loading