返回 DeepSeek-Reasonix
tabs_order_test.go
根目录 / desktop / tabs_order_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7
8 "reasonix/internal/config"
9 "reasonix/internal/control"
10 "reasonix/internal/event"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15 )
16
17 func testAppWithOrderedTabs(t *testing.T, active string, ids ...string) *App {
18 t.Helper()
19 isolateDesktopUserDirs(t)
20 tabs := make(map[string]*WorkspaceTab, len(ids))
21 for _, id := range ids {
22 tabs[id] = &WorkspaceTab{
23 ID: id,
24 Scope: "global",
25 TopicID: "topic_" + id,
26 TopicTitle: id,
27 Ready: true,
28 disabledMCP: map[string]ServerView{},
29 }
30 }
31 return &App{tabs: tabs, tabOrder: append([]string(nil), ids...), activeTabID: active}
32 }
33
34 func installNoopRuntimeEvents(app *App, sinks ...*tabEventSink) {
35 emit := func(context.Context, string, ...any) {}
36 if app != nil {
37 app.runtimeEvents.emit = emit
38 }
39 for _, sink := range sinks {
40 if sink != nil {
41 sink.runtimeEvents.emit = emit
42 }
43 }
44 }
45
46 func tabIDs(tabs []TabMeta) []string {
47 ids := make([]string, 0, len(tabs))
48 for _, tab := range tabs {
49 ids = append(ids, tab.ID)
50 }
51 return ids
52 }
53
54 func assertTabIDs(t *testing.T, got []TabMeta, want ...string) {
55 t.Helper()
56 gotIDs := tabIDs(got)
57 if len(gotIDs) != len(want) {
58 t.Fatalf("tab ids = %v, want %v", gotIDs, want)
59 }
60 for i := range want {
61 if gotIDs[i] != want[i] {
62 t.Fatalf("tab ids = %v, want %v", gotIDs, want)
63 }
64 }
65 }
66
67 type resetCountingSession struct {
68 control.SessionAPI
69 resets int
70 }
71
72 func (s *resetCountingSession) ResetPlannerSession() {
73 s.resets++
74 }
75
76 type snapshotObservingSession struct {
77 control.SessionAPI
78 onSnapshot func()
79 }
80
81 func (s *snapshotObservingSession) Snapshot() error {
82 if s.onSnapshot != nil {
83 s.onSnapshot()
84 }
85 return nil
86 }
87
88 func expectAppMutexAvailableDuringSnapshot(t *testing.T, app *App, checks chan<- struct{}) func() {
89 t.Helper()
90 return func() {
91 acquired := make(chan struct{})
92 go func() {
93 app.mu.Lock()
94 app.mu.Unlock() //nolint:staticcheck // probe: lock must be immediately acquirable
95 close(acquired)
96 }()
97 select {
98 case <-acquired:
99 case <-time.After(500 * time.Millisecond):
100 t.Error("Snapshot ran while holding app mutex")
101 }
102 if checks == nil {
103 return
104 }
105 select {
106 case checks <- struct{}{}:
107 default:
108 }
109 }
110 }
111
112 func TestListTabsKeepsExplicitOrderWhenActiveChanges(t *testing.T) {
113 app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
114
115 assertTabIDs(t, app.ListTabs(), "a", "b", "c")
116 if err := app.SetActiveTab("c"); err != nil {
117 t.Fatalf("SetActiveTab: %v", err)
118 }
119 assertTabIDs(t, app.ListTabs(), "a", "b", "c")
120 if got := app.activeTabID; got != "c" {
121 t.Fatalf("active tab = %q, want c", got)
122 }
123 }
124
125 func TestSetActiveTabDoesNotResetPlannerSession(t *testing.T) {
126 isolateDesktopUserDirs(t)
127 ctrlA := &resetCountingSession{SessionAPI: control.New(control.Options{Label: "a"})}
128 ctrlB := &resetCountingSession{SessionAPI: control.New(control.Options{Label: "b"})}
129 defer ctrlA.Close()
130 defer ctrlB.Close()
131 app := testAppWithOrderedTabs(t, "a", "a", "b")
132 app.tabs["a"].Ctrl = ctrlA
133 app.tabs["b"].Ctrl = ctrlB
134
135 if err := app.SetActiveTab("b"); err != nil {
136 t.Fatalf("SetActiveTab: %v", err)
137 }
138 if ctrlA.resets != 0 || ctrlB.resets != 0 {
139 t.Fatalf("planner resets on tab activation = active:%d inactive:%d, want 0", ctrlA.resets, ctrlB.resets)
140 }
141 }
142
143 func TestSingleSurfaceTabsFileKeepsActiveEntry(t *testing.T) {
144 f := desktopTabsFile{
145 Tabs: []desktopTabEntry{
146 {ID: "a", Scope: "global", TopicID: "topic-a"},
147 {ID: "b", Scope: "project", WorkspaceRoot: "/tmp/project", TopicID: "topic-b"},
148 {ID: "c", Scope: "global", TopicID: "topic-c"},
149 },
150 ActiveTab: "b",
151 }
152
153 got := singleSurfaceTabsFile(f)
154 if len(got.Tabs) != 1 || got.Tabs[0].ID != "b" || got.ActiveTab != "b" {
155 t.Fatalf("single-surface tabs = %+v, want only active b", got)
156 }
157 }
158
159 func TestSetDesktopLayoutStyleAppliesPolicyAfterWorkspaceAlias(t *testing.T) {
160 app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
161
162 if err := app.SetDesktopLayoutStyle("workspace"); err != nil {
163 t.Fatalf("SetDesktopLayoutStyle(workspace): %v", err)
164 }
165
166 assertTabIDs(t, app.ListTabs(), "b")
167 if got := loadTabsFile(); len(got.Tabs) != 1 || got.Tabs[0].ID != "b" || got.ActiveTab != "b" {
168 t.Fatalf("persisted tabs after workspace alias = %+v, want only active b", got)
169 }
170 }
171
172 func TestKeepOnlyVisibleTabDetachesRunningHiddenTab(t *testing.T) {
173 isolateDesktopUserDirs(t)
174 dir := config.SessionDir()
175 if err := os.MkdirAll(dir, 0o755); err != nil {
176 t.Fatalf("mkdir sessions: %v", err)
177 }
178 path := filepath.Join(dir, "running.jsonl")
179 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
180 ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "running", Sink: event.Discard})
181 running := &WorkspaceTab{
182 ID: "running",
183 Scope: "global",
184 WorkspaceRoot: globalTabWorkspaceRoot(),
185 SessionPath: path,
186 Ctrl: ctrl,
187 Ready: true,
188 sink: &tabEventSink{tabID: "running"},
189 disabledMCP: map[string]ServerView{},
190 }
191 target := &WorkspaceTab{ID: "target", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
192 app := &App{
193 tabs: map[string]*WorkspaceTab{"running": running, "target": target},
194 tabOrder: []string{"running", "target"},
195 activeTabID: "running",
196 detachedSessions: map[string]*WorkspaceTab{},
197 }
198
199 ctrl.Submit("block")
200 <-runner.started
201 if _, err := app.keepOnlyVisibleTab("target"); err != nil {
202 t.Fatalf("keepOnlyVisibleTab: %v", err)
203 }
204 assertTabIDs(t, app.ListTabs(), "target")
205 if !ctrl.Running() {
206 t.Fatal("single-surface pruning cancelled a running controller")
207 }
208 if _, ok := app.detachedSessions[sessionRuntimeKey(path)]; !ok {
209 t.Fatalf("detached runtime missing for %q", path)
210 }
211 if got := loadTabsFile(); len(got.Tabs) != 1 || got.Tabs[0].ID != "target" {
212 t.Fatalf("persisted tabs after pruning = %+v, want only target", got)
213 }
214
215 close(runner.release)
216 waitNotRunning(t, ctrl)
217 ctrl.Close()
218 }
219
220 func TestKeepOnlyVisibleTabSnapshotsHiddenTabWithoutAppLock(t *testing.T) {
221 isolateDesktopUserDirs(t)
222 app := &App{
223 tabs: map[string]*WorkspaceTab{},
224 tabOrder: []string{"hidden", "target"},
225 activeTabID: "hidden",
226 }
227 snapshotChecks := make(chan struct{}, 2)
228 hiddenCtrl := &snapshotObservingSession{
229 SessionAPI: control.New(control.Options{Label: "hidden"}),
230 onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
231 }
232 hidden := &WorkspaceTab{
233 ID: "hidden",
234 Scope: "global",
235 WorkspaceRoot: globalTabWorkspaceRoot(),
236 TopicID: "topic-hidden",
237 Ctrl: hiddenCtrl,
238 Ready: true,
239 sink: &tabEventSink{tabID: "hidden"},
240 disabledMCP: map[string]ServerView{},
241 }
242 target := &WorkspaceTab{
243 ID: "target",
244 Scope: "global",
245 Ready: true,
246 disabledMCP: map[string]ServerView{},
247 }
248 app.tabs["hidden"] = hidden
249 app.tabs["target"] = target
250
251 if _, err := app.keepOnlyVisibleTab("target"); err != nil {
252 t.Fatalf("keepOnlyVisibleTab: %v", err)
253 }
254 select {
255 case <-snapshotChecks:
256 case <-time.After(time.Second):
257 t.Fatal("hidden tab was not snapshotted before pruning")
258 }
259 assertTabIDs(t, app.ListTabs(), "target")
260 }
261
262 func TestCloseTabSnapshotsWithoutAppLock(t *testing.T) {
263 isolateDesktopUserDirs(t)
264 app := &App{
265 tabs: map[string]*WorkspaceTab{},
266 tabOrder: []string{"closing", "survivor"},
267 activeTabID: "closing",
268 }
269 snapshotChecks := make(chan struct{}, 1)
270 closingCtrl := &snapshotObservingSession{
271 SessionAPI: control.New(control.Options{Label: "closing"}),
272 onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
273 }
274 closing := &WorkspaceTab{
275 ID: "closing",
276 Scope: "global",
277 WorkspaceRoot: globalTabWorkspaceRoot(),
278 TopicID: "topic-closing",
279 Ctrl: closingCtrl,
280 Ready: true,
281 sink: &tabEventSink{tabID: "closing"},
282 disabledMCP: map[string]ServerView{},
283 }
284 survivor := &WorkspaceTab{
285 ID: "survivor",
286 Scope: "global",
287 Ready: true,
288 disabledMCP: map[string]ServerView{},
289 }
290 app.tabs["closing"] = closing
291 app.tabs["survivor"] = survivor
292
293 if err := app.CloseTab("closing"); err != nil {
294 t.Fatalf("CloseTab: %v", err)
295 }
296 select {
297 case <-snapshotChecks:
298 case <-time.After(time.Second):
299 t.Fatal("closing tab was not snapshotted")
300 }
301 assertTabIDs(t, app.ListTabs(), "survivor")
302 }
303
304 func TestKeepOnlyVisibleTabCancelsBuildingHiddenTab(t *testing.T) {
305 isolateDesktopUserDirs(t)
306 cancelled := false
307 building := &WorkspaceTab{
308 ID: "building",
309 Scope: "global",
310 Ready: false,
311 buildCancel: func() { cancelled = true },
312 sink: &tabEventSink{tabID: "building"},
313 disabledMCP: map[string]ServerView{},
314 }
315 target := &WorkspaceTab{ID: "target", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
316 app := &App{
317 tabs: map[string]*WorkspaceTab{"building": building, "target": target},
318 tabOrder: []string{"building", "target"},
319 activeTabID: "building",
320 }
321
322 if _, err := app.keepOnlyVisibleTab("target"); err != nil {
323 t.Fatalf("keepOnlyVisibleTab: %v", err)
324 }
325
326 assertTabIDs(t, app.ListTabs(), "target")
327 if !cancelled {
328 t.Fatal("building tab build was not cancelled")
329 }
330 if !building.removed {
331 t.Fatal("building tab was not marked removed")
332 }
333 }
334
335 func TestClearTabBuildCancelKeepsSuccessfulControllerContext(t *testing.T) {
336 ctx, cancel := context.WithCancel(context.Background())
337 defer cancel()
338 tab := &WorkspaceTab{ID: "tab", buildGeneration: 1, buildCancel: cancel}
339 app := &App{}
340
341 app.clearTabBuildCancel(tab, 1, cancel, true)
342
343 if tab.buildCancel != nil {
344 t.Fatal("build cancel was not cleared")
345 }
346 select {
347 case <-ctx.Done():
348 t.Fatal("successful tab build context was cancelled")
349 default:
350 }
351 }
352
353 func TestClearTabBuildCancelCancelsAbandonedBuildContext(t *testing.T) {
354 ctx, cancel := context.WithCancel(context.Background())
355 tab := &WorkspaceTab{ID: "tab", buildGeneration: 1, buildCancel: cancel}
356 app := &App{}
357
358 app.clearTabBuildCancel(tab, 1, cancel, false)
359
360 if tab.buildCancel != nil {
361 t.Fatal("build cancel was not cleared")
362 }
363 select {
364 case <-ctx.Done():
365 default:
366 t.Fatal("abandoned tab build context was not cancelled")
367 }
368 }
369
370 func TestEnsureSessionLeaseSerializesConcurrentSameTabAcquire(t *testing.T) {
371 isolateDesktopUserDirs(t)
372 dir := config.SessionDir()
373 if err := os.MkdirAll(dir, 0o755); err != nil {
374 t.Fatalf("mkdir sessions: %v", err)
375 }
376 path := filepath.Join(dir, "same-tab-concurrent-lease.jsonl")
377 tab := &WorkspaceTab{ID: "tab"}
378 t.Cleanup(tab.releaseSessionLease)
379
380 acquired := make(chan struct{})
381 releaseHook := make(chan struct{})
382 var once sync.Once
383 sessionLeaseAcquireHookForTest = func() {
384 once.Do(func() {
385 close(acquired)
386 <-releaseHook
387 })
388 }
389 t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
390
391 firstErr := make(chan error, 1)
392 go func() {
393 firstErr <- tab.ensureSessionLease(path)
394 }()
395
396 select {
397 case <-acquired:
398 case err := <-firstErr:
399 t.Fatalf("first ensureSessionLease returned before hook: %v", err)
400 case <-time.After(2 * time.Second):
401 t.Fatal("first ensureSessionLease did not acquire lease")
402 }
403
404 secondErr := make(chan error, 1)
405 go func() {
406 secondErr <- tab.ensureSessionLease(path)
407 }()
408
409 select {
410 case err := <-secondErr:
411 t.Fatalf("second ensureSessionLease returned while first acquire was unbound: %v", err)
412 case <-time.After(50 * time.Millisecond):
413 }
414
415 close(releaseHook)
416 if err := <-firstErr; err != nil {
417 t.Fatalf("first ensureSessionLease: %v", err)
418 }
419 if err := <-secondErr; err != nil {
420 t.Fatalf("second ensureSessionLease should reuse the tab lease: %v", err)
421 }
422 }
423
424 func TestAttachExistingSessionRuntimeSkipsRemovedTab(t *testing.T) {
425 isolateDesktopUserDirs(t)
426 dir := config.SessionDir()
427 if err := os.MkdirAll(dir, 0o755); err != nil {
428 t.Fatalf("mkdir sessions: %v", err)
429 }
430 path := filepath.Join(dir, "detached.jsonl")
431 key := sessionRuntimeKey(path)
432 detachedCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "detached", Sink: event.Discard})
433 defer detachedCtrl.Close()
434 detached := &WorkspaceTab{
435 ID: "detached",
436 Scope: "global",
437 SessionPath: path,
438 Ctrl: detachedCtrl,
439 Ready: true,
440 SharedHostKey: "detached-host",
441 sink: &tabEventSink{tabID: "detached"},
442 disabledMCP: map[string]ServerView{},
443 }
444 target := &WorkspaceTab{
445 ID: "target",
446 Scope: "global",
447 SessionPath: path,
448 removed: true,
449 sink: &tabEventSink{tabID: "target"},
450 disabledMCP: map[string]ServerView{},
451 }
452 app := &App{
453 tabs: map[string]*WorkspaceTab{},
454 detachedSessions: map[string]*WorkspaceTab{key: detached},
455 }
456
457 if app.attachExistingSessionRuntime(target, path, nil) {
458 t.Fatal("removed tab reattached a detached runtime")
459 }
460 if app.detachedSessions[key] != detached {
461 t.Fatal("detached runtime was removed")
462 }
463 if target.Ctrl != nil || target.Ready {
464 t.Fatal("removed target tab was mutated")
465 }
466 }
467
468 func TestRemoveWorkspaceSnapshotsProjectTabBeforeRemovingBinding(t *testing.T) {
469 isolateDesktopUserDirs(t)
470 projectRoot := t.TempDir()
471 if err := addProject(projectRoot, "Project"); err != nil {
472 t.Fatalf("add project: %v", err)
473 }
474 app := &App{
475 tabs: map[string]*WorkspaceTab{
476 "project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
477 "global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
478 },
479 tabOrder: []string{"project", "global"},
480 activeTabID: "project",
481 detachedSessions: map[string]*WorkspaceTab{},
482 }
483 sawBindingDuringSnapshot := false
484 app.tabs["project"].Ctrl = &snapshotObservingSession{
485 SessionAPI: control.New(control.Options{Label: "project"}),
486 onSnapshot: func() {
487 sawBindingDuringSnapshot = app.tabs["project"] != nil
488 },
489 }
490
491 if err := app.RemoveWorkspace(projectRoot); err != nil {
492 t.Fatalf("RemoveWorkspace: %v", err)
493 }
494 if !sawBindingDuringSnapshot {
495 t.Fatal("project tab was removed from app.tabs before Snapshot")
496 }
497 }
498
499 func TestRemoveWorkspaceSnapshotsProjectTabWithoutAppLock(t *testing.T) {
500 isolateDesktopUserDirs(t)
501 projectRoot := t.TempDir()
502 if err := addProject(projectRoot, "Project"); err != nil {
503 t.Fatalf("add project: %v", err)
504 }
505 app := &App{
506 tabs: map[string]*WorkspaceTab{
507 "project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
508 "global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
509 },
510 tabOrder: []string{"project", "global"},
511 activeTabID: "project",
512 detachedSessions: map[string]*WorkspaceTab{},
513 }
514 snapshotChecks := make(chan struct{}, 1)
515 app.tabs["project"].Ctrl = &snapshotObservingSession{
516 SessionAPI: control.New(control.Options{Label: "project"}),
517 onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
518 }
519
520 if err := app.RemoveWorkspace(projectRoot); err != nil {
521 t.Fatalf("RemoveWorkspace: %v", err)
522 }
523 select {
524 case <-snapshotChecks:
525 case <-time.After(time.Second):
526 t.Fatal("project tab was not snapshotted before removing workspace")
527 }
528 assertTabIDs(t, app.ListTabs(), "global")
529 }
530
531 func TestRemoveWorkspaceRejectsRunningProjectRuntime(t *testing.T) {
532 isolateDesktopUserDirs(t)
533 projectRoot := t.TempDir()
534 if err := addProject(projectRoot, "Project"); err != nil {
535 t.Fatalf("add project: %v", err)
536 }
537 dir := desktopSessionDir(projectRoot)
538 if err := os.MkdirAll(dir, 0o755); err != nil {
539 t.Fatalf("mkdir project sessions: %v", err)
540 }
541 path := filepath.Join(dir, "running.jsonl")
542 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
543 ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "running", Sink: event.Discard})
544 project := &WorkspaceTab{
545 ID: "project",
546 Scope: "project",
547 WorkspaceRoot: projectRoot,
548 TopicID: "topic-project",
549 SessionPath: path,
550 Ctrl: ctrl,
551 Ready: true,
552 sink: &tabEventSink{tabID: "project"},
553 disabledMCP: map[string]ServerView{},
554 }
555 app := &App{
556 tabs: map[string]*WorkspaceTab{
557 "project": project,
558 "global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), Ready: true, disabledMCP: map[string]ServerView{}},
559 },
560 tabOrder: []string{"project", "global"},
561 activeTabID: "project",
562 detachedSessions: map[string]*WorkspaceTab{},
563 }
564
565 ctrl.Submit("block")
566 <-runner.started
567 if err := app.RemoveWorkspace(projectRoot); err == nil {
568 t.Fatal("RemoveWorkspace succeeded with a running project session")
569 }
570 if got := app.ListWorkspaces(); len(got) != 1 || got[0].Path != normalizeProjectRoot(projectRoot) {
571 t.Fatalf("workspaces after rejected remove = %+v, want project retained", got)
572 }
573
574 close(runner.release)
575 waitNotRunning(t, ctrl)
576 ctrl.Close()
577 }
578
579 func TestListTabsRepairsStaleOrderWithoutRacing(t *testing.T) {
580 app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
581 app.tabOrder = []string{"a"}
582
583 var wg sync.WaitGroup
584 start := make(chan struct{})
585 errs := make(chan string, 8)
586 iterations := 100
587 if testing.Short() {
588 iterations = 5
589 }
590 for range 8 {
591 wg.Go(func() {
592 <-start
593 for range iterations {
594 if got := strings.Join(tabIDs(app.ListTabs()), ","); got != "a,b,c" {
595 errs <- got
596 return
597 }
598 }
599 })
600 }
601 close(start)
602 wg.Wait()
603 close(errs)
604 for got := range errs {
605 t.Fatalf("tab ids = %q, want a,b,c", got)
606 }
607
608 if got := strings.Join(app.tabOrder, ","); got != "a,b,c" {
609 t.Fatalf("repaired tab order = %q, want a,b,c", got)
610 }
611 }
612
613 func TestSaveTabsSkipsOlderSnapshot(t *testing.T) {
614 app := testAppWithOrderedTabs(t, "a", "a", "b")
615
616 app.mu.Lock()
617 dir, oldEntries, oldActiveID, oldVersion := app.saveTabsCollectLocked()
618 app.activeTabID = "b"
619 _, newEntries, newActiveID, newVersion := app.saveTabsCollectLocked()
620 app.mu.Unlock()
621
622 app.saveTabsWrite(dir, newEntries, newActiveID, newVersion)
623 app.saveTabsWrite(dir, oldEntries, oldActiveID, oldVersion)
624
625 if got := loadTabsFile().ActiveTab; got != "b" {
626 t.Fatalf("persisted active tab = %q, want b", got)
627 }
628 }
629
630 func TestReorderTabsPersistsSubmittedOrder(t *testing.T) {
631 app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
632
633 if err := app.ReorderTabs([]string{"c", "a", "b"}); err != nil {
634 t.Fatalf("ReorderTabs: %v", err)
635 }
636 assertTabIDs(t, app.ListTabs(), "c", "a", "b")
637 if got := app.activeTabID; got != "a" {
638 t.Fatalf("active tab = %q, want a", got)
639 }
640 }
641
642 func TestCloseActiveTabChoosesNeighborByOrder(t *testing.T) {
643 app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
644 if err := app.CloseTab("b"); err != nil {
645 t.Fatalf("CloseTab(b): %v", err)
646 }
647 assertTabIDs(t, app.ListTabs(), "a", "c")
648 if got := app.activeTabID; got != "c" {
649 t.Fatalf("active tab after closing middle = %q, want c", got)
650 }
651
652 if err := app.CloseTab("c"); err != nil {
653 t.Fatalf("CloseTab(c): %v", err)
654 }
655 assertTabIDs(t, app.ListTabs(), "a")
656 if got := app.activeTabID; got != "a" {
657 t.Fatalf("active tab after closing last = %q, want a", got)
658 }
659 }
660
661 func TestCloseActiveTabDoesNotResetSurvivorPlannerSession(t *testing.T) {
662 isolateDesktopUserDirs(t)
663 ctrlClosed := control.New(control.Options{Label: "closed"})
664 ctrlSurvivor := &resetCountingSession{SessionAPI: control.New(control.Options{Label: "survivor"})}
665 defer ctrlSurvivor.Close()
666 app := testAppWithOrderedTabs(t, "a", "a", "b")
667 app.tabs["a"].Ctrl = ctrlClosed
668 app.tabs["b"].Ctrl = ctrlSurvivor
669
670 if err := app.CloseTab("a"); err != nil {
671 t.Fatalf("CloseTab: %v", err)
672 }
673 if ctrlSurvivor.resets != 0 {
674 t.Fatalf("survivor planner resets = %d, want 0", ctrlSurvivor.resets)
675 }
676 }
677
678 func TestCloseRunningTabDetachesSessionRuntime(t *testing.T) {
679 isolateDesktopUserDirs(t)
680 dir := config.SessionDir()
681 if err := os.MkdirAll(dir, 0o755); err != nil {
682 t.Fatalf("mkdir sessions: %v", err)
683 }
684 path := filepath.Join(dir, "running.jsonl")
685 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
686 ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "running", Sink: event.Discard})
687 tab := &WorkspaceTab{
688 ID: "running",
689 Scope: "global",
690 WorkspaceRoot: globalTabWorkspaceRoot(),
691 SessionPath: path,
692 Ctrl: ctrl,
693 Ready: true,
694 sink: &tabEventSink{tabID: "running"},
695 disabledMCP: map[string]ServerView{},
696 }
697 app := &App{
698 tabs: map[string]*WorkspaceTab{
699 "running": tab,
700 "other": {ID: "other", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}},
701 },
702 tabOrder: []string{"running", "other"},
703 activeTabID: "running",
704 detachedSessions: map[string]*WorkspaceTab{},
705 }
706
707 ctrl.Submit("block")
708 <-runner.started
709 if err := app.CloseTab("running"); err != nil {
710 t.Fatalf("CloseTab(running): %v", err)
711 }
712 if !ctrl.Running() {
713 t.Fatal("closing a visible tab cancelled its running controller")
714 }
715 if _, ok := app.detachedSessions[sessionRuntimeKey(path)]; !ok {
716 t.Fatalf("detached runtime missing for %q", path)
717 }
718 if tab.sink.ctx != nil {
719 t.Fatal("detached tab sink should stop emitting to the closed view")
720 }
721
722 close(runner.release)
723 waitNotRunning(t, ctrl)
724 ctrl.Close()
725 }
726
727 func TestReorderTabsRejectsInvalidOrder(t *testing.T) {
728 app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
729 for name, order := range map[string][]string{
730 "missing": {"a", "b"},
731 "unknown": {"a", "b", "missing"},
732 "duplicate": {"a", "b", "b"},
733 } {
734 t.Run(name, func(t *testing.T) {
735 if err := app.ReorderTabs(order); err == nil {
736 t.Fatalf("ReorderTabs(%v) succeeded, want error", order)
737 }
738 })
739 }
740 assertTabIDs(t, app.ListTabs(), "a", "b", "c")
741 }
742
743 func TestNewUniqueTabIDLockedUsesFreshRandomID(t *testing.T) {
744 app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
745
746 app.mu.Lock()
747 got := app.newUniqueTabIDLocked()
748 app.mu.Unlock()
749 if _, exists := app.tabs[got]; exists {
750 t.Fatalf("newUniqueTabIDLocked returned existing id %q", got)
751 }
752 if !strings.HasPrefix(got, "tab_") {
753 t.Fatalf("tab id = %q, want tab_ prefix", got)
754 }
755 if len(got) != len("tab_")+32 {
756 t.Fatalf("tab id = %q, length %d, want 36", got, len(got))
757 }
758 }
759
760 func TestRestoredTabIDLockedReplacesEmptyAndDuplicateIDs(t *testing.T) {
761 app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
762
763 app.mu.Lock()
764 kept := app.restoredTabIDLocked("d")
765 duplicate := app.restoredTabIDLocked("a")
766 empty := app.restoredTabIDLocked(" ")
767 app.mu.Unlock()
768
769 if kept != "d" {
770 t.Fatalf("restored unique id = %q, want d", kept)
771 }
772 for name, got := range map[string]string{"duplicate": duplicate, "empty": empty} {
773 if _, exists := app.tabs[got]; exists {
774 t.Fatalf("%s restored id %q already exists", name, got)
775 }
776 if !strings.HasPrefix(got, "tab_") {
777 t.Fatalf("%s restored id = %q, want tab_ prefix", name, got)
778 }
779 }
780 }
781
781 lines GO