From f59a568c97faab4bf3fd4f3f3cc91bd844781b1f Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Sun, 12 Jul 2026 19:51:01 +1000 Subject: [PATCH 1/5] Remember commit-file selection and clear stale diff tabs on commit Entering the commit view still selects every changed file, but leaving and coming back no longer wipes a manual selection. Re-entry only re-selects all when the remembered selection was empty or already the full set; a partial selection (say 14 of 15 after unchecking one) is preserved as-is. The all-or- none test is derived from the current set, so uncheck-then-recheck counts as 'all' again. After a successful commit, every open working-tree diff tab now closes. They all reflect the pre-commit tree, so they go stale the instant the commit lands and would otherwise linger showing 'N unchanged lines' with no content. --- src/App.test.tsx | 86 ++++++++++++++++++++++++++++++++++++++++++++++++ src/App.tsx | 26 ++++++++++++++- 2 files changed, 111 insertions(+), 1 deletion(-) 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..c2ceb8a 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. + setOpenFiles((current) => { + const kept = current.filter((file) => !file.diff); + if (kept.length === current.length) return current; + setActivePath((currentActivePath) => + currentActivePath && kept.some((file) => file.path === currentActivePath) + ? currentActivePath + : kept.at(-1)?.path, + ); + return kept; + }); await refreshGitStatus(); } catch (reason) { setGitCommitError(`Unable to commit: ${String(reason)}`); From 293da41a6a0cf25b8506f1d6b2412fcab4f33d72 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Sun, 12 Jul 2026 20:55:39 +1000 Subject: [PATCH 2/5] Add ide browse to open repos headless in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ide browse opens a repo's web UI in the default browser instead of a native window. If nothing is running yet it cold-starts the app headless (HTTP server up, main window kept hidden) and gives the repo its own hidden session; if an instance is already running it reuses it. Several repos can be browsed at once, each on its own /{hash}/ URL, and closing a visible window leaves the background sessions serving until the app is quit. The launcher gains a browse subcommand that url-encodes the path and hands it to a new loopback GET /browse?path= route, which ensures a (hidden) session exists and 302-redirects to the repo's scoped SPA — so bash never has to reproduce the Rust workspace hash. Window creation from the HTTP handler bridges to the main thread via run_on_main_thread. open_launch_target_window gains a visible flag so browse sessions build hidden and never steal focus. --- docs/development.md | 2 + scripts/install-cli-command.sh | 50 ++++++++++++++++++++ scripts/validate-launch-runners.mjs | 12 ++++- src-tauri/src/http_server.rs | 71 ++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 51 +++++++++++++++++---- 5 files changed, 174 insertions(+), 12 deletions(-) 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..84eb225 100755 --- a/scripts/install-cli-command.sh +++ b/scripts/install-cli-command.sh @@ -83,11 +83,61 @@ 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() { + 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' "'\$char" ;; + 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..6589061 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,56 @@ 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 target = match launch_target_for_path(PathBuf::from(query.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..99912f4 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> { @@ -2885,6 +2894,19 @@ fn resolve_explicit_launch_target() -> Result, std::io::Err 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 +/// finds the same path (the token after `browse` is just the first argv entry that +/// exists on disk) and roots `main`'s session in it, 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 +2955,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 +3067,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 +3095,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 +3104,9 @@ fn open_launch_target_window( return Err(error); } }; - focus_window(&window); + if visible { + focus_window(&window); + } Ok(()) } @@ -3092,7 +3125,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()); } } From 9c03d3c7a78dbf6803fbc7e1f0e7ac905e342432 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Mon, 13 Jul 2026 00:05:36 +1000 Subject: [PATCH 3/5] Fix PR review feedback Address reviewer comments: - Defer setActivePath out of the setOpenFiles updater (queueMicrotask) so the post-commit diff-tab cleanup stays pure (gemini) - Force byte-wise url_encode iteration (LC_ALL=C) and mask the signed-byte printf conversion (& 0xFF) so ide browse percent-encodes non-ASCII paths correctly under bash 3.2 (copilot) --- scripts/install-cli-command.sh | 7 ++++++- src/App.tsx | 13 ++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/scripts/install-cli-command.sh b/scripts/install-cli-command.sh index 84eb225..403d1b9 100755 --- a/scripts/install-cli-command.sh +++ b/scripts/install-cli-command.sh @@ -86,13 +86,18 @@ activate_running_app() { # 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' "'\$char" ;; + *) printf '%%%02X' "\$(( \$(printf '%d' "'\$char") & 0xFF ))" ;; esac done } diff --git a/src/App.tsx b/src/App.tsx index c2ceb8a..2380bf0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1795,11 +1795,14 @@ export default function App() { setOpenFiles((current) => { const kept = current.filter((file) => !file.diff); if (kept.length === current.length) return current; - setActivePath((currentActivePath) => - currentActivePath && kept.some((file) => file.path === currentActivePath) - ? currentActivePath - : kept.at(-1)?.path, - ); + // Deferred so this updater stays pure — no nested setState calls during render. + queueMicrotask(() => { + setActivePath((currentActivePath) => + currentActivePath && kept.some((file) => file.path === currentActivePath) + ? currentActivePath + : kept.at(-1)?.path, + ); + }); return kept; }); await refreshGitStatus(); From 181ac08e1c7ed8834a15854e5980d5d52349a9a1 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Mon, 13 Jul 2026 08:00:43 +1000 Subject: [PATCH 4/5] Fix PR review feedback (round 2) Address reviewer comments: - Move both setOpenFiles/setActivePath calls out of the updater entirely (reading the pre-commit list from openFilesRef instead) so no state setter runs inside another's updater, even deferred (copilot) - Skip a leading 'browse' argv token in resolve_explicit_launch_target before scanning for the target path, so a ./browse directory in cwd can't be mistaken for it (copilot) --- src-tauri/src/lib.rs | 20 +++++++++++--------- src/App.tsx | 23 ++++++++++------------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 99912f4..215ad52 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2883,11 +2883,13 @@ 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); } @@ -2898,10 +2900,10 @@ fn resolve_explicit_launch_target() -> Result, std::io::Err /// 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 -/// finds the same path (the token after `browse` is just the first argv entry that -/// exists on disk) and roots `main`'s session in it, 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. +/// 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")) diff --git a/src/App.tsx b/src/App.tsx index 2380bf0..8f15701 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1792,19 +1792,16 @@ export default function App() { // 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. - setOpenFiles((current) => { - const kept = current.filter((file) => !file.diff); - if (kept.length === current.length) return current; - // Deferred so this updater stays pure — no nested setState calls during render. - queueMicrotask(() => { - setActivePath((currentActivePath) => - currentActivePath && kept.some((file) => file.path === currentActivePath) - ? currentActivePath - : kept.at(-1)?.path, - ); - }); - return kept; - }); + 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)}`); From a786f2ad8f53c10e2bd9e58432f51a5a948b7908 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Mon, 13 Jul 2026 08:09:56 +1000 Subject: [PATCH 5/5] Fix PR review feedback (round 3) Address reviewer comments: - Reject a non-absolute path in the /browse handler before resolving it, matching the documented contract (copilot) --- src-tauri/src/http_server.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/http_server.rs b/src-tauri/src/http_server.rs index 6589061..55815a4 100644 --- a/src-tauri/src/http_server.rs +++ b/src-tauri/src/http_server.rs @@ -1429,7 +1429,11 @@ async fn workspaces(State(state): State) -> Json