From c639721d6fe0a5ec5b1d0019a59bab463caa87d1 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Thu, 23 Jul 2026 22:50:58 +0930 Subject: [PATCH 1/4] Fix file targets in ide browse --- src-tauri/src/http_server.rs | 64 +++++++++++++++++++++++++++++++++--- src/tauri.test.ts | 11 +++++++ src/tauri.ts | 3 +- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/http_server.rs b/src-tauri/src/http_server.rs index 6a7dc7b..d9b3fee 100644 --- a/src-tauri/src/http_server.rs +++ b/src-tauri/src/http_server.rs @@ -55,6 +55,7 @@ pub struct HttpServerState { #[derive(Clone)] struct ResolvedWorkspace { workspace_root: Arc>, + initial_file: Option>>>, agent_context: Arc>, /// True when the request carried a `/{hash}` prefix that matched an open /// workspace. The `/` handler uses this to serve the SPA for `/{hash}/` @@ -262,6 +263,7 @@ pub async fn start_http_server(config: HttpServerConfig) -> Result>>, + hash: &str, + initial_file: Option, +) -> bool { + let Some(resolved) = resolve_workspace_by_hash(sessions, hash).await else { + return false; + }; + let Some(session_initial_file) = resolved.initial_file else { + return false; + }; + *session_initial_file.write().await = initial_file; + true +} + fn rewrite_request_path(request: &mut Request, new_path: &str) { let path_and_query = match request.uri().query() { Some(query) => format!("{new_path}?{query}"), @@ -513,6 +531,7 @@ async fn resolve_workspace_middleware( ) -> Response { let default = ResolvedWorkspace { workspace_root: state.workspace_root.clone(), + initial_file: None, agent_context: state.agent_context.clone(), scoped: false, }; @@ -549,6 +568,13 @@ async fn workspace_root(Extension(resolved): Extension) -> Js ) } +async fn initial_file(Extension(resolved): Extension) -> Json> { + match resolved.initial_file { + Some(initial_file) => Json(initial_file.read().await.clone()), + None => Json(None), + } +} + async fn workspace_display( Extension(resolved): Extension, Query(query): Query, @@ -1466,10 +1492,8 @@ async fn browse(app: tauri::AppHandle, state: HttpServerState, query: BrowseQuer }; 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 !update_open_browse_session(&state.window_sessions, &hash, target.initial_file.clone()).await + { if let Err(error) = open_hidden_workspace_window(app, target).await { return ApiError::internal(format!("failed to open background session: {error}")) .into_response(); @@ -1851,6 +1875,7 @@ mod tests { fn default_resolved(state: &HttpServerState) -> Extension { Extension(ResolvedWorkspace { workspace_root: state.workspace_root.clone(), + initial_file: None, agent_context: state.agent_context.clone(), scoped: false, }) @@ -1875,6 +1900,20 @@ mod tests { } } + #[tokio::test] + async fn initial_file_returns_the_scoped_session_target() { + let resolved = ResolvedWorkspace { + workspace_root: Arc::new(RwLock::new(PathBuf::from("/tmp"))), + initial_file: Some(Arc::new(RwLock::new(Some("README.md".to_string())))), + agent_context: Arc::new(RwLock::new(AgentContext::default())), + scoped: true, + }; + + let Json(path) = initial_file(Extension(resolved)).await; + + assert_eq!(path, Some("README.md".to_string())); + } + #[test] fn hosted_indexed_file_search_discards_stale_expansion_directories() { let dir = tempdir().unwrap(); @@ -2794,6 +2833,23 @@ mod tests { .is_none()); } + #[tokio::test] + async fn update_open_browse_session_sets_and_clears_the_requested_file() { + let dir = tempdir().unwrap(); + let session = test_session(dir.path()); + let initial_file = session.initial_file.clone(); + let mut sessions = HashMap::new(); + sessions.insert("main".to_string(), session); + let sessions = Arc::new(std::sync::RwLock::new(sessions)); + let hash = workspace_root_hash(dir.path()); + + assert!(update_open_browse_session(&sessions, &hash, Some("README.md".to_string()),).await); + assert_eq!(*initial_file.read().await, Some("README.md".to_string())); + + assert!(update_open_browse_session(&sessions, &hash, None).await); + assert_eq!(*initial_file.read().await, None); + } + #[tokio::test] async fn workspace_summaries_dedupe_by_hash() { let dir = tempdir().unwrap(); diff --git a/src/tauri.test.ts b/src/tauri.test.ts index 4a51395..d33f911 100644 --- a/src/tauri.test.ts +++ b/src/tauri.test.ts @@ -240,6 +240,17 @@ describe("hosted Tauri API transport", () => { expect(fetchMock.mock.calls[1][0]).toBe("/api/agent-context"); }); + it("reads the initial file from the scoped hosted session", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse("README.md")); + vi.stubGlobal("fetch", fetchMock); + const { getInitialFile } = await import("./tauri"); + + await expect(getInitialFile()).resolves.toBe("README.md"); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][0]).toBe("/api/initial-file"); + }); + it("does not persist recent files from hosted browser mode", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); diff --git a/src/tauri.ts b/src/tauri.ts index 5589906..3dd78eb 100644 --- a/src/tauri.ts +++ b/src/tauri.ts @@ -391,8 +391,7 @@ export function getWorkspaceRoot() { } export function getInitialFile() { - if (!isNativeTauri()) return Promise.resolve(undefined); - return invoke("get_initial_file"); + return callApi("get_initial_file", "/api/initial-file"); } export function getAppInfo() { From 191e8e7231488205d9007f0ec91c42285e83f33d Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Thu, 23 Jul 2026 22:56:56 +0930 Subject: [PATCH 2/4] Update smoke API fixture --- scripts/smoke-test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 83d8073..230658c 100644 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -128,6 +128,11 @@ async function fulfillApi(route) { return; } + if (url.pathname === "/api/initial-file") { + await route.fulfill(json(null)); + return; + } + if (url.pathname === "/api/workspace-display") { await route.fulfill( json({ From d7f9d3e27d035c4c51a6d339da71a8b679dc7f91 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Thu, 23 Jul 2026 22:58:15 +0930 Subject: [PATCH 3/4] Normalize empty initial file values --- src/tauri.test.ts | 7 +++++++ src/tauri.ts | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/tauri.test.ts b/src/tauri.test.ts index d33f911..d6fb748 100644 --- a/src/tauri.test.ts +++ b/src/tauri.test.ts @@ -251,6 +251,13 @@ describe("hosted Tauri API transport", () => { expect(fetchMock.mock.calls[0][0]).toBe("/api/initial-file"); }); + it("normalizes an empty hosted initial file to undefined", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(jsonResponse(null))); + const { getInitialFile } = await import("./tauri"); + + await expect(getInitialFile()).resolves.toBeUndefined(); + }); + it("does not persist recent files from hosted browser mode", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); diff --git a/src/tauri.ts b/src/tauri.ts index 3dd78eb..b1364e4 100644 --- a/src/tauri.ts +++ b/src/tauri.ts @@ -391,7 +391,9 @@ export function getWorkspaceRoot() { } export function getInitialFile() { - return callApi("get_initial_file", "/api/initial-file"); + return callApi("get_initial_file", "/api/initial-file").then( + (path) => path ?? undefined, + ); } export function getAppInfo() { From e68326d5f8565ae8dc60d4cdd394f6d240bf0c20 Mon Sep 17 00:00:00 2001 From: Gordon Beeming Date: Thu, 23 Jul 2026 23:02:21 +0930 Subject: [PATCH 4/4] Make browse file targets request-specific --- scripts/smoke-test.mjs | 5 -- src-tauri/src/http_server.rs | 89 ++++++++++++------------------------ src/tauri.test.ts | 17 ++++--- src/tauri.ts | 7 +-- 4 files changed, 44 insertions(+), 74 deletions(-) diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 230658c..83d8073 100644 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -128,11 +128,6 @@ async function fulfillApi(route) { return; } - if (url.pathname === "/api/initial-file") { - await route.fulfill(json(null)); - return; - } - if (url.pathname === "/api/workspace-display") { await route.fulfill( json({ diff --git a/src-tauri/src/http_server.rs b/src-tauri/src/http_server.rs index d9b3fee..5319228 100644 --- a/src-tauri/src/http_server.rs +++ b/src-tauri/src/http_server.rs @@ -55,7 +55,6 @@ pub struct HttpServerState { #[derive(Clone)] struct ResolvedWorkspace { workspace_root: Arc>, - initial_file: Option>>>, agent_context: Arc>, /// True when the request carried a `/{hash}` prefix that matched an open /// workspace. The `/` handler uses this to serve the SPA for `/{hash}/` @@ -263,7 +262,6 @@ pub async fn start_http_server(config: HttpServerConfig) -> Result>>, - hash: &str, - initial_file: Option, -) -> bool { - let Some(resolved) = resolve_workspace_by_hash(sessions, hash).await else { - return false; - }; - let Some(session_initial_file) = resolved.initial_file else { - return false; - }; - *session_initial_file.write().await = initial_file; - true -} - fn rewrite_request_path(request: &mut Request, new_path: &str) { let path_and_query = match request.uri().query() { Some(query) => format!("{new_path}?{query}"), @@ -531,7 +513,6 @@ async fn resolve_workspace_middleware( ) -> Response { let default = ResolvedWorkspace { workspace_root: state.workspace_root.clone(), - initial_file: None, agent_context: state.agent_context.clone(), scoped: false, }; @@ -568,13 +549,6 @@ async fn workspace_root(Extension(resolved): Extension) -> Js ) } -async fn initial_file(Extension(resolved): Extension) -> Json> { - match resolved.initial_file { - Some(initial_file) => Json(initial_file.read().await.clone()), - None => Json(None), - } -} - async fn workspace_display( Extension(resolved): Extension, Query(query): Query, @@ -1471,6 +1445,20 @@ async fn workspaces(State(state): State) -> Json) -> Result { + let path = format!("/{hash}/"); + let Some(initial_file) = initial_file else { + return Ok(path); + }; + let mut url = + tauri::Url::parse(&format!("http://localhost{path}")).map_err(|error| error.to_string())?; + url.query_pairs_mut().append_pair("file", initial_file); + let query = url + .query() + .ok_or_else(|| "failed to encode browse file query".to_string())?; + Ok(format!("{path}?{query}")) +} + /// `GET /browse?path=` — 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 @@ -1491,16 +1479,22 @@ async fn browse(app: tauri::AppHandle, state: HttpServerState, query: BrowseQuer Err(error) => return ApiError::bad_request(error.to_string()).into_response(), }; let hash = workspace_root_hash(&target.workspace_root); + let redirect_path = match browse_redirect_path(&hash, target.initial_file.as_deref()) { + Ok(path) => path, + Err(error) => return ApiError::internal(error).into_response(), + }; - if !update_open_browse_session(&state.window_sessions, &hash, target.initial_file.clone()).await - { + 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() + Redirect::to(&redirect_path).into_response() } /// Builds the hidden background window for a browse session on the main thread — @@ -1875,7 +1869,6 @@ mod tests { fn default_resolved(state: &HttpServerState) -> Extension { Extension(ResolvedWorkspace { workspace_root: state.workspace_root.clone(), - initial_file: None, agent_context: state.agent_context.clone(), scoped: false, }) @@ -1900,20 +1893,6 @@ mod tests { } } - #[tokio::test] - async fn initial_file_returns_the_scoped_session_target() { - let resolved = ResolvedWorkspace { - workspace_root: Arc::new(RwLock::new(PathBuf::from("/tmp"))), - initial_file: Some(Arc::new(RwLock::new(Some("README.md".to_string())))), - agent_context: Arc::new(RwLock::new(AgentContext::default())), - scoped: true, - }; - - let Json(path) = initial_file(Extension(resolved)).await; - - assert_eq!(path, Some("README.md".to_string())); - } - #[test] fn hosted_indexed_file_search_discards_stale_expansion_directories() { let dir = tempdir().unwrap(); @@ -2833,21 +2812,13 @@ mod tests { .is_none()); } - #[tokio::test] - async fn update_open_browse_session_sets_and_clears_the_requested_file() { - let dir = tempdir().unwrap(); - let session = test_session(dir.path()); - let initial_file = session.initial_file.clone(); - let mut sessions = HashMap::new(); - sessions.insert("main".to_string(), session); - let sessions = Arc::new(std::sync::RwLock::new(sessions)); - let hash = workspace_root_hash(dir.path()); - - assert!(update_open_browse_session(&sessions, &hash, Some("README.md".to_string()),).await); - assert_eq!(*initial_file.read().await, Some("README.md".to_string())); - - assert!(update_open_browse_session(&sessions, &hash, None).await); - assert_eq!(*initial_file.read().await, None); + #[test] + fn browse_redirect_path_includes_an_encoded_file_target() { + assert_eq!( + browse_redirect_path("abc123", Some("docs/hello world#1.md")).unwrap(), + "/abc123/?file=docs%2Fhello+world%231.md" + ); + assert_eq!(browse_redirect_path("abc123", None).unwrap(), "/abc123/"); } #[tokio::test] diff --git a/src/tauri.test.ts b/src/tauri.test.ts index d6fb748..434d082 100644 --- a/src/tauri.test.ts +++ b/src/tauri.test.ts @@ -16,6 +16,7 @@ describe("hosted Tauri API transport", () => { vi.unstubAllGlobals(); vi.resetModules(); delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; + window.history.replaceState({}, "", "/"); }); it("adds the local bearer token to hosted write requests", async () => { @@ -240,19 +241,21 @@ describe("hosted Tauri API transport", () => { expect(fetchMock.mock.calls[1][0]).toBe("/api/agent-context"); }); - it("reads the initial file from the scoped hosted session", async () => { - const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse("README.md")); + it("reads the initial file from the scoped hosted URL", async () => { + const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); + window.history.replaceState({}, "", "/abc123/?file=docs%2Fhello+world.md"); const { getInitialFile } = await import("./tauri"); - await expect(getInitialFile()).resolves.toBe("README.md"); + await expect(getInitialFile()).resolves.toBe("docs/hello world.md"); - expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchMock.mock.calls[0][0]).toBe("/api/initial-file"); + expect(fetchMock).not.toHaveBeenCalled(); }); - it("normalizes an empty hosted initial file to undefined", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(jsonResponse(null))); + it("normalizes an empty native initial file to undefined", async () => { + (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = {}; + const { invoke } = await import("@tauri-apps/api/core"); + vi.mocked(invoke).mockResolvedValueOnce(null); const { getInitialFile } = await import("./tauri"); await expect(getInitialFile()).resolves.toBeUndefined(); diff --git a/src/tauri.ts b/src/tauri.ts index b1364e4..bd6a2ff 100644 --- a/src/tauri.ts +++ b/src/tauri.ts @@ -391,9 +391,10 @@ export function getWorkspaceRoot() { } export function getInitialFile() { - return callApi("get_initial_file", "/api/initial-file").then( - (path) => path ?? undefined, - ); + if (!isNativeTauri()) { + return Promise.resolve(new URLSearchParams(window.location.search).get("file") ?? undefined); + } + return invoke("get_initial_file").then((path) => path ?? undefined); } export function getAppInfo() {