detached 2112549

fix(tabs): serialize tab persistence updates

commit
21125498a4ffa11b33cfd29b42d2621947d89637
author
brice · · 2026-07-04 02:02 UTC
parent
81e6be1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +17 -28
M internal/usecase/daemon/daemon.go +2 -13
@@ -30,7 +30,6 @@ import (
3030 "errors"
3131 "log/slog"
3232 "os"
33- "slices"
3433 "sort"
3534 "sync"
3635 "time"
@@ -125,14 +124,6 @@ type stoppedSession struct {
125124 purging bool
126125 }
127126
128-func (s stoppedSession) same(other stoppedSession) bool {
129- return s.name == other.name &&
130- s.cwd == other.cwd &&
131- s.createdAt == other.createdAt &&
132- s.purging == other.purging &&
133- slices.Equal(s.tabNames, other.tabNames)
134-}
135-
136127 type Option func(*Daemon)
137128
138129 // WithShell overrides the command (and its args) each session spawns. The
@@ -373,16 +364,14 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
373364 d.mu.Lock()
374365 target := d.findByNameLocked(k.Name)
375366 if target == nil {
376- if stopped, ok := d.stopped[k.Name]; ok {
367+ if _, ok := d.stopped[k.Name]; ok {
377368 if err := d.persist.Delete(k.Name); err != nil {
378369 d.mu.Unlock()
379370 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)
380371 _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))
381372 return
382373 }
383- if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) {
384- delete(d.stopped, k.Name)
385- }
374+ delete(d.stopped, k.Name)
386375 d.mu.Unlock()
387376 return
388377 }
@@ -30,7 +30,6 @@ import (
30 "errors"30 "errors"
31 "log/slog"31 "log/slog"
32 "os"32 "os"
33- "slices"
34 "sort"33 "sort"
35 "sync"34 "sync"
36 "time"35 "time"
@@ -125,14 +124,6 @@ type stoppedSession struct {
125 purging bool124 purging bool
126 }125 }
127 126
128-func (s stoppedSession) same(other stoppedSession) bool {
129- return s.name == other.name &&
130- s.cwd == other.cwd &&
131- s.createdAt == other.createdAt &&
132- s.purging == other.purging &&
133- slices.Equal(s.tabNames, other.tabNames)
134-}
135-
136 type Option func(*Daemon)127 type Option func(*Daemon)
137 128
138 // WithShell overrides the command (and its args) each session spawns. The129 // WithShell overrides the command (and its args) each session spawns. The
@@ -373,16 +364,14 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
373 d.mu.Lock()364 d.mu.Lock()
374 target := d.findByNameLocked(k.Name)365 target := d.findByNameLocked(k.Name)
375 if target == nil {366 if target == nil {
376- if stopped, ok := d.stopped[k.Name]; ok {367+ if _, ok := d.stopped[k.Name]; ok {
377 if err := d.persist.Delete(k.Name); err != nil {368 if err := d.persist.Delete(k.Name); err != nil {
378 d.mu.Unlock()369 d.mu.Unlock()
379 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)370 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)
380 _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))371 _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))
381 return372 return
382 }373 }
383- if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) {374+ delete(d.stopped, k.Name)
384- delete(d.stopped, k.Name)
385- }
386 d.mu.Unlock()375 d.mu.Unlock()
387 return376 return
388 }377 }
M internal/usecase/daemon/picker.go +1 -3
@@ -449,9 +449,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) {
449449 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
450450 return
451451 }
452- if cur, ok := d.stopped[target.Name]; ok && cur.same(stopped) {
453- delete(d.stopped, target.Name)
454- }
452+ delete(d.stopped, target.Name)
455453 }
456454 d.mu.Unlock()
457455 return
@@ -449,9 +449,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) {
449 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)449 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
450 return450 return
451 }451 }
452- if cur, ok := d.stopped[target.Name]; ok && cur.same(stopped) {452+ delete(d.stopped, target.Name)
453- delete(d.stopped, target.Name)
454- }
455 }453 }
456 d.mu.Unlock()454 d.mu.Unlock()
457 return455 return
M internal/usecase/daemon/session.go +12 -10
@@ -28,6 +28,7 @@ import (
2828 "context"
2929 "errors"
3030 "fmt"
31+ "slices"
3132 "strconv"
3233 "strings"
3334 "sync"
@@ -358,14 +359,7 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
358359 return errors.New("daemon: session closed")
359360 }
360361 sess.mu.Lock()
361- found := false
362- for _, candidate := range sess.tabs {
363- if candidate == tb {
364- found = true
365- break
366- }
367- }
368- if !found {
362+ if !slices.Contains(sess.tabs, tb) {
369363 sess.mu.Unlock()
370364 d.mu.Unlock()
371365 return errors.New("tab not found")
@@ -410,6 +404,11 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
410404 }
411405
412406 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
407+ d.mu.Lock()
408+ if d.sessions[sess.id] != sess {
409+ d.mu.Unlock()
410+ return
411+ }
413412 sess.mu.Lock()
414413 idx := -1
415414 for i, w := range sess.tabs {
@@ -420,10 +419,12 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
420419 }
421420 if idx == -1 {
422421 sess.mu.Unlock()
422+ d.mu.Unlock()
423423 return
424424 }
425425 if len(sess.tabs) == 1 {
426426 sess.mu.Unlock()
427+ d.mu.Unlock()
427428 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
428429 return
429430 }
@@ -437,13 +438,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
437438 ac := sess.client
438439 ephemeral := sess.ephemeral
439440 record := sess.persistRecordLocked(time.Now().UnixNano())
440- sess.mu.Unlock()
441-
442441 if !ephemeral {
443442 if err := d.persist.Save(record); err != nil {
444443 d.log.Warn("persisting closed tab failed", "err", err, "session", record.Name)
445444 }
446445 }
446+ sess.mu.Unlock()
447+ d.mu.Unlock()
448+
447449 d.clearDestroyedTabPreview(tb)
448450 if tb.cancel != nil {
449451 tb.cancel()
@@ -28,6 +28,7 @@ import (
28 "context"28 "context"
29 "errors"29 "errors"
30 "fmt"30 "fmt"
31+ "slices"
31 "strconv"32 "strconv"
32 "strings"33 "strings"
33 "sync"34 "sync"
@@ -358,14 +359,7 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
358 return errors.New("daemon: session closed")359 return errors.New("daemon: session closed")
359 }360 }
360 sess.mu.Lock()361 sess.mu.Lock()
361- found := false362+ if !slices.Contains(sess.tabs, tb) {
362- for _, candidate := range sess.tabs {
363- if candidate == tb {
364- found = true
365- break
366- }
367- }
368- if !found {
369 sess.mu.Unlock()363 sess.mu.Unlock()
370 d.mu.Unlock()364 d.mu.Unlock()
371 return errors.New("tab not found")365 return errors.New("tab not found")
@@ -410,6 +404,11 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
410 }404 }
411 405
412 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {406 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
407+ d.mu.Lock()
408+ if d.sessions[sess.id] != sess {
409+ d.mu.Unlock()
410+ return
411+ }
413 sess.mu.Lock()412 sess.mu.Lock()
414 idx := -1413 idx := -1
415 for i, w := range sess.tabs {414 for i, w := range sess.tabs {
@@ -420,10 +419,12 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
420 }419 }
421 if idx == -1 {420 if idx == -1 {
422 sess.mu.Unlock()421 sess.mu.Unlock()
422+ d.mu.Unlock()
423 return423 return
424 }424 }
425 if len(sess.tabs) == 1 {425 if len(sess.tabs) == 1 {
426 sess.mu.Unlock()426 sess.mu.Unlock()
427+ d.mu.Unlock()
427 _ = d.killSession(sess, ports.ReasonSessionKilled, false)428 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
428 return429 return
429 }430 }
@@ -437,13 +438,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
437 ac := sess.client438 ac := sess.client
438 ephemeral := sess.ephemeral439 ephemeral := sess.ephemeral
439 record := sess.persistRecordLocked(time.Now().UnixNano())440 record := sess.persistRecordLocked(time.Now().UnixNano())
440- sess.mu.Unlock()
441-
442 if !ephemeral {441 if !ephemeral {
443 if err := d.persist.Save(record); err != nil {442 if err := d.persist.Save(record); err != nil {
444 d.log.Warn("persisting closed tab failed", "err", err, "session", record.Name)443 d.log.Warn("persisting closed tab failed", "err", err, "session", record.Name)
445 }444 }
446 }445 }
446+ sess.mu.Unlock()
447+ d.mu.Unlock()
448+
447 d.clearDestroyedTabPreview(tb)449 d.clearDestroyedTabPreview(tb)
448 if tb.cancel != nil {450 if tb.cancel != nil {
449 tb.cancel()451 tb.cancel()
M internal/usecase/daemon/session_test.go +2 -2
@@ -650,13 +650,13 @@ func (s *mockStoreState) has(name string) bool {
650650 func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
651651 store, _ := newMockStore(t)
652652 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
653- require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8}))
653+ require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
654654
655655 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
656656 d.mu.Lock()
657657 stopped := d.stopped["work"]
658658 d.mu.Unlock()
659- require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7}, stopped)
659+ require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}, stopped)
660660 }
661661
662662 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
@@ -650,13 +650,13 @@ func (s *mockStoreState) has(name string) bool {
650 func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {650 func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
651 store, _ := newMockStore(t)651 store, _ := newMockStore(t)
652 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))652 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
653- require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8}))653+ require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
654 654
655 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))655 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
656 d.mu.Lock()656 d.mu.Lock()
657 stopped := d.stopped["work"]657 stopped := d.stopped["work"]
658 d.mu.Unlock()658 d.mu.Unlock()
659- require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7}, stopped)659+ require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}, stopped)
660 }660 }
661 661
662 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {662 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close