From 7714ca286f7638ab951770d20f74a442fc1a0787 Mon Sep 17 00:00:00 2001 From: Andrew Chapman Date: Tue, 15 Sep 2026 23:20:24 -0800 Subject: [PATCH 1/3] wcore: backfill missing workspace Name/Icon/Color instead of dropping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListWorkspaces silently continue'd past any workspace missing Name, Icon, or Color, excluding it (and every tab/block inside it) from every listing built on top of it - wsh workspace list, wsh blocks list's default enumeration, and (since it's the same canonical listing function) very likely the app's own workspace switcher. This isn't a half-created/zombie-record guard: CreateWorkspace always backfills these three fields via UpdateWorkspace immediately after insert, so there's no legitimate transient state where a real workspace has any of them empty. A workspace found that way is a genuine, live workspace - most likely one that predates these fields or hit a migration gap - being permanently and silently hidden. Confirmed against a real user's database: a workspace with name=NULL/icon=NULL/color=NULL held a tab that was the CLI's own current shell's tab (WAVETERM_TABID), fully live, invisible to `wsh workspace list` and `wsh blocks list` the entire time. Fix: backfill the same default values UpdateWorkspace already uses when it encounters a blank field, persist them once, and include the workspace normally - color is cycled against the workspace count already backfilled in this pass rather than a recursive ListWorkspaces call, since UpdateWorkspace's own approach (calling ListWorkspaces to count) would recurse here. An already-complete workspace takes the pre-existing code path entirely untouched. Added a real (not mocked) sqlite-backed test using an isolated temp-dir DB via the actual wstore migrations: confirms an incomplete workspace is included and its backfill persisted (not just patched on the returned value), an already-complete workspace is left untouched, and confirmed the test fails against the unpatched code (reproduces the exact drop) before passing against the fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- pkg/wcore/workspace.go | 21 ++++++- pkg/wcore/workspace_test.go | 110 ++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 pkg/wcore/workspace_test.go diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index c01e509a13..670ce425cd 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -400,7 +400,26 @@ func ListWorkspaces(ctx context.Context) (waveobj.WorkspaceList, error) { var wl waveobj.WorkspaceList for _, workspace := range workspaces { if workspace.Name == "" || workspace.Icon == "" || workspace.Color == "" { - continue + // CreateWorkspace always backfills these via UpdateWorkspace + // immediately after insert, so a workspace missing any of them + // isn't a half-created/zombie record - it's a real, live + // workspace (with real tabs and blocks) that predates these + // fields or a migration gap. Backfill once and persist, rather + // than silently and permanently hiding it - and everything + // inside it - from every listing. Same default values + // UpdateWorkspace itself uses, computed without a recursive + // ListWorkspaces call: WorkspaceColors is cycled against the + // count already backfilled in this pass instead. + if workspace.Name == "" { + workspace.Name = fmt.Sprintf("New Workspace (%s)", workspace.OID[0:5]) + } + if workspace.Icon == "" { + workspace.Icon = WorkspaceIcons[0] + } + if workspace.Color == "" { + workspace.Color = WorkspaceColors[len(wl)%len(WorkspaceColors)] + } + wstore.DBUpdate(ctx, workspace) } windowId, ok := workspaceToWindow[workspace.OID] if !ok { diff --git a/pkg/wcore/workspace_test.go b/pkg/wcore/workspace_test.go new file mode 100644 index 0000000000..c0e36b87d7 --- /dev/null +++ b/pkg/wcore/workspace_test.go @@ -0,0 +1,110 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 +package wcore + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +// initTestWStore points wstore at a fresh, real sqlite DB (real migrations, +// real driver - not mocked) under t.TempDir() so each test gets an isolated +// store. +func initTestWStore(t *testing.T) context.Context { + t.Helper() + wavebase.DataHome_VarCache = t.TempDir() + if err := wavebase.EnsureWaveDBDir(); err != nil { + t.Fatalf("failed to ensure wave db dir: %v", err) + } + if err := wstore.InitWStore(); err != nil { + t.Fatalf("failed to init wstore: %v", err) + } + return context.Background() +} + +// TestListWorkspaces_BackfillsIncompleteWorkspace reproduces the exact bug +// confirmed against a real user's database: a workspace missing Name/Icon/ +// Color (which normal creation via CreateWorkspace/UpdateWorkspace never +// produces - this only happens on workspaces predating those fields, or a +// migration gap) was silently dropped from ListWorkspaces entirely, along +// with every tab and block inside it. It must now be included, with +// defaults backfilled and persisted rather than just patched in memory. +func TestListWorkspaces_BackfillsIncompleteWorkspace(t *testing.T) { + ctx := initTestWStore(t) + + complete := &waveobj.Workspace{ + OID: uuid.NewString(), + Name: "Workspace1", + Icon: "flask", + Color: "#FF453A", + TabIds: []string{uuid.NewString()}, + } + if err := wstore.DBInsert(ctx, complete); err != nil { + t.Fatalf("failed to insert complete workspace: %v", err) + } + + incompleteTabId := uuid.NewString() + incomplete := &waveobj.Workspace{ + OID: uuid.NewString(), + Name: "", + Icon: "", + Color: "", + TabIds: []string{incompleteTabId}, + } + if err := wstore.DBInsert(ctx, incomplete); err != nil { + t.Fatalf("failed to insert incomplete workspace: %v", err) + } + + list, err := ListWorkspaces(ctx) + if err != nil { + t.Fatalf("ListWorkspaces failed: %v", err) + } + if len(list) != 2 { + t.Fatalf("expected 2 workspaces (the previously-dropped one must now be included), got %d: %+v", len(list), list) + } + + var found bool + for _, entry := range list { + if entry.WorkspaceId == incomplete.OID { + found = true + } + } + if !found { + t.Fatalf("the incomplete workspace %q was dropped from ListWorkspaces, same as the original bug", incomplete.OID) + } + + // The backfill must be persisted, not just patched on the in-memory + // value ListWorkspaces happened to build - re-fetch independently. + refetched, err := wstore.DBMustGet[*waveobj.Workspace](ctx, incomplete.OID) + if err != nil { + t.Fatalf("failed to refetch workspace: %v", err) + } + if refetched.Name == "" { + t.Errorf("Name was not persisted (still empty on refetch)") + } + if refetched.Icon == "" { + t.Errorf("Icon was not persisted (still empty on refetch)") + } + if refetched.Color == "" { + t.Errorf("Color was not persisted (still empty on refetch)") + } + if len(refetched.TabIds) != 1 || refetched.TabIds[0] != incompleteTabId { + t.Errorf("backfill must not disturb existing fields (TabIds), got %v", refetched.TabIds) + } + + // The complete, already-normal workspace must be completely unaffected - + // same values, not re-persisted or altered. + refetchedComplete, err := wstore.DBMustGet[*waveobj.Workspace](ctx, complete.OID) + if err != nil { + t.Fatalf("failed to refetch complete workspace: %v", err) + } + if refetchedComplete.Name != "Workspace1" || refetchedComplete.Icon != "flask" || refetchedComplete.Color != "#FF453A" { + t.Errorf("an already-complete workspace must be left untouched, got name=%q icon=%q color=%q", + refetchedComplete.Name, refetchedComplete.Icon, refetchedComplete.Color) + } +} From 432be083c9133940b8613ef1dd09376cc11f8bfd Mon Sep 17 00:00:00 2001 From: Andrew Chapman Date: Wed, 16 Sep 2026 00:09:59 -0800 Subject: [PATCH 2/3] wcore: split ListWorkspaces into switcher vs CLI variants, drop mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex correctly flagged two P1s against the original backfill approach, and coderabbit a real error-handling gap in the same code - all three pointed at a genuine design conflict, not just implementation bugs: CreateWindow deliberately creates a blank, unsaved scratch workspace for a new window (CreateWorkspace with applyDefaults=false), and DeleteWorkspace auto-cleans one of those up on window close unless it's since been named (checks Name/Icon non-empty). The frontend switcher (WorkspaceService.ListWorkspaces, backed by this same wcore.ListWorkspaces) treats blank Name/Icon as a meaningful "unsaved" state it renders differently. Backfilling and persisting defaults the moment anything calls ListWorkspaces - which the switcher does on mount - would silently "save" scratch workspaces the user never asked to keep, breaking DeleteWorkspace's cleanup and orphaning them. It also had a real concurrent-write race (read via DBGetAllObjsByType, then a later blind DBUpdate could stomp a concurrent change to the same workspace) and swallowed DBUpdate's error entirely. Fix: stop trying to fix this by mutating data. ListWorkspaces keeps its exact original behavior (exclude unsaved workspaces, switcher-facing, zero risk to the existing scratch-workspace lifecycle). New ListAllWorkspaces includes them too, without ever writing anything - this is what wsh workspace list / wsh blocks list need instead, since CLI tooling has to see every live workspace's tabs and blocks regardless of whether the user bothered to name it. Only WshServer.WorkspaceListCommand (which backs those two CLI commands, confirmed via call-site search - never called from the frontend, which uses the separate WorkspaceService.ListWorkspaces) is switched to the new function. Confirmed against a real user's database: an unsaved workspace held their own long-running Claude Code session's tab, completely invisible to wsh blocks list the whole time it was in continuous daily use. Rewrote the test to match: ListWorkspaces still excludes an unsaved workspace AND never mutates it; ListAllWorkspaces includes it, also without mutating it. Both verified with a real (non-mocked) sqlite-backed store via wstore.InitWStore() under an isolated temp dir. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- pkg/wcore/workspace.go | 48 +++++----- pkg/wcore/workspace_test.go | 152 ++++++++++++++++++++---------- pkg/wshrpc/wshserver/wshserver.go | 6 +- 3 files changed, 135 insertions(+), 71 deletions(-) diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index 670ce425cd..daccd2ec7f 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -383,7 +383,32 @@ func UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []str return nil } +// ListWorkspaces returns only "saved" workspaces (Name, Icon, and Color all +// set) - the ones worth offering in the cross-window switcher. CreateWindow +// deliberately creates a blank, unsaved scratch workspace for a new window +// (CreateWorkspace with applyDefaults=false; see CreateWindow), and +// DeleteWorkspace auto-cleans one of those up on close unless it's since +// been named. Excluding unsaved workspaces here is that same intentional +// lifecycle, not a data-completeness bug - do not backfill or persist +// defaults into them, that would silently and permanently "save" scratch +// workspaces the user never asked to keep, defeating DeleteWorkspace's +// cleanup and orphaning them. func ListWorkspaces(ctx context.Context) (waveobj.WorkspaceList, error) { + return listWorkspacesInternal(ctx, false) +} + +// ListAllWorkspaces includes unsaved (scratch) workspaces too - CLI tooling +// (wsh workspace list, wsh blocks list) needs visibility into every live +// workspace's tabs and blocks regardless of whether the user has gotten +// around to naming it, unlike the switcher's "workspaces you'd want to +// jump to" framing. A confirmed real case: a workspace holding a +// continuously-used session's own tab, never named, was completely +// invisible to wsh blocks list because of this filter. +func ListAllWorkspaces(ctx context.Context) (waveobj.WorkspaceList, error) { + return listWorkspacesInternal(ctx, true) +} + +func listWorkspacesInternal(ctx context.Context, includeUnsaved bool) (waveobj.WorkspaceList, error) { workspaces, err := wstore.DBGetAllObjsByType[*waveobj.Workspace](ctx, waveobj.OType_Workspace) if err != nil { return nil, err @@ -399,27 +424,8 @@ func ListWorkspaces(ctx context.Context) (waveobj.WorkspaceList, error) { var wl waveobj.WorkspaceList for _, workspace := range workspaces { - if workspace.Name == "" || workspace.Icon == "" || workspace.Color == "" { - // CreateWorkspace always backfills these via UpdateWorkspace - // immediately after insert, so a workspace missing any of them - // isn't a half-created/zombie record - it's a real, live - // workspace (with real tabs and blocks) that predates these - // fields or a migration gap. Backfill once and persist, rather - // than silently and permanently hiding it - and everything - // inside it - from every listing. Same default values - // UpdateWorkspace itself uses, computed without a recursive - // ListWorkspaces call: WorkspaceColors is cycled against the - // count already backfilled in this pass instead. - if workspace.Name == "" { - workspace.Name = fmt.Sprintf("New Workspace (%s)", workspace.OID[0:5]) - } - if workspace.Icon == "" { - workspace.Icon = WorkspaceIcons[0] - } - if workspace.Color == "" { - workspace.Color = WorkspaceColors[len(wl)%len(WorkspaceColors)] - } - wstore.DBUpdate(ctx, workspace) + if !includeUnsaved && (workspace.Name == "" || workspace.Icon == "" || workspace.Color == "") { + continue } windowId, ok := workspaceToWindow[workspace.OID] if !ok { diff --git a/pkg/wcore/workspace_test.go b/pkg/wcore/workspace_test.go index c0e36b87d7..643efa67b8 100644 --- a/pkg/wcore/workspace_test.go +++ b/pkg/wcore/workspace_test.go @@ -4,6 +4,8 @@ package wcore import ( "context" + "os" + "path/filepath" "testing" "github.com/google/uuid" @@ -14,12 +16,17 @@ import ( // initTestWStore points wstore at a fresh, real sqlite DB (real migrations, // real driver - not mocked) under t.TempDir() so each test gets an isolated -// store. +// store. Creates the db subdirectory directly rather than via +// wavebase.EnsureWaveDBDir(), which caches success process-wide by a fixed +// key - fine for a real single-lifetime process, but it would silently +// no-op for every test after the first, each pointed at its own fresh +// (not-yet-existing) temp dir. func initTestWStore(t *testing.T) context.Context { t.Helper() - wavebase.DataHome_VarCache = t.TempDir() - if err := wavebase.EnsureWaveDBDir(); err != nil { - t.Fatalf("failed to ensure wave db dir: %v", err) + dataDir := t.TempDir() + wavebase.DataHome_VarCache = dataDir + if err := os.MkdirAll(filepath.Join(dataDir, wavebase.WaveDBDir), 0700); err != nil { + t.Fatalf("failed to create wave db dir: %v", err) } if err := wstore.InitWStore(); err != nil { t.Fatalf("failed to init wstore: %v", err) @@ -27,84 +34,131 @@ func initTestWStore(t *testing.T) context.Context { return context.Background() } -// TestListWorkspaces_BackfillsIncompleteWorkspace reproduces the exact bug -// confirmed against a real user's database: a workspace missing Name/Icon/ -// Color (which normal creation via CreateWorkspace/UpdateWorkspace never -// produces - this only happens on workspaces predating those fields, or a -// migration gap) was silently dropped from ListWorkspaces entirely, along -// with every tab and block inside it. It must now be included, with -// defaults backfilled and persisted rather than just patched in memory. -func TestListWorkspaces_BackfillsIncompleteWorkspace(t *testing.T) { +func containsWorkspaceId(list waveobj.WorkspaceList, id string) bool { + for _, entry := range list { + if entry.WorkspaceId == id { + return true + } + } + return false +} + +// TestListWorkspaces_ExcludesUnsavedWithoutMutating confirms ListWorkspaces +// keeps excluding an unsaved (blank Name/Icon/Color) workspace - this is the +// intentional CreateWindow/DeleteWorkspace scratch-workspace lifecycle +// (blank workspaces are deliberately unnamed until a user "saves" them, and +// auto-cleaned up on window close otherwise), not the bug. It must also +// leave the unsaved workspace's stored fields completely untouched - no +// backfill, no persistence - since writing defaults into it would silently +// convert it into a "saved" workspace and break that cleanup lifecycle. +func TestListWorkspaces_ExcludesUnsavedWithoutMutating(t *testing.T) { ctx := initTestWStore(t) - complete := &waveobj.Workspace{ + saved := &waveobj.Workspace{ OID: uuid.NewString(), Name: "Workspace1", Icon: "flask", Color: "#FF453A", TabIds: []string{uuid.NewString()}, } - if err := wstore.DBInsert(ctx, complete); err != nil { - t.Fatalf("failed to insert complete workspace: %v", err) + if err := wstore.DBInsert(ctx, saved); err != nil { + t.Fatalf("failed to insert saved workspace: %v", err) } - incompleteTabId := uuid.NewString() - incomplete := &waveobj.Workspace{ + unsavedTabId := uuid.NewString() + unsaved := &waveobj.Workspace{ OID: uuid.NewString(), Name: "", Icon: "", Color: "", - TabIds: []string{incompleteTabId}, + TabIds: []string{unsavedTabId}, } - if err := wstore.DBInsert(ctx, incomplete); err != nil { - t.Fatalf("failed to insert incomplete workspace: %v", err) + if err := wstore.DBInsert(ctx, unsaved); err != nil { + t.Fatalf("failed to insert unsaved workspace: %v", err) } list, err := ListWorkspaces(ctx) if err != nil { t.Fatalf("ListWorkspaces failed: %v", err) } - if len(list) != 2 { - t.Fatalf("expected 2 workspaces (the previously-dropped one must now be included), got %d: %+v", len(list), list) + if len(list) != 1 || !containsWorkspaceId(list, saved.OID) { + t.Fatalf("expected only the saved workspace, got %+v", list) + } + if containsWorkspaceId(list, unsaved.OID) { + t.Fatalf("unsaved workspace must stay excluded from ListWorkspaces (switcher-facing)") } - var found bool - for _, entry := range list { - if entry.WorkspaceId == incomplete.OID { - found = true - } + // The unsaved workspace's stored fields must be byte-for-byte untouched + // by the call - no backfill, no persisted mutation of any kind. + refetched, err := wstore.DBMustGet[*waveobj.Workspace](ctx, unsaved.OID) + if err != nil { + t.Fatalf("failed to refetch unsaved workspace: %v", err) } - if !found { - t.Fatalf("the incomplete workspace %q was dropped from ListWorkspaces, same as the original bug", incomplete.OID) + if refetched.Name != "" || refetched.Icon != "" || refetched.Color != "" { + t.Fatalf("ListWorkspaces must not persist any backfill into an unsaved workspace, got name=%q icon=%q color=%q", + refetched.Name, refetched.Icon, refetched.Color) } + if len(refetched.TabIds) != 1 || refetched.TabIds[0] != unsavedTabId { + t.Fatalf("ListWorkspaces must not disturb existing fields, got TabIds=%v", refetched.TabIds) + } +} - // The backfill must be persisted, not just patched on the in-memory - // value ListWorkspaces happened to build - re-fetch independently. - refetched, err := wstore.DBMustGet[*waveobj.Workspace](ctx, incomplete.OID) - if err != nil { - t.Fatalf("failed to refetch workspace: %v", err) +// TestListAllWorkspaces_IncludesUnsavedWithoutMutating is the actual fix: +// CLI tooling (wsh workspace list, wsh blocks list) needs visibility into +// every live workspace regardless of saved status - confirmed against a +// real user's database, where an unsaved workspace held a continuously-used +// session's own tab, completely invisible to those commands. It must +// include the unsaved workspace, but - just like ListWorkspaces - must +// never write anything into it; only the caller decides whether/how to +// display an unnamed workspace. +func TestListAllWorkspaces_IncludesUnsavedWithoutMutating(t *testing.T) { + ctx := initTestWStore(t) + + saved := &waveobj.Workspace{ + OID: uuid.NewString(), + Name: "Workspace1", + Icon: "flask", + Color: "#FF453A", + TabIds: []string{uuid.NewString()}, } - if refetched.Name == "" { - t.Errorf("Name was not persisted (still empty on refetch)") + if err := wstore.DBInsert(ctx, saved); err != nil { + t.Fatalf("failed to insert saved workspace: %v", err) } - if refetched.Icon == "" { - t.Errorf("Icon was not persisted (still empty on refetch)") + + unsavedTabId := uuid.NewString() + unsaved := &waveobj.Workspace{ + OID: uuid.NewString(), + Name: "", + Icon: "", + Color: "", + TabIds: []string{unsavedTabId}, } - if refetched.Color == "" { - t.Errorf("Color was not persisted (still empty on refetch)") + if err := wstore.DBInsert(ctx, unsaved); err != nil { + t.Fatalf("failed to insert unsaved workspace: %v", err) } - if len(refetched.TabIds) != 1 || refetched.TabIds[0] != incompleteTabId { - t.Errorf("backfill must not disturb existing fields (TabIds), got %v", refetched.TabIds) + + list, err := ListAllWorkspaces(ctx) + if err != nil { + t.Fatalf("ListAllWorkspaces failed: %v", err) + } + if len(list) != 2 { + t.Fatalf("expected both workspaces (the unsaved one must now be visible to CLI tooling), got %d: %+v", len(list), list) + } + if !containsWorkspaceId(list, saved.OID) || !containsWorkspaceId(list, unsaved.OID) { + t.Fatalf("expected both workspace IDs present, got %+v", list) } - // The complete, already-normal workspace must be completely unaffected - - // same values, not re-persisted or altered. - refetchedComplete, err := wstore.DBMustGet[*waveobj.Workspace](ctx, complete.OID) + // No mutation, same as ListWorkspaces - this function only changes what + // gets included, never what gets written. + refetched, err := wstore.DBMustGet[*waveobj.Workspace](ctx, unsaved.OID) if err != nil { - t.Fatalf("failed to refetch complete workspace: %v", err) + t.Fatalf("failed to refetch unsaved workspace: %v", err) + } + if refetched.Name != "" || refetched.Icon != "" || refetched.Color != "" { + t.Fatalf("ListAllWorkspaces must not persist any backfill either, got name=%q icon=%q color=%q", + refetched.Name, refetched.Icon, refetched.Color) } - if refetchedComplete.Name != "Workspace1" || refetchedComplete.Icon != "flask" || refetchedComplete.Color != "#FF453A" { - t.Errorf("an already-complete workspace must be left untouched, got name=%q icon=%q color=%q", - refetchedComplete.Name, refetchedComplete.Icon, refetchedComplete.Color) + if len(refetched.TabIds) != 1 || refetched.TabIds[0] != unsavedTabId { + t.Fatalf("ListAllWorkspaces must not disturb existing fields, got TabIds=%v", refetched.TabIds) } } diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 38006fd9a8..cc094a25f2 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -945,7 +945,11 @@ func (ws *WshServer) BlocksListCommand( } func (ws *WshServer) WorkspaceListCommand(ctx context.Context) ([]wshrpc.WorkspaceInfoData, error) { - workspaceList, err := wcore.ListWorkspaces(ctx) + // This backs wsh workspace list / wsh blocks list, not the frontend + // switcher (WorkspaceService.ListWorkspaces, unchanged) - CLI tooling + // needs every live workspace's tabs/blocks, including unsaved scratch + // ones the switcher intentionally hides. See ListAllWorkspaces. + workspaceList, err := wcore.ListAllWorkspaces(ctx) if err != nil { return nil, fmt.Errorf("error listing workspaces: %w", err) } From 9fb4a55ef1ba70dbf9428a6d554e2db026b6372a Mon Sep 17 00:00:00 2001 From: Andrew Chapman Date: Wed, 16 Sep 2026 00:40:30 -0800 Subject: [PATCH 3/3] wshrpc: add WorkspaceListAllCommand instead of repurposing the shared one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex caught a real regression in 432be083: WorkspaceListCommand isn't CLI-only, despite the comment claiming otherwise. emain-menu.ts's Electron Workspace menu and emain-tabview.ts's Alt+Ctrl+ workspace shortcuts both call it directly, and both rely on unsaved (scratch) workspaces staying excluded - the menu labels them by workspacedata.name (blank for a scratch workspace) and the shortcuts index into the list positionally. My earlier call-site search covered pkg/, frontend/, and cmd/ but missed emain/ entirely, a third source tree in this repo (Electron main-process TS) - so the "never called from the frontend" claim in the previous commit was wrong. Fix: leave WorkspaceListCommand's behavior completely unchanged (excludes unsaved workspaces, used by emain AND the frontend switcher). Add a new WorkspaceListAllCommand RPC that includes them, regenerated through the real codegen (cmd/generatego, cmd/generatets) rather than hand-edited, and point only wsh workspace list / wsh blocks list at it. Verified: go build/vet/test clean, and a full `npm run build:prod` (electron-vite, which compiles main/preload/renderer together) confirms emain-menu.ts and emain-tabview.ts still compile clean against the regenerated bindings, unchanged and unaffected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- cmd/wsh/cmd/wshcmd-blocks.go | 2 +- cmd/wsh/cmd/wshcmd-workspace.go | 2 +- frontend/app/store/wshclientapi.ts | 6 ++++++ pkg/wshrpc/wshclient/wshclient.go | 6 ++++++ pkg/wshrpc/wshrpctypes.go | 7 +++++++ pkg/wshrpc/wshserver/wshserver.go | 28 +++++++++++++++++++++++----- 6 files changed, 44 insertions(+), 7 deletions(-) diff --git a/cmd/wsh/cmd/wshcmd-blocks.go b/cmd/wsh/cmd/wshcmd-blocks.go index 7e4b935ee3..3414084da9 100644 --- a/cmd/wsh/cmd/wshcmd-blocks.go +++ b/cmd/wsh/cmd/wshcmd-blocks.go @@ -106,7 +106,7 @@ func blocksListRun(cmd *cobra.Command, args []string) error { var allBlocks []BlockDetails - workspaces, err := wshclient.WorkspaceListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)}) + workspaces, err := wshclient.WorkspaceListAllCommand(RpcClient, &wshrpc.RpcOpts{Timeout: int64(blocksTimeout)}) if err != nil { return fmt.Errorf("failed to list workspaces: %v", err) } diff --git a/cmd/wsh/cmd/wshcmd-workspace.go b/cmd/wsh/cmd/wshcmd-workspace.go index 6a793d68cf..b6ec198e6b 100644 --- a/cmd/wsh/cmd/wshcmd-workspace.go +++ b/cmd/wsh/cmd/wshcmd-workspace.go @@ -28,7 +28,7 @@ var workspaceListCommand = &cobra.Command{ } func workspaceListRun(cmd *cobra.Command, args []string) { - workspaces, err := wshclient.WorkspaceListCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) + workspaces, err := wshclient.WorkspaceListAllCommand(RpcClient, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { WriteStderr("Unable to list workspaces: %v\n", err) return diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 8482be260d..5dece08473 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -1032,6 +1032,12 @@ export class RpcApiType { return client.wshRpcCall("workspacelist", null, opts); } + // command "workspacelistall" [call] + WorkspaceListAllCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "workspacelistall", null, opts); + return client.wshRpcCall("workspacelistall", null, opts); + } + // command "writeappfile" [call] WriteAppFileCommand(client: WshClient, data: CommandWriteAppFileData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "writeappfile", data, opts); diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index d5333aec2b..f2b426db52 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -1024,6 +1024,12 @@ func WorkspaceListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.Wor return resp, err } +// command "workspacelistall", wshserver.WorkspaceListAllCommand +func WorkspaceListAllCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.WorkspaceInfoData, error) { + resp, err := sendRpcRequestCallHelper[[]wshrpc.WorkspaceInfoData](w, "workspacelistall", nil, opts) + return resp, err +} + // command "writeappfile", wshserver.WriteAppFileCommand func WriteAppFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAppFileData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "writeappfile", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 51e2338ba8..db2c709f71 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -146,6 +146,13 @@ type WshRpcInterface interface { GetSecretsLinuxStorageBackendCommand(ctx context.Context) (string, error) WorkspaceListCommand(ctx context.Context) ([]WorkspaceInfoData, error) + // WorkspaceListAllCommand is the CLI-only counterpart to + // WorkspaceListCommand: it includes unsaved (scratch) workspaces too. + // WorkspaceListCommand itself must keep excluding them - it's also + // called from emain (Electron Workspace menu, Alt+Ctrl+N workspace + // switching), which relies on that filtering to avoid blank menu + // entries and shortcut slots for scratch workspaces. + WorkspaceListAllCommand(ctx context.Context) ([]WorkspaceInfoData, error) GetUpdateChannelCommand(ctx context.Context) (string, error) // terminal diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index cc094a25f2..fd5cd50162 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -945,11 +945,29 @@ func (ws *WshServer) BlocksListCommand( } func (ws *WshServer) WorkspaceListCommand(ctx context.Context) ([]wshrpc.WorkspaceInfoData, error) { - // This backs wsh workspace list / wsh blocks list, not the frontend - // switcher (WorkspaceService.ListWorkspaces, unchanged) - CLI tooling - // needs every live workspace's tabs/blocks, including unsaved scratch - // ones the switcher intentionally hides. See ListAllWorkspaces. - workspaceList, err := wcore.ListAllWorkspaces(ctx) + return workspaceListInternal(ctx, false) +} + +// WorkspaceListAllCommand is the CLI-only counterpart backing wsh workspace +// list / wsh blocks list: it includes unsaved (scratch) workspaces too, +// since CLI tooling needs visibility into every live workspace's tabs and +// blocks regardless of whether the user has named it. WorkspaceListCommand +// itself is also called from emain (Electron Workspace menu, Alt+Ctrl+N +// workspace switching), which relies on unsaved workspaces staying +// excluded there to avoid blank menu entries and shortcut slots - so it +// must keep its original behavior unchanged. +func (ws *WshServer) WorkspaceListAllCommand(ctx context.Context) ([]wshrpc.WorkspaceInfoData, error) { + return workspaceListInternal(ctx, true) +} + +func workspaceListInternal(ctx context.Context, includeUnsaved bool) ([]wshrpc.WorkspaceInfoData, error) { + var workspaceList waveobj.WorkspaceList + var err error + if includeUnsaved { + workspaceList, err = wcore.ListAllWorkspaces(ctx) + } else { + workspaceList, err = wcore.ListWorkspaces(ctx) + } if err != nil { return nil, fmt.Errorf("error listing workspaces: %w", err) }