From a45985816b33c5e0730a4bc5e41091d363b986ab Mon Sep 17 00:00:00 2001 From: Udaya Tejas Date: Fri, 2 Oct 2026 10:49:51 -0400 Subject: [PATCH] fix(tui): cycle to real workspaces instead of a phantom "default" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cycle_workspace` hardcoded "default" when entering the named-workspace scope from the "all" scope (and when wrapping around). This assumed the server-provided `workspace_names` list always contains "default" and lists it first. That assumption does not hold: the TUI populates `workspace_names` from `ListWorkspaces`, which is membership-scoped per principal. A non-admin user who is not a member of the "default" workspace receives a list that omits it entirely. In that case, after one full cycle the scope indicator lands on "default" — a workspace that does not exist in the list — and the next cycle finds no match, flipping straight back to "all". The user can never return to any of their real workspaces by cycling. Enter the named scope at `workspace_names.first()` instead, and only when at least one workspace is known. The stale `current_workspace = "default"` writes in the "all" branches are dropped since `current_workspace` is unused while `all_workspaces` is set. Signed-off-by: Udaya Tejas --- crates/openshell-tui/src/app.rs | 42 +++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 4b218a37cf..b7ad3e436f 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -1216,11 +1216,16 @@ impl App { pub fn cycle_workspace(&mut self) { if self.all_workspaces { - self.all_workspaces = false; - self.current_workspace = "default".to_string(); + // Leaving "all" scope: return to the first known named workspace. + // The workspace list is server-provided and membership-scoped, so + // it is not guaranteed to contain (or start with) "default"; only + // enter named scope when at least one workspace is known. + if let Some(first) = self.workspace_names.first() { + self.all_workspaces = false; + self.current_workspace = first.clone(); + } } else if self.workspace_names.is_empty() { self.all_workspaces = true; - self.current_workspace = "default".to_string(); } else { let current_idx = self .workspace_names @@ -1232,7 +1237,6 @@ impl App { } _ => { self.all_workspaces = true; - self.current_workspace = "default".to_string(); } } } @@ -4112,6 +4116,36 @@ mod tests { assert_eq!(app.current_workspace, "default"); } + #[tokio::test] + async fn cycle_workspace_without_default_stays_on_real_workspaces() { + // The workspace list is server-provided and membership-scoped, so a + // non-admin principal may have no "default" workspace visible. + let mut app = test_app(); + app.workspace_names = vec!["team-a".to_string(), "team-b".to_string()]; + app.current_workspace = "team-a".to_string(); + app.all_workspaces = false; + + // Collect every named-scope selection across several full cycles. Each + // named scope must be a workspace that actually exists in the list; + // the cursor must never land on a phantom "default". + let mut named_scopes = Vec::new(); + for _ in 0..8 { + if !app.all_workspaces { + assert!( + app.workspace_names.contains(&app.current_workspace), + "cycled to '{}', which is not a known workspace", + app.current_workspace + ); + named_scopes.push(app.current_workspace.clone()); + } + app.cycle_workspace(); + } + + // Both real workspaces must be reachable by repeated cycling. + assert!(named_scopes.iter().any(|w| w == "team-a")); + assert!(named_scopes.iter().any(|w| w == "team-b")); + } + // -- selected_sandbox_workspace ---------------------------------------- #[test]