diff --git a/docs/development.md b/docs/development.md index 6b74d76..4f12e67 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 ` 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 diff --git a/scripts/install-cli-command.sh b/scripts/install-cli-command.sh index 9800a29..403d1b9 100755 --- a/scripts/install-cli-command.sh +++ b/scripts/install-cli-command.sh @@ -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 " >&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 diff --git a/scripts/validate-launch-runners.mjs b/scripts/validate-launch-runners.mjs index 24e863a..e826dd7 100644 --- a/scripts/validate-launch-runners.mjs +++ b/scripts/validate-launch-runners.mjs @@ -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'); @@ -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], { diff --git a/src-tauri/src/http_server.rs b/src-tauri/src/http_server.rs index 56ec208..55815a4 100644 --- a/src-tauri/src/http_server.rs +++ b/src-tauri/src/http_server.rs @@ -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}; @@ -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)] @@ -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 { @@ -252,6 +259,7 @@ pub async fn start_http_server(config: HttpServerConfig) -> Result Result, Query(query): Query| { + let app = browse_app.clone(); + async move { browse(app, state, query).await } + }, + ), + ) .route("/mcp", post(codex_mcp).options(cors_preflight)) .route("/", get(index)) .route("/{*path}", get(static_file).options(cors_preflight)) @@ -1401,6 +1418,60 @@ async fn workspaces(State(state): State) -> Json` — the landing point for `ide browse`. Ensures a (possibly +/// hidden) session exists for `path`, then redirects into its scoped SPA, so a plain +/// `open ` 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. +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() +} + +/// 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, Extension(resolved): Extension, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c686c00..215ad52 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1580,6 +1580,7 @@ fn resolve_frontend_dist(resource_dir: Option, 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") @@ -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()); } } @@ -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(); @@ -2864,9 +2873,9 @@ fn last_segment(path: &Path) -> Option { } #[derive(Debug, Clone, PartialEq, Eq)] -struct LaunchTarget { - workspace_root: PathBuf, - initial_file: Option, +pub(crate) struct LaunchTarget { + pub(crate) workspace_root: PathBuf, + pub(crate) initial_file: Option, } fn resolve_explicit_launch_target() -> Result, std::io::Error> { @@ -2874,17 +2883,32 @@ fn resolve_explicit_launch_target() -> Result, std::io::Err 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 ` (`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 { let current_dir = std::env::current_dir()?; let workspace_root = project_root_for_process_dir(¤t_dir); @@ -2933,7 +2957,7 @@ fn launch_target_for_saved_workspace(path: &str) -> Option { }) } -fn launch_target_for_path(path: PathBuf) -> Result { +pub(crate) fn launch_target_for_path(path: PathBuf) -> Result { let canonical = path.canonicalize()?; if canonical.is_dir() { return Ok(LaunchTarget { @@ -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(()); } @@ -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, @@ -3073,7 +3106,9 @@ fn open_launch_target_window( return Err(error); } }; - focus_window(&window); + if visible { + focus_window(&window); + } Ok(()) } @@ -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()); } } diff --git a/src/App.test.tsx b/src/App.test.tsx index 5a4028e..b57c222 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -4764,6 +4764,59 @@ describe("Git commit sidebar", () => { expect(within(panel).getByPlaceholderText("Commit message")).toHaveValue(""); }); + it("remembers a partial selection across leaving and re-entering commit mode", async () => { + render(); + + expect(await treeButton("README.md")).toBeInTheDocument(); + fireEvent.click(screen.getByTitle("Source control")); + + let panel = await screen.findByLabelText("Git commit panel"); + await waitFor(() => expect(within(panel).getByText("2 / 2")).toBeInTheDocument()); + + fireEvent.click(within(panel).getByLabelText("Deselect src/App.tsx")); + await waitFor(() => expect(within(panel).getByText("1 / 2")).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Files" })); + await waitFor(() => + expect(screen.queryByLabelText("Git commit panel")).not.toBeInTheDocument(), + ); + + // Re-entering re-fetches status; the file set is unchanged (persistent + // default mock), so the partial selection should survive as-is rather + // than reset to "all". + fireEvent.click(screen.getByTitle("Source control")); + panel = await screen.findByLabelText("Git commit panel"); + await waitFor(() => expect(within(panel).getByText("1 / 2")).toBeInTheDocument()); + expect( + (within(panel).getByLabelText("Select src/App.tsx") as HTMLInputElement).checked, + ).toBe(false); + }); + + it("collapses to select-all on re-entry when the remembered selection is empty", async () => { + render(); + + expect(await treeButton("README.md")).toBeInTheDocument(); + fireEvent.click(screen.getByTitle("Source control")); + + let panel = await screen.findByLabelText("Git commit panel"); + await waitFor(() => expect(within(panel).getByText("2 / 2")).toBeInTheDocument()); + + fireEvent.click(within(panel).getByLabelText("Deselect all changes")); + await waitFor(() => expect(within(panel).getByText("0 / 2")).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Files" })); + await waitFor(() => + expect(screen.queryByLabelText("Git commit panel")).not.toBeInTheDocument(), + ); + + // A remembered "nothing selected" is indistinguishable from a fresh + // first-ever entry, so re-entry falls back to selecting everything + // rather than reopening the panel with nothing checked. + fireEvent.click(screen.getByTitle("Source control")); + panel = await screen.findByLabelText("Git commit panel"); + await waitFor(() => expect(within(panel).getByText("2 / 2")).toBeInTheDocument()); + }); + it("commits when Cmd/Ctrl+Enter is pressed in the message textarea", async () => { render(); @@ -4904,6 +4957,39 @@ describe("Git commit sidebar", () => { expect(tabButton("README.md (Working Tree)")).toBeTruthy(); }); + it("closes diff tabs after a successful commit, even pinned ones", async () => { + render(); + + expect(await treeButton("README.md")).toBeInTheDocument(); + fireEvent.click(screen.getByTitle("Source control")); + + const panel = await screen.findByLabelText("Git commit panel"); + const readmeRow = await within(panel).findByText("README.md"); + fireEvent.click(readmeRow.closest("button")!); + await screen.findByLabelText("Diff README.md"); + // Pin it — a pin survives leaving commit mode, but a post-commit diff is + // stale regardless of pin state, so it must still close here. + await waitFor(() => expect(tabButton("README.md (Working Tree)")).toBeTruthy()); + fireEvent.doubleClick(tabButton("README.md (Working Tree)")!); + + fireEvent.change(within(panel).getByPlaceholderText("Commit message"), { + target: { value: "Update readme" }, + }); + tauriMocks.getGitStatus.mockResolvedValueOnce({ + status: "available", + branch: "main", + headDetached: false, + headUnborn: false, + files: [{ path: "src/App.tsx", status: "modified", staged: true, unstaged: false }], + }); + fireEvent.click(within(panel).getByRole("button", { name: /^Commit/ })); + + await waitFor(() => + expect(within(panel).getByText(/Committed \d+ file\(s\) as abc1234/)).toBeInTheDocument(), + ); + expect(tabButton("README.md (Working Tree)")).toBeUndefined(); + }); + it("shows Git status badges and a folder dot in the main tree outside commit mode", async () => { render(); diff --git a/src/App.tsx b/src/App.tsx index 1ea6d31..8f15701 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1441,7 +1441,18 @@ export default function App() { const validPaths = new Set(status.files.map((file) => file.path)); if (!gitStatusInitializedRef.current) { gitStatusInitializedRef.current = true; - return validPaths; + // Re-entering commit mode: remember the prior selection instead of + // always selecting everything. Prune to paths still valid, then + // collapse to "select all" only when nothing survived (first-ever + // entry, or the user had deselected everything) or everything did + // (prior selection was already "all") — a genuine partial selection + // is preserved exactly. + const pruned = new Set(); + for (const path of current) { + if (validPaths.has(path)) pruned.add(path); + } + if (pruned.size === 0 || pruned.size === validPaths.size) return validPaths; + return pruned; } const next = new Set(); for (const path of current) { @@ -1778,6 +1789,19 @@ export default function App() { const result = await commitGitChanges(trimmedMessage, selectedPaths); setGitCommitMessage(""); setGitCommitSuccess(`Committed ${result.committedPaths.length} file(s) as ${result.shortSha}`); + // Every open diff tab (pinned or not) reflects the pre-commit working + // tree, so it's stale the instant the commit lands — unlike the + // leave-commit-mode cleanup above, a pin doesn't save it here. + const openBeforeCleanup = openFilesRef.current; + const kept = openBeforeCleanup.filter((file) => !file.diff); + if (kept.length !== openBeforeCleanup.length) { + setOpenFiles(kept); + setActivePath((currentActivePath) => + currentActivePath && kept.some((file) => file.path === currentActivePath) + ? currentActivePath + : kept.at(-1)?.path, + ); + } await refreshGitStatus(); } catch (reason) { setGitCommitError(`Unable to commit: ${String(reason)}`);