From e8c159247a5c0a1d45870c4ca3c636440874503c Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 21 Aug 2026 04:51:44 +0900 Subject: [PATCH] fix: serve chooser before workspace creation --- crates/workspace-server/src/server.rs | 99 ++++++++++++++++++++++++--- 1 file changed, 89 insertions(+), 10 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3cffac63..9bc15fd6 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -897,6 +897,9 @@ async fn dispatch_workspace_request( Ok(workspaces) => workspaces, Err(error) => return server_error_response(error), }; + if workspaces.is_empty() && is_server_static_forward(path) { + return serve_server_static_shell(&api, path).await; + } if workspaces.len() == 1 || is_server_global_forward(path) { match workspaces.first() { Some(workspace) => match api.router_for_workspace(&workspace.workspace_id).await { @@ -927,6 +930,17 @@ fn is_server_global_forward(path: &str) -> bool { || path.starts_with("/assets/") } +fn is_server_static_forward(path: &str) -> bool { + path == "/" || path.starts_with("/_app/") || path.starts_with("/assets/") +} + +async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Response { + let Some(static_root) = api.template.static_assets_dir.as_ref() else { + return StatusCode::NOT_FOUND.into_response(); + }; + static_file_or_spa_response(static_root, path).await +} + fn scoped_workspace_id(path: &str) -> Option<&str> { let mut segments = path.trim_start_matches('/').split('/'); match (segments.next(), segments.next(), segments.next()) { @@ -12503,16 +12517,7 @@ async fn static_or_spa_fallback(State(api): State, uri: Uri) -> Re return StatusCode::NOT_FOUND.into_response(); }; - match read_static_or_index(static_root, scoped_workspace_static_path(uri.path())).await { - Ok(StaticAsset { - bytes, - content_type, - }) => (StatusCode::OK, [(CONTENT_TYPE, content_type)], bytes).into_response(), - Err(error) => { - tracing::debug!(%error, path = %uri.path(), "failed to serve static asset"); - StatusCode::NOT_FOUND.into_response() - } - } + static_file_or_spa_response(static_root, scoped_workspace_static_path(uri.path())).await } fn unscoped_workspace_ui_redirect( @@ -12552,6 +12557,19 @@ struct StaticAsset { content_type: &'static str, } +async fn static_file_or_spa_response(static_root: &Path, request_path: &str) -> Response { + match read_static_or_index(static_root, request_path).await { + Ok(StaticAsset { + bytes, + content_type, + }) => (StatusCode::OK, [(CONTENT_TYPE, content_type)], bytes).into_response(), + Err(error) => { + tracing::debug!(%error, path = request_path, "failed to serve static asset"); + StatusCode::NOT_FOUND.into_response() + } + } +} + fn scoped_workspace_static_path(path: &str) -> &str { let Some(scoped) = path.strip_prefix("/w/") else { return path; @@ -14429,6 +14447,67 @@ mod tests { config } + #[tokio::test] + async fn server_router_serves_workspace_chooser_before_first_workspace_exists() { + let dir = tempfile::tempdir().unwrap(); + let static_dir = dir.path().join("static"); + std::fs::create_dir_all(static_dir.join("_app/immutable/entry")).unwrap(); + std::fs::write( + static_dir.join("index.html"), + "
Workspace chooser
", + ) + .unwrap(); + std::fs::write( + static_dir.join("_app/immutable/entry/start.js"), + "console.log('workspace app');", + ) + .unwrap(); + let mut template = test_server_config(dir.path()); + template.static_assets_dir = Some(static_dir); + let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); + let app = build_workspace_server_router(template, store) + .await + .unwrap(); + + for (uri, expected) in [ + ("/", "
Workspace chooser
"), + ( + "/_app/immutable/entry/start.js", + "console.log('workspace app');", + ), + ] { + let response = app + .clone() + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + String::from_utf8( + to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(), + ) + .unwrap(), + expected + ); + } + + let response = app + .oneshot( + Request::builder() + .uri("/api/workspaces") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(serde_json::from_slice::(&body).unwrap(), json!([])); + } + #[tokio::test] async fn server_router_dispatches_two_workspace_contexts_without_state_leakage() { let dir = tempfile::tempdir().unwrap();