detached 6ff07bd

feat(persistence): persist named sessions

commit
6ff07bde70e6d759783d1443252b3c26a0091eac
author
brice · · 2026-07-03 20:20 UTC
committer
brice · · 2026-07-03 20:32 UTC
parent
7cfdd26
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
24 files · +2145 -66
M internal/app/logging.go +3 -15
@@ -6,26 +6,14 @@ import (
66 "os"
77 "path/filepath"
88 "strings"
9+
10+ "github.com/bnema/vev/internal/platform"
911 )
1012
1113 // logFileName is the fixed name of vev's shared log file, used by both the
1214 // daemon and the raw-attached client (which must never log to the console).
1315 const logFileName = "vev.log"
1416
15-// stateDir returns the directory vev writes its log to: $XDG_STATE_HOME/vev
16-// if set, else ~/.local/state/vev.
17-func stateDir() string {
18- if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
19- return filepath.Join(xdg, "vev")
20- }
21- home, err := os.UserHomeDir()
22- if err != nil || home == "" {
23- // Last resort: a per-uid temp path so logging never aborts startup.
24- return filepath.Join(os.TempDir(), fmt.Sprintf("vev-state-%d", os.Getuid()))
25- }
26- return filepath.Join(home, ".local", "state", "vev")
27-}
28-
2917 // parseLogLevel maps VEV_LOG to a slog level, defaulting to info.
3018 func parseLogLevel(s string) slog.Level {
3119 switch strings.ToLower(strings.TrimSpace(s)) {
@@ -48,7 +36,7 @@ func parseLogLevel(s string) slog.Level {
4836 //
4937 // No log rotation is performed for the MVP (future work).
5038 func setupLogging() (*os.File, error) {
51- dir := stateDir()
39+ dir := platform.StateDir()
5240 if err := os.MkdirAll(dir, 0o700); err != nil {
5341 return nil, fmt.Errorf("vev: creating state dir: %w", err)
5442 }
@@ -6,26 +6,14 @@ import (
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8 "strings"8 "strings"
9+
10+ "github.com/bnema/vev/internal/platform"
9 )11 )
10 12
11 // logFileName is the fixed name of vev's shared log file, used by both the13 // logFileName is the fixed name of vev's shared log file, used by both the
12 // daemon and the raw-attached client (which must never log to the console).14 // daemon and the raw-attached client (which must never log to the console).
13 const logFileName = "vev.log"15 const logFileName = "vev.log"
14 16
15-// stateDir returns the directory vev writes its log to: $XDG_STATE_HOME/vev
16-// if set, else ~/.local/state/vev.
17-func stateDir() string {
18- if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
19- return filepath.Join(xdg, "vev")
20- }
21- home, err := os.UserHomeDir()
22- if err != nil || home == "" {
23- // Last resort: a per-uid temp path so logging never aborts startup.
24- return filepath.Join(os.TempDir(), fmt.Sprintf("vev-state-%d", os.Getuid()))
25- }
26- return filepath.Join(home, ".local", "state", "vev")
27-}
28-
29 // parseLogLevel maps VEV_LOG to a slog level, defaulting to info.17 // parseLogLevel maps VEV_LOG to a slog level, defaulting to info.
30 func parseLogLevel(s string) slog.Level {18 func parseLogLevel(s string) slog.Level {
31 switch strings.ToLower(strings.TrimSpace(s)) {19 switch strings.ToLower(strings.TrimSpace(s)) {
@@ -48,7 +36,7 @@ func parseLogLevel(s string) slog.Level {
48 //36 //
49 // No log rotation is performed for the MVP (future work).37 // No log rotation is performed for the MVP (future work).
50 func setupLogging() (*os.File, error) {38 func setupLogging() (*os.File, error) {
51- dir := stateDir()39+ dir := platform.StateDir()
52 if err := os.MkdirAll(dir, 0o700); err != nil {40 if err := os.MkdirAll(dir, 0o700); err != nil {
53 return nil, fmt.Errorf("vev: creating state dir: %w", err)41 return nil, fmt.Errorf("vev: creating state dir: %w", err)
54 }42 }
M internal/app/run.go +49 -7
@@ -14,6 +14,7 @@ import (
1414 _ "net/http/pprof"
1515 "os"
1616 "os/signal"
17+ "path/filepath"
1718 "syscall"
1819 "text/tabwriter"
1920 "time"
@@ -24,10 +25,13 @@ import (
2425 "github.com/bnema/vev/internal/adapters/sshstdio"
2526 "github.com/bnema/vev/internal/adapters/term"
2627 "github.com/bnema/vev/internal/domain"
28+ "github.com/bnema/vev/internal/persist"
29+ "github.com/bnema/vev/internal/platform"
2730 "github.com/bnema/vev/internal/ports"
2831 "github.com/bnema/vev/internal/usecase/client"
2932 "github.com/bnema/vev/internal/usecase/confirm"
3033 "github.com/bnema/vev/internal/usecase/daemon"
34+ "github.com/bnema/vev/pkg/kv"
3135 )
3236
3337 // cmdKind identifies which sub-command the CLI parsed.
@@ -223,7 +227,13 @@ func runDaemon() error {
223227 }
224228
225229 slog.Info("daemon starting", "socket", ln.Addr())
226- d := daemon.New(pty.NewFactory(), clock.New(), slog.Default())
230+ daemonOpts := []daemon.Option(nil)
231+ if store, err := kv.Open(filepath.Join(platform.StateDir(), "sessions.kv")); err != nil {
232+ slog.Warn("opening session store failed; persistence disabled", "err", err)
233+ } else {
234+ daemonOpts = append(daemonOpts, daemon.WithStore(store))
235+ }
236+ d := daemon.New(pty.NewFactory(), clock.New(), slog.Default(), daemonOpts...)
227237 if err := d.Serve(ctx, ln); err != nil {
228238 slog.Error("daemon exited", "err", err)
229239 return err
@@ -482,12 +492,20 @@ func proxyTransports(ctx context.Context, a, b ports.Transport) error {
482492 }
483493 }
484494
485-// runList prints the daemon's session listing. With no daemon running there
486-// are, by definition, no sessions.
495+// runList prints the daemon's session listing. With no daemon running, it
496+// falls back to the persisted stopped-session records.
487497 func runList(_ context.Context) error {
488498 transport, err := realDial(ipc.SocketDir())
489499 if err != nil {
490- fmt.Println("no sessions")
500+ records, loadErr := persist.LoadReadOnly(platform.StateDir())
501+ if loadErr != nil {
502+ return fmt.Errorf("vev: reading stored sessions: %w", loadErr)
503+ }
504+ infos := make([]ports.SessionInfo, 0, len(records))
505+ for _, r := range records {
506+ infos = append(infos, ports.SessionInfo{Name: r.Name, Stopped: true})
507+ }
508+ printSessions(os.Stdout, infos)
491509 return nil
492510 }
493511 defer func() { _ = transport.Close() }()
@@ -521,13 +539,18 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
521539 return
522540 }
523541 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
524- _, _ = fmt.Fprintln(tw, "NAME\tTABS\tATTACHED")
542+ _, _ = fmt.Fprintln(tw, "NAME\tSTATE\tTABS\tATTACHED")
525543 for _, s := range sessions {
544+ state := "running"
545+ tabs := fmt.Sprintf("%d", s.Tabs)
526546 attached := "no"
527- if s.Attached {
547+ if s.Stopped {
548+ state = "stopped"
549+ tabs = "-"
550+ } else if s.Attached {
528551 attached = "yes"
529552 }
530- _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Tabs, attached)
553+ _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached)
531554 }
532555 _ = tw.Flush()
533556 }
@@ -536,6 +559,25 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
536559 func runKill(_ context.Context, name string, all, daemon bool) error {
537560 transport, err := realDial(ipc.SocketDir())
538561 if err != nil {
562+ if name != "" && !all && !daemon {
563+ store, openErr := kv.Open(filepath.Join(platform.StateDir(), "sessions.kv"))
564+ if openErr != nil {
565+ return fmt.Errorf("vev: opening stored sessions: %w", openErr)
566+ }
567+ if _, ok := store.Get([]byte(name)); !ok {
568+ _ = store.Close()
569+ return fmt.Errorf("vev: no such session: %s", name)
570+ }
571+ if deleteErr := store.Delete([]byte(name)); deleteErr != nil {
572+ _ = store.Close()
573+ return fmt.Errorf("vev: deleting stored session: %w", deleteErr)
574+ }
575+ if closeErr := store.Close(); closeErr != nil {
576+ return fmt.Errorf("vev: closing stored sessions: %w", closeErr)
577+ }
578+ printKillSuccess(name, all, daemon)
579+ return nil
580+ }
539581 return fmt.Errorf("vev: no daemon running")
540582 }
541583 defer func() { _ = transport.Close() }()
@@ -14,6 +14,7 @@ import (
14 _ "net/http/pprof"14 _ "net/http/pprof"
15 "os"15 "os"
16 "os/signal"16 "os/signal"
17+ "path/filepath"
17 "syscall"18 "syscall"
18 "text/tabwriter"19 "text/tabwriter"
19 "time"20 "time"
@@ -24,10 +25,13 @@ import (
24 "github.com/bnema/vev/internal/adapters/sshstdio"25 "github.com/bnema/vev/internal/adapters/sshstdio"
25 "github.com/bnema/vev/internal/adapters/term"26 "github.com/bnema/vev/internal/adapters/term"
26 "github.com/bnema/vev/internal/domain"27 "github.com/bnema/vev/internal/domain"
28+ "github.com/bnema/vev/internal/persist"
29+ "github.com/bnema/vev/internal/platform"
27 "github.com/bnema/vev/internal/ports"30 "github.com/bnema/vev/internal/ports"
28 "github.com/bnema/vev/internal/usecase/client"31 "github.com/bnema/vev/internal/usecase/client"
29 "github.com/bnema/vev/internal/usecase/confirm"32 "github.com/bnema/vev/internal/usecase/confirm"
30 "github.com/bnema/vev/internal/usecase/daemon"33 "github.com/bnema/vev/internal/usecase/daemon"
34+ "github.com/bnema/vev/pkg/kv"
31 )35 )
32 36
33 // cmdKind identifies which sub-command the CLI parsed.37 // cmdKind identifies which sub-command the CLI parsed.
@@ -223,7 +227,13 @@ func runDaemon() error {
223 }227 }
224 228
225 slog.Info("daemon starting", "socket", ln.Addr())229 slog.Info("daemon starting", "socket", ln.Addr())
226- d := daemon.New(pty.NewFactory(), clock.New(), slog.Default())230+ daemonOpts := []daemon.Option(nil)
231+ if store, err := kv.Open(filepath.Join(platform.StateDir(), "sessions.kv")); err != nil {
232+ slog.Warn("opening session store failed; persistence disabled", "err", err)
233+ } else {
234+ daemonOpts = append(daemonOpts, daemon.WithStore(store))
235+ }
236+ d := daemon.New(pty.NewFactory(), clock.New(), slog.Default(), daemonOpts...)
227 if err := d.Serve(ctx, ln); err != nil {237 if err := d.Serve(ctx, ln); err != nil {
228 slog.Error("daemon exited", "err", err)238 slog.Error("daemon exited", "err", err)
229 return err239 return err
@@ -482,12 +492,20 @@ func proxyTransports(ctx context.Context, a, b ports.Transport) error {
482 }492 }
483 }493 }
484 494
485-// runList prints the daemon's session listing. With no daemon running there495+// runList prints the daemon's session listing. With no daemon running, it
486-// are, by definition, no sessions.496+// falls back to the persisted stopped-session records.
487 func runList(_ context.Context) error {497 func runList(_ context.Context) error {
488 transport, err := realDial(ipc.SocketDir())498 transport, err := realDial(ipc.SocketDir())
489 if err != nil {499 if err != nil {
490- fmt.Println("no sessions")500+ records, loadErr := persist.LoadReadOnly(platform.StateDir())
501+ if loadErr != nil {
502+ return fmt.Errorf("vev: reading stored sessions: %w", loadErr)
503+ }
504+ infos := make([]ports.SessionInfo, 0, len(records))
505+ for _, r := range records {
506+ infos = append(infos, ports.SessionInfo{Name: r.Name, Stopped: true})
507+ }
508+ printSessions(os.Stdout, infos)
491 return nil509 return nil
492 }510 }
493 defer func() { _ = transport.Close() }()511 defer func() { _ = transport.Close() }()
@@ -521,13 +539,18 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
521 return539 return
522 }540 }
523 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)541 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
524- _, _ = fmt.Fprintln(tw, "NAME\tTABS\tATTACHED")542+ _, _ = fmt.Fprintln(tw, "NAME\tSTATE\tTABS\tATTACHED")
525 for _, s := range sessions {543 for _, s := range sessions {
544+ state := "running"
545+ tabs := fmt.Sprintf("%d", s.Tabs)
526 attached := "no"546 attached := "no"
527- if s.Attached {547+ if s.Stopped {
548+ state = "stopped"
549+ tabs = "-"
550+ } else if s.Attached {
528 attached = "yes"551 attached = "yes"
529 }552 }
530- _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Tabs, attached)553+ _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached)
531 }554 }
532 _ = tw.Flush()555 _ = tw.Flush()
533 }556 }
@@ -536,6 +559,25 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
536 func runKill(_ context.Context, name string, all, daemon bool) error {559 func runKill(_ context.Context, name string, all, daemon bool) error {
537 transport, err := realDial(ipc.SocketDir())560 transport, err := realDial(ipc.SocketDir())
538 if err != nil {561 if err != nil {
562+ if name != "" && !all && !daemon {
563+ store, openErr := kv.Open(filepath.Join(platform.StateDir(), "sessions.kv"))
564+ if openErr != nil {
565+ return fmt.Errorf("vev: opening stored sessions: %w", openErr)
566+ }
567+ if _, ok := store.Get([]byte(name)); !ok {
568+ _ = store.Close()
569+ return fmt.Errorf("vev: no such session: %s", name)
570+ }
571+ if deleteErr := store.Delete([]byte(name)); deleteErr != nil {
572+ _ = store.Close()
573+ return fmt.Errorf("vev: deleting stored session: %w", deleteErr)
574+ }
575+ if closeErr := store.Close(); closeErr != nil {
576+ return fmt.Errorf("vev: closing stored sessions: %w", closeErr)
577+ }
578+ printKillSuccess(name, all, daemon)
579+ return nil
580+ }
539 return fmt.Errorf("vev: no daemon running")581 return fmt.Errorf("vev: no daemon running")
540 }582 }
541 defer func() { _ = transport.Close() }()583 defer func() { _ = transport.Close() }()
M internal/app/run_test.go +109 -0
@@ -4,9 +4,14 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "io"
8+ "os"
9+ "path/filepath"
710 "strings"
811 "testing"
12+ "time"
913
14+ "github.com/bnema/vev/internal/persist"
1015 "github.com/bnema/vev/internal/ports"
1116 "github.com/bnema/vev/internal/usecase/client"
1217 "github.com/bnema/vev/internal/usecase/confirm"
@@ -96,6 +101,110 @@ func TestParseArgs(t *testing.T) {
96101 }
97102 }
98103
104+func TestPrintSessionsShowsStoppedState(t *testing.T) {
105+ var out bytes.Buffer
106+ printSessions(&out, []ports.SessionInfo{
107+ {Name: "main", Tabs: 2, Attached: true},
108+ {Name: "old", Stopped: true},
109+ })
110+ got := out.String()
111+ for _, want := range []string{"NAME", "STATE", "main", "running", "2", "yes", "old", "stopped", "-"} {
112+ if !strings.Contains(got, want) {
113+ t.Fatalf("printSessions output %q missing %q", got, want)
114+ }
115+ }
116+}
117+
118+func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
119+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
120+ t.Setenv("XDG_STATE_HOME", stateRoot)
121+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
122+
123+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
124+ if err != nil {
125+ t.Fatalf("persist.Open error = %v", err)
126+ }
127+ now := time.Now().UnixNano()
128+ if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
129+ t.Fatalf("Save error = %v", err)
130+ }
131+ if err := p.Close(); err != nil {
132+ t.Fatalf("Close error = %v", err)
133+ }
134+
135+ got := captureStdout(t, func() {
136+ if err := runList(context.Background()); err != nil {
137+ t.Fatalf("runList error = %v", err)
138+ }
139+ })
140+ for _, want := range []string{"stored", "stopped", "-"} {
141+ if !strings.Contains(got, want) {
142+ t.Fatalf("runList output %q missing %q", got, want)
143+ }
144+ }
145+}
146+
147+func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
148+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
149+ t.Setenv("XDG_STATE_HOME", stateRoot)
150+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
151+
152+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
153+ if err != nil {
154+ t.Fatalf("persist.Open error = %v", err)
155+ }
156+ now := time.Now().UnixNano()
157+ if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
158+ t.Fatalf("Save error = %v", err)
159+ }
160+ if err := p.Close(); err != nil {
161+ t.Fatalf("Close error = %v", err)
162+ }
163+
164+ got := captureStdout(t, func() {
165+ if err := runKill(context.Background(), "stored", false, false); err != nil {
166+ t.Fatalf("runKill error = %v", err)
167+ }
168+ })
169+ if !strings.Contains(got, "killed stored") {
170+ t.Fatalf("runKill output = %q, want success", got)
171+ }
172+ records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
173+ if err != nil {
174+ t.Fatalf("LoadReadOnly error = %v", err)
175+ }
176+ if len(records) != 0 {
177+ t.Fatalf("records after kill = %#v, want none", records)
178+ }
179+}
180+
181+func captureStdout(t *testing.T, fn func()) string {
182+ t.Helper()
183+ old := os.Stdout
184+ r, w, err := os.Pipe()
185+ if err != nil {
186+ t.Fatalf("os.Pipe error = %v", err)
187+ }
188+ os.Stdout = w
189+ defer func() { os.Stdout = old }()
190+ defer func() { _ = r.Close() }()
191+ closed := false
192+ defer func() {
193+ if !closed {
194+ _ = w.Close()
195+ }
196+ }()
197+
198+ fn()
199+ _ = w.Close()
200+ closed = true
201+ out, err := io.ReadAll(r)
202+ if err != nil {
203+ t.Fatalf("ReadAll stdout error = %v", err)
204+ }
205+ return string(out)
206+}
207+
99208 func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) {
100209 var prompts bytes.Buffer
101210 answers := strings.NewReader("y\n")
@@ -4,9 +4,14 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "errors"6 "errors"
7+ "io"
8+ "os"
9+ "path/filepath"
7 "strings"10 "strings"
8 "testing"11 "testing"
12+ "time"
9 13
14+ "github.com/bnema/vev/internal/persist"
10 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/client"16 "github.com/bnema/vev/internal/usecase/client"
12 "github.com/bnema/vev/internal/usecase/confirm"17 "github.com/bnema/vev/internal/usecase/confirm"
@@ -96,6 +101,110 @@ func TestParseArgs(t *testing.T) {
96 }101 }
97 }102 }
98 103
104+func TestPrintSessionsShowsStoppedState(t *testing.T) {
105+ var out bytes.Buffer
106+ printSessions(&out, []ports.SessionInfo{
107+ {Name: "main", Tabs: 2, Attached: true},
108+ {Name: "old", Stopped: true},
109+ })
110+ got := out.String()
111+ for _, want := range []string{"NAME", "STATE", "main", "running", "2", "yes", "old", "stopped", "-"} {
112+ if !strings.Contains(got, want) {
113+ t.Fatalf("printSessions output %q missing %q", got, want)
114+ }
115+ }
116+}
117+
118+func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
119+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
120+ t.Setenv("XDG_STATE_HOME", stateRoot)
121+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
122+
123+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
124+ if err != nil {
125+ t.Fatalf("persist.Open error = %v", err)
126+ }
127+ now := time.Now().UnixNano()
128+ if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
129+ t.Fatalf("Save error = %v", err)
130+ }
131+ if err := p.Close(); err != nil {
132+ t.Fatalf("Close error = %v", err)
133+ }
134+
135+ got := captureStdout(t, func() {
136+ if err := runList(context.Background()); err != nil {
137+ t.Fatalf("runList error = %v", err)
138+ }
139+ })
140+ for _, want := range []string{"stored", "stopped", "-"} {
141+ if !strings.Contains(got, want) {
142+ t.Fatalf("runList output %q missing %q", got, want)
143+ }
144+ }
145+}
146+
147+func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
148+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
149+ t.Setenv("XDG_STATE_HOME", stateRoot)
150+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
151+
152+ p, err := persist.Open(filepath.Join(stateRoot, "vev"))
153+ if err != nil {
154+ t.Fatalf("persist.Open error = %v", err)
155+ }
156+ now := time.Now().UnixNano()
157+ if err := p.Save(persist.Record{Name: "stored", Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
158+ t.Fatalf("Save error = %v", err)
159+ }
160+ if err := p.Close(); err != nil {
161+ t.Fatalf("Close error = %v", err)
162+ }
163+
164+ got := captureStdout(t, func() {
165+ if err := runKill(context.Background(), "stored", false, false); err != nil {
166+ t.Fatalf("runKill error = %v", err)
167+ }
168+ })
169+ if !strings.Contains(got, "killed stored") {
170+ t.Fatalf("runKill output = %q, want success", got)
171+ }
172+ records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
173+ if err != nil {
174+ t.Fatalf("LoadReadOnly error = %v", err)
175+ }
176+ if len(records) != 0 {
177+ t.Fatalf("records after kill = %#v, want none", records)
178+ }
179+}
180+
181+func captureStdout(t *testing.T, fn func()) string {
182+ t.Helper()
183+ old := os.Stdout
184+ r, w, err := os.Pipe()
185+ if err != nil {
186+ t.Fatalf("os.Pipe error = %v", err)
187+ }
188+ os.Stdout = w
189+ defer func() { os.Stdout = old }()
190+ defer func() { _ = r.Close() }()
191+ closed := false
192+ defer func() {
193+ if !closed {
194+ _ = w.Close()
195+ }
196+ }()
197+
198+ fn()
199+ _ = w.Close()
200+ closed = true
201+ out, err := io.ReadAll(r)
202+ if err != nil {
203+ t.Fatalf("ReadAll stdout error = %v", err)
204+ }
205+ return string(out)
206+}
207+
99 func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) {208 func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) {
100 var prompts bytes.Buffer209 var prompts bytes.Buffer
101 answers := strings.NewReader("y\n")210 answers := strings.NewReader("y\n")
A internal/persist/persist.go +131 -0
@@ -0,0 +1,131 @@
1+package persist
2+
3+import (
4+ "path/filepath"
5+ "sort"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/pkg/kv"
9+)
10+
11+const filename = "sessions.kv"
12+
13+// Persister stores named session metadata. A nil Store makes all mutating
14+// operations no-ops, so callers can keep persistence optional.
15+type Persister struct {
16+ store ports.Store
17+}
18+
19+// Open opens the session persister under dir.
20+func Open(dir string) (*Persister, error) {
21+ store, err := kv.Open(filepath.Join(dir, filename))
22+ if err != nil {
23+ return nil, err
24+ }
25+ return New(store), nil
26+}
27+
28+// New wraps store in a Persister. Passing nil creates a no-op persister.
29+func New(store ports.Store) *Persister {
30+ return &Persister{store: store}
31+}
32+
33+// LoadReadOnly replays the session store under dir without mutating it.
34+func LoadReadOnly(dir string) ([]Record, error) {
35+ data, err := kv.Replay(filepath.Join(dir, filename))
36+ if err != nil {
37+ return nil, err
38+ }
39+ return decodeAll(data)
40+}
41+
42+// Save persists a structural session record and syncs it to disk.
43+func (p *Persister) Save(r Record) error {
44+ if p == nil || p.store == nil {
45+ return nil
46+ }
47+ value, err := encodeRecordValue(r)
48+ if err != nil {
49+ return err
50+ }
51+ if err := p.store.Set([]byte(r.Name), value); err != nil {
52+ return err
53+ }
54+ return p.store.Sync()
55+}
56+
57+// Touch updates hot-path session cwd metadata without forcing an fsync.
58+func (p *Persister) Touch(name, cwd string, at int64) error {
59+ if p == nil || p.store == nil {
60+ return nil
61+ }
62+ if name == "" {
63+ return errEmptyName
64+ }
65+
66+ createdAt := at
67+ p.store.Range(func(k, v []byte) bool {
68+ if string(k) != name {
69+ return true
70+ }
71+ r, err := decodeRecordValue(name, v)
72+ if err == nil {
73+ createdAt = r.CreatedAt
74+ }
75+ return false
76+ })
77+
78+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at})
79+ if err != nil {
80+ return err
81+ }
82+ return p.store.Set([]byte(name), value)
83+}
84+
85+// Delete removes a persisted session record and syncs the structural change.
86+func (p *Persister) Delete(name string) error {
87+ if p == nil || p.store == nil {
88+ return nil
89+ }
90+ if name == "" {
91+ return errEmptyName
92+ }
93+ if err := p.store.Delete([]byte(name)); err != nil {
94+ return err
95+ }
96+ return p.store.Sync()
97+}
98+
99+// LoadAll returns all persisted session records sorted by name.
100+func (p *Persister) LoadAll() ([]Record, error) {
101+ if p == nil || p.store == nil {
102+ return nil, nil
103+ }
104+ data := make(map[string][]byte)
105+ p.store.Range(func(k, v []byte) bool {
106+ data[string(k)] = append([]byte(nil), v...)
107+ return true
108+ })
109+ return decodeAll(data)
110+}
111+
112+// Close closes the underlying store.
113+func (p *Persister) Close() error {
114+ if p == nil || p.store == nil {
115+ return nil
116+ }
117+ return p.store.Close()
118+}
119+
120+func decodeAll(data map[string][]byte) ([]Record, error) {
121+ records := make([]Record, 0, len(data))
122+ for name, value := range data {
123+ r, err := decodeRecordValue(name, value)
124+ if err != nil {
125+ return nil, err
126+ }
127+ records = append(records, r)
128+ }
129+ sort.Slice(records, func(i, j int) bool { return records[i].Name < records[j].Name })
130+ return records, nil
131+}
@@ -0,0 +1,131 @@
1+package persist
2+
3+import (
4+ "path/filepath"
5+ "sort"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/pkg/kv"
9+)
10+
11+const filename = "sessions.kv"
12+
13+// Persister stores named session metadata. A nil Store makes all mutating
14+// operations no-ops, so callers can keep persistence optional.
15+type Persister struct {
16+ store ports.Store
17+}
18+
19+// Open opens the session persister under dir.
20+func Open(dir string) (*Persister, error) {
21+ store, err := kv.Open(filepath.Join(dir, filename))
22+ if err != nil {
23+ return nil, err
24+ }
25+ return New(store), nil
26+}
27+
28+// New wraps store in a Persister. Passing nil creates a no-op persister.
29+func New(store ports.Store) *Persister {
30+ return &Persister{store: store}
31+}
32+
33+// LoadReadOnly replays the session store under dir without mutating it.
34+func LoadReadOnly(dir string) ([]Record, error) {
35+ data, err := kv.Replay(filepath.Join(dir, filename))
36+ if err != nil {
37+ return nil, err
38+ }
39+ return decodeAll(data)
40+}
41+
42+// Save persists a structural session record and syncs it to disk.
43+func (p *Persister) Save(r Record) error {
44+ if p == nil || p.store == nil {
45+ return nil
46+ }
47+ value, err := encodeRecordValue(r)
48+ if err != nil {
49+ return err
50+ }
51+ if err := p.store.Set([]byte(r.Name), value); err != nil {
52+ return err
53+ }
54+ return p.store.Sync()
55+}
56+
57+// Touch updates hot-path session cwd metadata without forcing an fsync.
58+func (p *Persister) Touch(name, cwd string, at int64) error {
59+ if p == nil || p.store == nil {
60+ return nil
61+ }
62+ if name == "" {
63+ return errEmptyName
64+ }
65+
66+ createdAt := at
67+ p.store.Range(func(k, v []byte) bool {
68+ if string(k) != name {
69+ return true
70+ }
71+ r, err := decodeRecordValue(name, v)
72+ if err == nil {
73+ createdAt = r.CreatedAt
74+ }
75+ return false
76+ })
77+
78+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at})
79+ if err != nil {
80+ return err
81+ }
82+ return p.store.Set([]byte(name), value)
83+}
84+
85+// Delete removes a persisted session record and syncs the structural change.
86+func (p *Persister) Delete(name string) error {
87+ if p == nil || p.store == nil {
88+ return nil
89+ }
90+ if name == "" {
91+ return errEmptyName
92+ }
93+ if err := p.store.Delete([]byte(name)); err != nil {
94+ return err
95+ }
96+ return p.store.Sync()
97+}
98+
99+// LoadAll returns all persisted session records sorted by name.
100+func (p *Persister) LoadAll() ([]Record, error) {
101+ if p == nil || p.store == nil {
102+ return nil, nil
103+ }
104+ data := make(map[string][]byte)
105+ p.store.Range(func(k, v []byte) bool {
106+ data[string(k)] = append([]byte(nil), v...)
107+ return true
108+ })
109+ return decodeAll(data)
110+}
111+
112+// Close closes the underlying store.
113+func (p *Persister) Close() error {
114+ if p == nil || p.store == nil {
115+ return nil
116+ }
117+ return p.store.Close()
118+}
119+
120+func decodeAll(data map[string][]byte) ([]Record, error) {
121+ records := make([]Record, 0, len(data))
122+ for name, value := range data {
123+ r, err := decodeRecordValue(name, value)
124+ if err != nil {
125+ return nil, err
126+ }
127+ records = append(records, r)
128+ }
129+ sort.Slice(records, func(i, j int) bool { return records[i].Name < records[j].Name })
130+ return records, nil
131+}
A internal/persist/persist_test.go +174 -0
@@ -0,0 +1,174 @@
1+package persist
2+
3+import (
4+ "errors"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRecordCodecRoundTrip(t *testing.T) {
12+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22}
13+ value, err := encodeRecordValue(r)
14+ require.NoError(t, err)
15+
16+ got, err := decodeRecordValue(r.Name, value)
17+ require.NoError(t, err)
18+ require.Equal(t, r, got)
19+}
20+
21+func TestRecordCodecRejectsMalformedData(t *testing.T) {
22+ _, err := encodeRecordValue(Record{Cwd: "/tmp"})
23+ require.ErrorIs(t, err, errEmptyName)
24+
25+ badValues := [][]byte{
26+ nil,
27+ {0, 0, 0},
28+ {0, 0, 0, 5, 'a'},
29+ {0, 0, 0, 1, 'a', 0},
30+ {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
31+ {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
32+ }
33+ for _, value := range badValues {
34+ _, err := decodeRecordValue("name", value)
35+ require.ErrorIs(t, err, errMalformedRecord)
36+ }
37+
38+ _, err = decodeRecordValue("", make([]byte, 20))
39+ require.ErrorIs(t, err, errEmptyName)
40+}
41+
42+func TestNilPersisterNoOps(t *testing.T) {
43+ var p *Persister
44+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
45+ require.NoError(t, p.Touch("a", "/tmp/next", 2))
46+ require.NoError(t, p.Delete("a"))
47+ records, err := p.LoadAll()
48+ require.NoError(t, err)
49+ require.Empty(t, records)
50+ require.NoError(t, p.Close())
51+
52+ p = New(nil)
53+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
54+ require.NoError(t, p.Touch("a", "/tmp/next", 2))
55+ require.NoError(t, p.Delete("a"))
56+ records, err = p.LoadAll()
57+ require.NoError(t, err)
58+ require.Empty(t, records)
59+ require.NoError(t, p.Close())
60+}
61+
62+func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
63+ dir := t.TempDir()
64+ p, err := Open(dir)
65+ require.NoError(t, err)
66+
67+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10}))
68+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}))
69+ require.NoError(t, p.Touch("b", "/b/next", 30))
70+
71+ records, err := p.LoadAll()
72+ require.NoError(t, err)
73+ require.Equal(t, []Record{
74+ {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20},
75+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30},
76+ }, records)
77+
78+ require.NoError(t, p.Delete("a"))
79+ records, err = p.LoadAll()
80+ require.NoError(t, err)
81+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}}, records)
82+ require.NoError(t, p.Close())
83+}
84+
85+func TestLoadReadOnlyUsesReplay(t *testing.T) {
86+ dir := t.TempDir()
87+ p, err := Open(dir)
88+ require.NoError(t, err)
89+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}))
90+ require.NoError(t, p.Close())
91+
92+ records, err := LoadReadOnly(dir)
93+ require.NoError(t, err)
94+ require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}}, records)
95+
96+ // Missing stores replay as empty rather than creating the WAL.
97+ missing := filepath.Join(t.TempDir(), "missing")
98+ records, err = LoadReadOnly(missing)
99+ require.NoError(t, err)
100+ require.Empty(t, records)
101+}
102+
103+func TestSyncBehavior(t *testing.T) {
104+ store := newFakeStore()
105+ p := New(store)
106+
107+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
108+ require.Equal(t, 1, store.sets)
109+ require.Equal(t, 1, store.syncs)
110+
111+ require.NoError(t, p.Touch("work", "/work/next", 2))
112+ require.Equal(t, 2, store.sets)
113+ require.Equal(t, 1, store.syncs)
114+
115+ require.NoError(t, p.Delete("work"))
116+ require.Equal(t, 1, store.deletes)
117+ require.Equal(t, 2, store.syncs)
118+}
119+
120+func TestSavePropagatesSetErrorWithoutSync(t *testing.T) {
121+ store := newFakeStore()
122+ store.errSet = errors.New("set failed")
123+ p := New(store)
124+
125+ err := p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1})
126+ require.ErrorIs(t, err, store.errSet)
127+ require.Zero(t, store.syncs)
128+}
129+
130+type fakeStore struct {
131+ data map[string][]byte
132+ sets int
133+ deletes int
134+ syncs int
135+ closed bool
136+ errSet error
137+}
138+
139+func newFakeStore() *fakeStore {
140+ return &fakeStore{data: make(map[string][]byte)}
141+}
142+
143+func (s *fakeStore) Set(key, val []byte) error {
144+ s.sets++
145+ if s.errSet != nil {
146+ return s.errSet
147+ }
148+ s.data[string(key)] = append([]byte(nil), val...)
149+ return nil
150+}
151+
152+func (s *fakeStore) Delete(key []byte) error {
153+ s.deletes++
154+ delete(s.data, string(key))
155+ return nil
156+}
157+
158+func (s *fakeStore) Range(fn func(k, v []byte) bool) {
159+ for k, v := range s.data {
160+ if !fn([]byte(k), append([]byte(nil), v...)) {
161+ return
162+ }
163+ }
164+}
165+
166+func (s *fakeStore) Sync() error {
167+ s.syncs++
168+ return nil
169+}
170+
171+func (s *fakeStore) Close() error {
172+ s.closed = true
173+ return nil
174+}
@@ -0,0 +1,174 @@
1+package persist
2+
3+import (
4+ "errors"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestRecordCodecRoundTrip(t *testing.T) {
12+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22}
13+ value, err := encodeRecordValue(r)
14+ require.NoError(t, err)
15+
16+ got, err := decodeRecordValue(r.Name, value)
17+ require.NoError(t, err)
18+ require.Equal(t, r, got)
19+}
20+
21+func TestRecordCodecRejectsMalformedData(t *testing.T) {
22+ _, err := encodeRecordValue(Record{Cwd: "/tmp"})
23+ require.ErrorIs(t, err, errEmptyName)
24+
25+ badValues := [][]byte{
26+ nil,
27+ {0, 0, 0},
28+ {0, 0, 0, 5, 'a'},
29+ {0, 0, 0, 1, 'a', 0},
30+ {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
31+ {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
32+ }
33+ for _, value := range badValues {
34+ _, err := decodeRecordValue("name", value)
35+ require.ErrorIs(t, err, errMalformedRecord)
36+ }
37+
38+ _, err = decodeRecordValue("", make([]byte, 20))
39+ require.ErrorIs(t, err, errEmptyName)
40+}
41+
42+func TestNilPersisterNoOps(t *testing.T) {
43+ var p *Persister
44+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
45+ require.NoError(t, p.Touch("a", "/tmp/next", 2))
46+ require.NoError(t, p.Delete("a"))
47+ records, err := p.LoadAll()
48+ require.NoError(t, err)
49+ require.Empty(t, records)
50+ require.NoError(t, p.Close())
51+
52+ p = New(nil)
53+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
54+ require.NoError(t, p.Touch("a", "/tmp/next", 2))
55+ require.NoError(t, p.Delete("a"))
56+ records, err = p.LoadAll()
57+ require.NoError(t, err)
58+ require.Empty(t, records)
59+ require.NoError(t, p.Close())
60+}
61+
62+func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
63+ dir := t.TempDir()
64+ p, err := Open(dir)
65+ require.NoError(t, err)
66+
67+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10}))
68+ require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}))
69+ require.NoError(t, p.Touch("b", "/b/next", 30))
70+
71+ records, err := p.LoadAll()
72+ require.NoError(t, err)
73+ require.Equal(t, []Record{
74+ {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20},
75+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30},
76+ }, records)
77+
78+ require.NoError(t, p.Delete("a"))
79+ records, err = p.LoadAll()
80+ require.NoError(t, err)
81+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}}, records)
82+ require.NoError(t, p.Close())
83+}
84+
85+func TestLoadReadOnlyUsesReplay(t *testing.T) {
86+ dir := t.TempDir()
87+ p, err := Open(dir)
88+ require.NoError(t, err)
89+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}))
90+ require.NoError(t, p.Close())
91+
92+ records, err := LoadReadOnly(dir)
93+ require.NoError(t, err)
94+ require.Equal(t, []Record{{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 2}}, records)
95+
96+ // Missing stores replay as empty rather than creating the WAL.
97+ missing := filepath.Join(t.TempDir(), "missing")
98+ records, err = LoadReadOnly(missing)
99+ require.NoError(t, err)
100+ require.Empty(t, records)
101+}
102+
103+func TestSyncBehavior(t *testing.T) {
104+ store := newFakeStore()
105+ p := New(store)
106+
107+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
108+ require.Equal(t, 1, store.sets)
109+ require.Equal(t, 1, store.syncs)
110+
111+ require.NoError(t, p.Touch("work", "/work/next", 2))
112+ require.Equal(t, 2, store.sets)
113+ require.Equal(t, 1, store.syncs)
114+
115+ require.NoError(t, p.Delete("work"))
116+ require.Equal(t, 1, store.deletes)
117+ require.Equal(t, 2, store.syncs)
118+}
119+
120+func TestSavePropagatesSetErrorWithoutSync(t *testing.T) {
121+ store := newFakeStore()
122+ store.errSet = errors.New("set failed")
123+ p := New(store)
124+
125+ err := p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1})
126+ require.ErrorIs(t, err, store.errSet)
127+ require.Zero(t, store.syncs)
128+}
129+
130+type fakeStore struct {
131+ data map[string][]byte
132+ sets int
133+ deletes int
134+ syncs int
135+ closed bool
136+ errSet error
137+}
138+
139+func newFakeStore() *fakeStore {
140+ return &fakeStore{data: make(map[string][]byte)}
141+}
142+
143+func (s *fakeStore) Set(key, val []byte) error {
144+ s.sets++
145+ if s.errSet != nil {
146+ return s.errSet
147+ }
148+ s.data[string(key)] = append([]byte(nil), val...)
149+ return nil
150+}
151+
152+func (s *fakeStore) Delete(key []byte) error {
153+ s.deletes++
154+ delete(s.data, string(key))
155+ return nil
156+}
157+
158+func (s *fakeStore) Range(fn func(k, v []byte) bool) {
159+ for k, v := range s.data {
160+ if !fn([]byte(k), append([]byte(nil), v...)) {
161+ return
162+ }
163+ }
164+}
165+
166+func (s *fakeStore) Sync() error {
167+ s.syncs++
168+ return nil
169+}
170+
171+func (s *fakeStore) Close() error {
172+ s.closed = true
173+ return nil
174+}
A internal/persist/record.go +57 -0
@@ -0,0 +1,57 @@
1+package persist
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "fmt"
7+)
8+
9+// Record is the persisted metadata for a named session.
10+type Record struct {
11+ Name string
12+ Cwd string
13+ CreatedAt int64
14+ UpdatedAt int64
15+}
16+
17+var (
18+ errEmptyName = errors.New("persist: empty session name")
19+ errMalformedRecord = errors.New("persist: malformed record")
20+)
21+
22+func encodeRecordValue(r Record) ([]byte, error) {
23+ if r.Name == "" {
24+ return nil, errEmptyName
25+ }
26+ if len(r.Cwd) > int(^uint32(0)) {
27+ return nil, fmt.Errorf("persist: cwd too large")
28+ }
29+
30+ buf := make([]byte, 4+len(r.Cwd)+8+8)
31+ binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
32+ copy(buf[4:], r.Cwd)
33+ off := 4 + len(r.Cwd)
34+ binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
35+ binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
36+ return buf, nil
37+}
38+
39+func decodeRecordValue(name string, value []byte) (Record, error) {
40+ if name == "" {
41+ return Record{}, errEmptyName
42+ }
43+ if len(value) < 4 {
44+ return Record{}, errMalformedRecord
45+ }
46+ cwdLen := int(binary.BigEndian.Uint32(value[:4]))
47+ if cwdLen > len(value)-4 || len(value) != 4+cwdLen+8+8 {
48+ return Record{}, errMalformedRecord
49+ }
50+ off := 4 + cwdLen
51+ return Record{
52+ Name: name,
53+ Cwd: string(value[4:off]),
54+ CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
55+ UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
56+ }, nil
57+}
@@ -0,0 +1,57 @@
1+package persist
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "fmt"
7+)
8+
9+// Record is the persisted metadata for a named session.
10+type Record struct {
11+ Name string
12+ Cwd string
13+ CreatedAt int64
14+ UpdatedAt int64
15+}
16+
17+var (
18+ errEmptyName = errors.New("persist: empty session name")
19+ errMalformedRecord = errors.New("persist: malformed record")
20+)
21+
22+func encodeRecordValue(r Record) ([]byte, error) {
23+ if r.Name == "" {
24+ return nil, errEmptyName
25+ }
26+ if len(r.Cwd) > int(^uint32(0)) {
27+ return nil, fmt.Errorf("persist: cwd too large")
28+ }
29+
30+ buf := make([]byte, 4+len(r.Cwd)+8+8)
31+ binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
32+ copy(buf[4:], r.Cwd)
33+ off := 4 + len(r.Cwd)
34+ binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
35+ binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
36+ return buf, nil
37+}
38+
39+func decodeRecordValue(name string, value []byte) (Record, error) {
40+ if name == "" {
41+ return Record{}, errEmptyName
42+ }
43+ if len(value) < 4 {
44+ return Record{}, errMalformedRecord
45+ }
46+ cwdLen := int(binary.BigEndian.Uint32(value[:4]))
47+ if cwdLen > len(value)-4 || len(value) != 4+cwdLen+8+8 {
48+ return Record{}, errMalformedRecord
49+ }
50+ off := 4 + cwdLen
51+ return Record{
52+ Name: name,
53+ Cwd: string(value[4:off]),
54+ CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
55+ UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
56+ }, nil
57+}
M internal/platform/dir.go +19 -1
@@ -1,6 +1,24 @@
11 package platform
22
3-import "os"
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+)
8+
9+// StateDir returns the directory vev writes state to: $XDG_STATE_HOME/vev if
10+// set, else ~/.local/state/vev.
11+func StateDir() string {
12+ if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
13+ return filepath.Join(xdg, "vev")
14+ }
15+ home, err := os.UserHomeDir()
16+ if err != nil || home == "" {
17+ // Last resort: a per-uid temp path so state setup never aborts startup.
18+ return filepath.Join(os.TempDir(), fmt.Sprintf("vev-state-%d", os.Getuid()))
19+ }
20+ return filepath.Join(home, ".local", "state", "vev")
21+}
422
523 // DirOrHome returns dir when it names an existing directory. Otherwise it
624 // falls back to the current user's home directory, then to / as a last resort.
@@ -1,6 +1,24 @@
1 package platform1 package platform
2 2
3-import "os"3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+)
8+
9+// StateDir returns the directory vev writes state to: $XDG_STATE_HOME/vev if
10+// set, else ~/.local/state/vev.
11+func StateDir() string {
12+ if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
13+ return filepath.Join(xdg, "vev")
14+ }
15+ home, err := os.UserHomeDir()
16+ if err != nil || home == "" {
17+ // Last resort: a per-uid temp path so state setup never aborts startup.
18+ return filepath.Join(os.TempDir(), fmt.Sprintf("vev-state-%d", os.Getuid()))
19+ }
20+ return filepath.Join(home, ".local", "state", "vev")
21+}
4 22
5 // DirOrHome returns dir when it names an existing directory. Otherwise it23 // DirOrHome returns dir when it names an existing directory. Otherwise it
6 // falls back to the current user's home directory, then to / as a last resort.24 // falls back to the current user's home directory, then to / as a last resort.
A internal/platform/proc_linux.go +11 -0
@@ -0,0 +1,11 @@
1+package platform
2+
3+import (
4+ "fmt"
5+ "os"
6+)
7+
8+// ProcessCwd returns the current working directory for a Linux process.
9+func ProcessCwd(pid int) (string, error) {
10+ return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))
11+}
@@ -0,0 +1,11 @@
1+package platform
2+
3+import (
4+ "fmt"
5+ "os"
6+)
7+
8+// ProcessCwd returns the current working directory for a Linux process.
9+func ProcessCwd(pid int) (string, error) {
10+ return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))
11+}
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 3
4+const ProtocolVersion uint16 = 4
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 34+const ProtocolVersion uint16 = 4
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
M internal/ports/frame_test.go +6 -0
@@ -2,6 +2,12 @@ package ports
22
33 import "testing"
44
5+func TestProtocolVersion(t *testing.T) {
6+ if ProtocolVersion != 4 {
7+ t.Fatalf("ProtocolVersion = %d, want 4", ProtocolVersion)
8+ }
9+}
10+
511 func TestMsgTypeUnique(t *testing.T) {
612 tests := []struct {
713 name string
@@ -2,6 +2,12 @@ package ports
2 2
3 import "testing"3 import "testing"
4 4
5+func TestProtocolVersion(t *testing.T) {
6+ if ProtocolVersion != 4 {
7+ t.Fatalf("ProtocolVersion = %d, want 4", ProtocolVersion)
8+ }
9+}
10+
5 func TestMsgTypeUnique(t *testing.T) {11 func TestMsgTypeUnique(t *testing.T) {
6 tests := []struct {12 tests := []struct {
7 name string13 name string
M internal/ports/mocks/mocks.go +263 -0
@@ -1074,6 +1074,269 @@ func (_c *MockListener_Close_Call) RunAndReturn(run func() error) *MockListener_
10741074 return _c
10751075 }
10761076
1077+// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1078+// The first argument is typically a *testing.T value.
1079+func NewMockStore(t interface {
1080+ mock.TestingT
1081+ Cleanup(func())
1082+}) *MockStore {
1083+ mock := &MockStore{}
1084+ mock.Mock.Test(t)
1085+
1086+ t.Cleanup(func() { mock.AssertExpectations(t) })
1087+
1088+ return mock
1089+}
1090+
1091+// MockStore is an autogenerated mock type for the Store type
1092+type MockStore struct {
1093+ mock.Mock
1094+}
1095+
1096+type MockStore_Expecter struct {
1097+ mock *mock.Mock
1098+}
1099+
1100+func (_m *MockStore) EXPECT() *MockStore_Expecter {
1101+ return &MockStore_Expecter{mock: &_m.Mock}
1102+}
1103+
1104+// Close provides a mock function for the type MockStore
1105+func (_mock *MockStore) Close() error {
1106+ ret := _mock.Called()
1107+
1108+ if len(ret) == 0 {
1109+ panic("no return value specified for Close")
1110+ }
1111+
1112+ var r0 error
1113+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1114+ r0 = returnFunc()
1115+ } else {
1116+ r0 = ret.Error(0)
1117+ }
1118+ return r0
1119+}
1120+
1121+// MockStore_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
1122+type MockStore_Close_Call struct {
1123+ *mock.Call
1124+}
1125+
1126+// Close is a helper method to define mock.On call
1127+func (_e *MockStore_Expecter) Close() *MockStore_Close_Call {
1128+ return &MockStore_Close_Call{Call: _e.mock.On("Close")}
1129+}
1130+
1131+func (_c *MockStore_Close_Call) Run(run func()) *MockStore_Close_Call {
1132+ _c.Call.Run(func(args mock.Arguments) {
1133+ run()
1134+ })
1135+ return _c
1136+}
1137+
1138+func (_c *MockStore_Close_Call) Return(err error) *MockStore_Close_Call {
1139+ _c.Call.Return(err)
1140+ return _c
1141+}
1142+
1143+func (_c *MockStore_Close_Call) RunAndReturn(run func() error) *MockStore_Close_Call {
1144+ _c.Call.Return(run)
1145+ return _c
1146+}
1147+
1148+// Delete provides a mock function for the type MockStore
1149+func (_mock *MockStore) Delete(key []byte) error {
1150+ ret := _mock.Called(key)
1151+
1152+ if len(ret) == 0 {
1153+ panic("no return value specified for Delete")
1154+ }
1155+
1156+ var r0 error
1157+ if returnFunc, ok := ret.Get(0).(func([]byte) error); ok {
1158+ r0 = returnFunc(key)
1159+ } else {
1160+ r0 = ret.Error(0)
1161+ }
1162+ return r0
1163+}
1164+
1165+// MockStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
1166+type MockStore_Delete_Call struct {
1167+ *mock.Call
1168+}
1169+
1170+// Delete is a helper method to define mock.On call
1171+// - key []byte
1172+func (_e *MockStore_Expecter) Delete(key any) *MockStore_Delete_Call {
1173+ return &MockStore_Delete_Call{Call: _e.mock.On("Delete", key)}
1174+}
1175+
1176+func (_c *MockStore_Delete_Call) Run(run func(key []byte)) *MockStore_Delete_Call {
1177+ _c.Call.Run(func(args mock.Arguments) {
1178+ var arg0 []byte
1179+ if args[0] != nil {
1180+ arg0 = args[0].([]byte)
1181+ }
1182+ run(
1183+ arg0,
1184+ )
1185+ })
1186+ return _c
1187+}
1188+
1189+func (_c *MockStore_Delete_Call) Return(err error) *MockStore_Delete_Call {
1190+ _c.Call.Return(err)
1191+ return _c
1192+}
1193+
1194+func (_c *MockStore_Delete_Call) RunAndReturn(run func(key []byte) error) *MockStore_Delete_Call {
1195+ _c.Call.Return(run)
1196+ return _c
1197+}
1198+
1199+// Range provides a mock function for the type MockStore
1200+func (_mock *MockStore) Range(fn func(k []byte, v []byte) bool) {
1201+ _mock.Called(fn)
1202+ return
1203+}
1204+
1205+// MockStore_Range_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Range'
1206+type MockStore_Range_Call struct {
1207+ *mock.Call
1208+}
1209+
1210+// Range is a helper method to define mock.On call
1211+// - fn func(k []byte, v []byte) bool
1212+func (_e *MockStore_Expecter) Range(fn any) *MockStore_Range_Call {
1213+ return &MockStore_Range_Call{Call: _e.mock.On("Range", fn)}
1214+}
1215+
1216+func (_c *MockStore_Range_Call) Run(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call {
1217+ _c.Call.Run(func(args mock.Arguments) {
1218+ var arg0 func(k []byte, v []byte) bool
1219+ if args[0] != nil {
1220+ arg0 = args[0].(func(k []byte, v []byte) bool)
1221+ }
1222+ run(
1223+ arg0,
1224+ )
1225+ })
1226+ return _c
1227+}
1228+
1229+func (_c *MockStore_Range_Call) Return() *MockStore_Range_Call {
1230+ _c.Call.Return()
1231+ return _c
1232+}
1233+
1234+func (_c *MockStore_Range_Call) RunAndReturn(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call {
1235+ _c.Run(run)
1236+ return _c
1237+}
1238+
1239+// Set provides a mock function for the type MockStore
1240+func (_mock *MockStore) Set(key []byte, val []byte) error {
1241+ ret := _mock.Called(key, val)
1242+
1243+ if len(ret) == 0 {
1244+ panic("no return value specified for Set")
1245+ }
1246+
1247+ var r0 error
1248+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte) error); ok {
1249+ r0 = returnFunc(key, val)
1250+ } else {
1251+ r0 = ret.Error(0)
1252+ }
1253+ return r0
1254+}
1255+
1256+// MockStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set'
1257+type MockStore_Set_Call struct {
1258+ *mock.Call
1259+}
1260+
1261+// Set is a helper method to define mock.On call
1262+// - key []byte
1263+// - val []byte
1264+func (_e *MockStore_Expecter) Set(key any, val any) *MockStore_Set_Call {
1265+ return &MockStore_Set_Call{Call: _e.mock.On("Set", key, val)}
1266+}
1267+
1268+func (_c *MockStore_Set_Call) Run(run func(key []byte, val []byte)) *MockStore_Set_Call {
1269+ _c.Call.Run(func(args mock.Arguments) {
1270+ var arg0 []byte
1271+ if args[0] != nil {
1272+ arg0 = args[0].([]byte)
1273+ }
1274+ var arg1 []byte
1275+ if args[1] != nil {
1276+ arg1 = args[1].([]byte)
1277+ }
1278+ run(
1279+ arg0,
1280+ arg1,
1281+ )
1282+ })
1283+ return _c
1284+}
1285+
1286+func (_c *MockStore_Set_Call) Return(err error) *MockStore_Set_Call {
1287+ _c.Call.Return(err)
1288+ return _c
1289+}
1290+
1291+func (_c *MockStore_Set_Call) RunAndReturn(run func(key []byte, val []byte) error) *MockStore_Set_Call {
1292+ _c.Call.Return(run)
1293+ return _c
1294+}
1295+
1296+// Sync provides a mock function for the type MockStore
1297+func (_mock *MockStore) Sync() error {
1298+ ret := _mock.Called()
1299+
1300+ if len(ret) == 0 {
1301+ panic("no return value specified for Sync")
1302+ }
1303+
1304+ var r0 error
1305+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1306+ r0 = returnFunc()
1307+ } else {
1308+ r0 = ret.Error(0)
1309+ }
1310+ return r0
1311+}
1312+
1313+// MockStore_Sync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sync'
1314+type MockStore_Sync_Call struct {
1315+ *mock.Call
1316+}
1317+
1318+// Sync is a helper method to define mock.On call
1319+func (_e *MockStore_Expecter) Sync() *MockStore_Sync_Call {
1320+ return &MockStore_Sync_Call{Call: _e.mock.On("Sync")}
1321+}
1322+
1323+func (_c *MockStore_Sync_Call) Run(run func()) *MockStore_Sync_Call {
1324+ _c.Call.Run(func(args mock.Arguments) {
1325+ run()
1326+ })
1327+ return _c
1328+}
1329+
1330+func (_c *MockStore_Sync_Call) Return(err error) *MockStore_Sync_Call {
1331+ _c.Call.Return(err)
1332+ return _c
1333+}
1334+
1335+func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Call {
1336+ _c.Call.Return(run)
1337+ return _c
1338+}
1339+
10771340 // NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
10781341 // The first argument is typically a *testing.T value.
10791342 func NewMockClock(t interface {
@@ -1074,6 +1074,269 @@ func (_c *MockListener_Close_Call) RunAndReturn(run func() error) *MockListener_
1074 return _c1074 return _c
1075 }1075 }
1076 1076
1077+// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1078+// The first argument is typically a *testing.T value.
1079+func NewMockStore(t interface {
1080+ mock.TestingT
1081+ Cleanup(func())
1082+}) *MockStore {
1083+ mock := &MockStore{}
1084+ mock.Mock.Test(t)
1085+
1086+ t.Cleanup(func() { mock.AssertExpectations(t) })
1087+
1088+ return mock
1089+}
1090+
1091+// MockStore is an autogenerated mock type for the Store type
1092+type MockStore struct {
1093+ mock.Mock
1094+}
1095+
1096+type MockStore_Expecter struct {
1097+ mock *mock.Mock
1098+}
1099+
1100+func (_m *MockStore) EXPECT() *MockStore_Expecter {
1101+ return &MockStore_Expecter{mock: &_m.Mock}
1102+}
1103+
1104+// Close provides a mock function for the type MockStore
1105+func (_mock *MockStore) Close() error {
1106+ ret := _mock.Called()
1107+
1108+ if len(ret) == 0 {
1109+ panic("no return value specified for Close")
1110+ }
1111+
1112+ var r0 error
1113+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1114+ r0 = returnFunc()
1115+ } else {
1116+ r0 = ret.Error(0)
1117+ }
1118+ return r0
1119+}
1120+
1121+// MockStore_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
1122+type MockStore_Close_Call struct {
1123+ *mock.Call
1124+}
1125+
1126+// Close is a helper method to define mock.On call
1127+func (_e *MockStore_Expecter) Close() *MockStore_Close_Call {
1128+ return &MockStore_Close_Call{Call: _e.mock.On("Close")}
1129+}
1130+
1131+func (_c *MockStore_Close_Call) Run(run func()) *MockStore_Close_Call {
1132+ _c.Call.Run(func(args mock.Arguments) {
1133+ run()
1134+ })
1135+ return _c
1136+}
1137+
1138+func (_c *MockStore_Close_Call) Return(err error) *MockStore_Close_Call {
1139+ _c.Call.Return(err)
1140+ return _c
1141+}
1142+
1143+func (_c *MockStore_Close_Call) RunAndReturn(run func() error) *MockStore_Close_Call {
1144+ _c.Call.Return(run)
1145+ return _c
1146+}
1147+
1148+// Delete provides a mock function for the type MockStore
1149+func (_mock *MockStore) Delete(key []byte) error {
1150+ ret := _mock.Called(key)
1151+
1152+ if len(ret) == 0 {
1153+ panic("no return value specified for Delete")
1154+ }
1155+
1156+ var r0 error
1157+ if returnFunc, ok := ret.Get(0).(func([]byte) error); ok {
1158+ r0 = returnFunc(key)
1159+ } else {
1160+ r0 = ret.Error(0)
1161+ }
1162+ return r0
1163+}
1164+
1165+// MockStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
1166+type MockStore_Delete_Call struct {
1167+ *mock.Call
1168+}
1169+
1170+// Delete is a helper method to define mock.On call
1171+// - key []byte
1172+func (_e *MockStore_Expecter) Delete(key any) *MockStore_Delete_Call {
1173+ return &MockStore_Delete_Call{Call: _e.mock.On("Delete", key)}
1174+}
1175+
1176+func (_c *MockStore_Delete_Call) Run(run func(key []byte)) *MockStore_Delete_Call {
1177+ _c.Call.Run(func(args mock.Arguments) {
1178+ var arg0 []byte
1179+ if args[0] != nil {
1180+ arg0 = args[0].([]byte)
1181+ }
1182+ run(
1183+ arg0,
1184+ )
1185+ })
1186+ return _c
1187+}
1188+
1189+func (_c *MockStore_Delete_Call) Return(err error) *MockStore_Delete_Call {
1190+ _c.Call.Return(err)
1191+ return _c
1192+}
1193+
1194+func (_c *MockStore_Delete_Call) RunAndReturn(run func(key []byte) error) *MockStore_Delete_Call {
1195+ _c.Call.Return(run)
1196+ return _c
1197+}
1198+
1199+// Range provides a mock function for the type MockStore
1200+func (_mock *MockStore) Range(fn func(k []byte, v []byte) bool) {
1201+ _mock.Called(fn)
1202+ return
1203+}
1204+
1205+// MockStore_Range_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Range'
1206+type MockStore_Range_Call struct {
1207+ *mock.Call
1208+}
1209+
1210+// Range is a helper method to define mock.On call
1211+// - fn func(k []byte, v []byte) bool
1212+func (_e *MockStore_Expecter) Range(fn any) *MockStore_Range_Call {
1213+ return &MockStore_Range_Call{Call: _e.mock.On("Range", fn)}
1214+}
1215+
1216+func (_c *MockStore_Range_Call) Run(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call {
1217+ _c.Call.Run(func(args mock.Arguments) {
1218+ var arg0 func(k []byte, v []byte) bool
1219+ if args[0] != nil {
1220+ arg0 = args[0].(func(k []byte, v []byte) bool)
1221+ }
1222+ run(
1223+ arg0,
1224+ )
1225+ })
1226+ return _c
1227+}
1228+
1229+func (_c *MockStore_Range_Call) Return() *MockStore_Range_Call {
1230+ _c.Call.Return()
1231+ return _c
1232+}
1233+
1234+func (_c *MockStore_Range_Call) RunAndReturn(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call {
1235+ _c.Run(run)
1236+ return _c
1237+}
1238+
1239+// Set provides a mock function for the type MockStore
1240+func (_mock *MockStore) Set(key []byte, val []byte) error {
1241+ ret := _mock.Called(key, val)
1242+
1243+ if len(ret) == 0 {
1244+ panic("no return value specified for Set")
1245+ }
1246+
1247+ var r0 error
1248+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte) error); ok {
1249+ r0 = returnFunc(key, val)
1250+ } else {
1251+ r0 = ret.Error(0)
1252+ }
1253+ return r0
1254+}
1255+
1256+// MockStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set'
1257+type MockStore_Set_Call struct {
1258+ *mock.Call
1259+}
1260+
1261+// Set is a helper method to define mock.On call
1262+// - key []byte
1263+// - val []byte
1264+func (_e *MockStore_Expecter) Set(key any, val any) *MockStore_Set_Call {
1265+ return &MockStore_Set_Call{Call: _e.mock.On("Set", key, val)}
1266+}
1267+
1268+func (_c *MockStore_Set_Call) Run(run func(key []byte, val []byte)) *MockStore_Set_Call {
1269+ _c.Call.Run(func(args mock.Arguments) {
1270+ var arg0 []byte
1271+ if args[0] != nil {
1272+ arg0 = args[0].([]byte)
1273+ }
1274+ var arg1 []byte
1275+ if args[1] != nil {
1276+ arg1 = args[1].([]byte)
1277+ }
1278+ run(
1279+ arg0,
1280+ arg1,
1281+ )
1282+ })
1283+ return _c
1284+}
1285+
1286+func (_c *MockStore_Set_Call) Return(err error) *MockStore_Set_Call {
1287+ _c.Call.Return(err)
1288+ return _c
1289+}
1290+
1291+func (_c *MockStore_Set_Call) RunAndReturn(run func(key []byte, val []byte) error) *MockStore_Set_Call {
1292+ _c.Call.Return(run)
1293+ return _c
1294+}
1295+
1296+// Sync provides a mock function for the type MockStore
1297+func (_mock *MockStore) Sync() error {
1298+ ret := _mock.Called()
1299+
1300+ if len(ret) == 0 {
1301+ panic("no return value specified for Sync")
1302+ }
1303+
1304+ var r0 error
1305+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1306+ r0 = returnFunc()
1307+ } else {
1308+ r0 = ret.Error(0)
1309+ }
1310+ return r0
1311+}
1312+
1313+// MockStore_Sync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sync'
1314+type MockStore_Sync_Call struct {
1315+ *mock.Call
1316+}
1317+
1318+// Sync is a helper method to define mock.On call
1319+func (_e *MockStore_Expecter) Sync() *MockStore_Sync_Call {
1320+ return &MockStore_Sync_Call{Call: _e.mock.On("Sync")}
1321+}
1322+
1323+func (_c *MockStore_Sync_Call) Run(run func()) *MockStore_Sync_Call {
1324+ _c.Call.Run(func(args mock.Arguments) {
1325+ run()
1326+ })
1327+ return _c
1328+}
1329+
1330+func (_c *MockStore_Sync_Call) Return(err error) *MockStore_Sync_Call {
1331+ _c.Call.Return(err)
1332+ return _c
1333+}
1334+
1335+func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Call {
1336+ _c.Call.Return(run)
1337+ return _c
1338+}
1339+
1077 // NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1340 // NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1078 // The first argument is typically a *testing.T value.1341 // The first argument is typically a *testing.T value.
1079 func NewMockClock(t interface {1342 func NewMockClock(t interface {
M internal/ports/ports.go +11 -0
@@ -43,6 +43,17 @@ type Listener interface {
4343 Addr() string
4444 }
4545
46+// Store is a small byte-key/value persistence port.
47+//
48+// Implementations may buffer writes; Sync is the durability barrier.
49+type Store interface {
50+ Set(key, val []byte) error
51+ Delete(key []byte) error
52+ Range(fn func(k, v []byte) bool)
53+ Sync() error
54+ Close() error
55+}
56+
4657 // Clock abstracts time so usecases can be tested without real delays.
4758 type Clock interface {
4859 Now() time.Time
@@ -43,6 +43,17 @@ type Listener interface {
43 Addr() string43 Addr() string
44 }44 }
45 45
46+// Store is a small byte-key/value persistence port.
47+//
48+// Implementations may buffer writes; Sync is the durability barrier.
49+type Store interface {
50+ Set(key, val []byte) error
51+ Delete(key []byte) error
52+ Range(fn func(k, v []byte) bool)
53+ Sync() error
54+ Close() error
55+}
56+
46 // Clock abstracts time so usecases can be tested without real delays.57 // Clock abstracts time so usecases can be tested without real delays.
47 type Clock interface {58 type Clock interface {
48 Now() time.Time59 Now() time.Time
M internal/ports/wire.go +11 -0
@@ -126,6 +126,7 @@ type SessionInfo struct {
126126 Ephemeral bool
127127 Tabs uint16
128128 Attached bool
129+ Stopped bool
129130 }
130131
131132 // Sessions is the daemon's reply to a List, enumerating live sessions.
@@ -559,6 +560,11 @@ func MarshalSessions(m Sessions) []byte {
559560 } else {
560561 w.putUint8(0)
561562 }
563+ if s.Stopped {
564+ w.putUint8(1)
565+ } else {
566+ w.putUint8(0)
567+ }
562568 }
563569 return w.b
564570 }
@@ -592,6 +598,11 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
592598 return Sessions{}, err
593599 }
594600 s.Attached = att != 0
601+ stopped, err := r.getUint8()
602+ if err != nil {
603+ return Sessions{}, err
604+ }
605+ s.Stopped = stopped != 0
595606 sessions = append(sessions, s)
596607 }
597608 if err := r.done(); err != nil {
@@ -126,6 +126,7 @@ type SessionInfo struct {
126 Ephemeral bool126 Ephemeral bool
127 Tabs uint16127 Tabs uint16
128 Attached bool128 Attached bool
129+ Stopped bool
129 }130 }
130 131
131 // Sessions is the daemon's reply to a List, enumerating live sessions.132 // Sessions is the daemon's reply to a List, enumerating live sessions.
@@ -559,6 +560,11 @@ func MarshalSessions(m Sessions) []byte {
559 } else {560 } else {
560 w.putUint8(0)561 w.putUint8(0)
561 }562 }
563+ if s.Stopped {
564+ w.putUint8(1)
565+ } else {
566+ w.putUint8(0)
567+ }
562 }568 }
563 return w.b569 return w.b
564 }570 }
@@ -592,6 +598,11 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
592 return Sessions{}, err598 return Sessions{}, err
593 }599 }
594 s.Attached = att != 0600 s.Attached = att != 0
601+ stopped, err := r.getUint8()
602+ if err != nil {
603+ return Sessions{}, err
604+ }
605+ s.Stopped = stopped != 0
595 sessions = append(sessions, s)606 sessions = append(sessions, s)
596 }607 }
597 if err := r.done(); err != nil {608 if err := r.done(); err != nil {
M internal/ports/wire_test.go +3 -3
@@ -424,12 +424,12 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) {
424424 name: "two",
425425 msg: Sessions{Sessions: []SessionInfo{
426426 {SessionID: "0", Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
427- {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true},
427+ {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true, Stopped: true},
428428 }},
429429 want: []byte{
430430 0x00, 0x02,
431- 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00,
432- 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01,
431+ 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x00,
432+ 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x01,
433433 },
434434 },
435435 }
@@ -424,12 +424,12 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) {
424 name: "two",424 name: "two",
425 msg: Sessions{Sessions: []SessionInfo{425 msg: Sessions{Sessions: []SessionInfo{
426 {SessionID: "0", Name: "0", Ephemeral: true, Tabs: 1, Attached: false},426 {SessionID: "0", Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
427- {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true},427+ {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true, Stopped: true},
428 }},428 }},
429 want: []byte{429 want: []byte{
430 0x00, 0x02,430 0x00, 0x02,
431- 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00,431+ 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x00,
432- 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01,432+ 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x01,
433 },433 },
434 },434 },
435 }435 }
M internal/usecase/daemon/client.go +5 -2
@@ -366,6 +366,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
366366 return // already displaced by a newer client; nothing to do
367367 }
368368 d.unregisterPreview(ac)
369+ if !sess.ephemeral {
370+ d.refreshSessionCwd(sess)
371+ }
369372 d.resetScreenDefaultColors(sess)
370373 if explicit {
371374 // Synchronous so the ack is delivered before the transport closes
@@ -376,7 +379,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
376379 }
377380 _ = ac.tr.Close()
378381 if sess.ephemeral {
379- d.killSession(sess, ports.ReasonSessionKilled)
382+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
380383 }
381384 }
382385
@@ -446,7 +449,7 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
446449 _ = ac.tr.Close()
447450 d.log.Warn("detached client after send error", "session", sess.name)
448451 if sess.ephemeral {
449- d.killSession(sess, ports.ReasonSessionKilled)
452+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
450453 }
451454 }
452455 }
@@ -366,6 +366,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
366 return // already displaced by a newer client; nothing to do366 return // already displaced by a newer client; nothing to do
367 }367 }
368 d.unregisterPreview(ac)368 d.unregisterPreview(ac)
369+ if !sess.ephemeral {
370+ d.refreshSessionCwd(sess)
371+ }
369 d.resetScreenDefaultColors(sess)372 d.resetScreenDefaultColors(sess)
370 if explicit {373 if explicit {
371 // Synchronous so the ack is delivered before the transport closes374 // Synchronous so the ack is delivered before the transport closes
@@ -376,7 +379,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
376 }379 }
377 _ = ac.tr.Close()380 _ = ac.tr.Close()
378 if sess.ephemeral {381 if sess.ephemeral {
379- d.killSession(sess, ports.ReasonSessionKilled)382+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
380 }383 }
381 }384 }
382 385
@@ -446,7 +449,7 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
446 _ = ac.tr.Close()449 _ = ac.tr.Close()
447 d.log.Warn("detached client after send error", "session", sess.name)450 d.log.Warn("detached client after send error", "session", sess.name)
448 if sess.ephemeral {451 if sess.ephemeral {
449- d.killSession(sess, ports.ReasonSessionKilled)452+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
450 }453 }
451 }454 }
452 }455 }
M internal/usecase/daemon/daemon.go +100 -12
@@ -35,6 +35,8 @@ import (
3535 "time"
3636
3737 "github.com/bnema/vev/internal/domain"
38+ "github.com/bnema/vev/internal/persist"
39+ "github.com/bnema/vev/internal/platform"
3840 "github.com/bnema/vev/internal/ports"
3941 )
4042
@@ -67,6 +69,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24}
6769 type Daemon struct {
6870 mu sync.Mutex
6971 sessions map[domain.SessionID]*session
72+ stopped map[string]stoppedSession
7073 nextID uint64
7174 // closing marks that shutdown has irreversibly begun. It is set under mu,
7275 // atomically with the event that makes shutdown inevitable (the registry
@@ -85,12 +88,15 @@ type Daemon struct {
8588 animFrame int
8689 animWake chan struct{}
8790
88- ptys ports.PTYFactory
89- clock ports.Clock
90- log *slog.Logger
91- baseEnv []string
92- shell string
93- shellArgs []string
91+ ptys ports.PTYFactory
92+ clock ports.Clock
93+ log *slog.Logger
94+ baseEnv []string
95+ shell string
96+ shellArgs []string
97+ persist *persist.Persister
98+ persistEnabled bool
99+ procCwd func(int) (string, error)
94100
95101 serveCtx context.Context
96102 serveCancel context.CancelFunc
@@ -110,6 +116,12 @@ type Daemon struct {
110116
111117 // session is a single multiplexed session. It owns one or more full-screen
112118
119+type stoppedSession struct {
120+ name string
121+ cwd string
122+ createdAt int64
123+}
124+
113125 type Option func(*Daemon)
114126
115127 // WithShell overrides the command (and its args) each session spawns. The
@@ -122,6 +134,24 @@ func WithShell(cmd string, args []string) Option {
122134 }
123135 }
124136
137+// WithStore enables persisted named session metadata. A nil store keeps the
138+// daemon in no-op persistence mode.
139+func WithStore(store ports.Store) Option {
140+ return func(d *Daemon) {
141+ d.persist = persist.New(store)
142+ d.persistEnabled = store != nil
143+ }
144+}
145+
146+// WithCwdReader overrides the process cwd reader used for persistence tests.
147+func WithCwdReader(fn func(int) (string, error)) Option {
148+ return func(d *Daemon) {
149+ if fn != nil {
150+ d.procCwd = fn
151+ }
152+ }
153+}
154+
125155 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
126156 // render debounce, and log receives diagnostics (defaults to slog.Default).
127157 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -134,17 +164,33 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
134164 }
135165 d := &Daemon{
136166 sessions: make(map[domain.SessionID]*session),
167+ stopped: make(map[string]stoppedSession),
137168 ptys: ptys,
138169 clock: clock,
139170 log: log,
140171 baseEnv: os.Environ(),
141172 shell: shell,
173+ persist: persist.New(nil),
174+ procCwd: platform.ProcessCwd,
142175 done: make(chan struct{}),
143176 animWake: make(chan struct{}, 1),
144177 }
145178 for _, o := range opts {
146179 o(d)
147180 }
181+ if d.persist == nil {
182+ d.persist = persist.New(nil)
183+ }
184+ if d.procCwd == nil {
185+ d.procCwd = platform.ProcessCwd
186+ }
187+ if records, err := d.persist.LoadAll(); err != nil {
188+ d.log.Warn("loading persisted sessions failed", "err", err)
189+ } else {
190+ for _, r := range records {
191+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt}
192+ }
193+ }
148194 return d
149195 }
150196
@@ -160,6 +206,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
160206 d.sessWg.Go(func() {
161207 d.attentionAnimator(d.serveCtx)
162208 })
209+ d.sessWg.Go(func() {
210+ d.cwdSampler(d.serveCtx)
211+ })
163212
164213 // Break the accept loop when either the parent context is cancelled or the
165214 // registry drains to empty: both close the listener, which fails Accept.
@@ -198,6 +247,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
198247 d.shutdownAll(ports.ReasonServerShutdown)
199248 d.sessWg.Wait()
200249 d.waitNotifies()
250+ if err := d.persist.Close(); err != nil {
251+ d.log.Warn("closing session persister failed", "err", err)
252+ }
201253 return nil
202254 }
203255
@@ -216,7 +268,7 @@ func (d *Daemon) shutdownAll(reason uint8) {
216268 return
217269 }
218270 for _, s := range snapshot {
219- d.killSession(s, reason)
271+ _ = d.killSession(s, reason, false)
220272 }
221273 }
222274
@@ -259,7 +311,8 @@ func (d *Daemon) handleList(tr ports.Transport) {
259311 defer func() { _ = tr.Close() }()
260312
261313 d.mu.Lock()
262- infos := make([]ports.SessionInfo, 0, len(d.sessions))
314+ infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))
315+ liveNames := make(map[string]struct{}, len(d.sessions))
263316 for _, s := range d.sessions {
264317 s.mu.Lock()
265318 info := ports.SessionInfo{
@@ -269,9 +322,16 @@ func (d *Daemon) handleList(tr ports.Transport) {
269322 Tabs: uint16(len(s.tabs)),
270323 Attached: s.client != nil,
271324 }
325+ liveNames[s.name] = struct{}{}
272326 s.mu.Unlock()
273327 infos = append(infos, info)
274328 }
329+ for name := range d.stopped {
330+ if _, live := liveNames[name]; live {
331+ continue
332+ }
333+ infos = append(infos, ports.SessionInfo{Name: name, Stopped: true})
334+ }
275335 d.mu.Unlock()
276336
277337 sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })
@@ -296,13 +356,31 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
296356
297357 d.mu.Lock()
298358 target := d.findByNameLocked(k.Name)
359+ if target == nil {
360+ if stopped, ok := d.stopped[k.Name]; ok {
361+ d.mu.Unlock()
362+ if err := d.persist.Delete(k.Name); err != nil {
363+ d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)
364+ _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))
365+ return
366+ }
367+ d.mu.Lock()
368+ if cur, ok := d.stopped[k.Name]; ok && cur == stopped {
369+ delete(d.stopped, k.Name)
370+ }
371+ d.mu.Unlock()
372+ return
373+ }
374+ }
299375 d.mu.Unlock()
300376
301377 if target == nil {
302378 _ = tr.Send(frameError(ports.ErrNoSuchSession, "no such session: "+k.Name))
303379 return
304380 }
305- d.killSession(target, ports.ReasonSessionKilled)
381+ if err := d.killSession(target, ports.ReasonSessionKilled, true); err != nil {
382+ _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted session failed"))
383+ }
306384 }
307385
308386 // handleHello runs the attach handshake: version check, intent routing,
@@ -387,7 +465,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
387465 d.mu.Unlock()
388466 return nil, nil, &protoErr{ports.ErrInternal, "empty session name"}
389467 }
390- if d.nameTakenLocked(h.Name) {
468+ if d.nameLiveOrStoppedLocked(h.Name) {
391469 d.mu.Unlock()
392470 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
393471 }
@@ -404,8 +482,18 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
404482 case ports.IntentAttach:
405483 sess := d.findByNameLocked(h.Name)
406484 if sess == nil {
407- d.mu.Unlock()
408- return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
485+ stopped, ok := d.stopped[h.Name]
486+ if !ok {
487+ d.mu.Unlock()
488+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
489+ }
490+ cwd := platform.DirOrHome(stopped.cwd)
491+ var err error
492+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
493+ if err != nil {
494+ d.mu.Unlock()
495+ return nil, nil, err
496+ }
409497 }
410498 ac, old := d.attachClient(sess, tr, sz)
411499 d.mu.Unlock()
@@ -35,6 +35,8 @@ import (
35 "time"35 "time"
36 36
37 "github.com/bnema/vev/internal/domain"37 "github.com/bnema/vev/internal/domain"
38+ "github.com/bnema/vev/internal/persist"
39+ "github.com/bnema/vev/internal/platform"
38 "github.com/bnema/vev/internal/ports"40 "github.com/bnema/vev/internal/ports"
39 )41 )
40 42
@@ -67,6 +69,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24}
67 type Daemon struct {69 type Daemon struct {
68 mu sync.Mutex70 mu sync.Mutex
69 sessions map[domain.SessionID]*session71 sessions map[domain.SessionID]*session
72+ stopped map[string]stoppedSession
70 nextID uint6473 nextID uint64
71 // closing marks that shutdown has irreversibly begun. It is set under mu,74 // closing marks that shutdown has irreversibly begun. It is set under mu,
72 // atomically with the event that makes shutdown inevitable (the registry75 // atomically with the event that makes shutdown inevitable (the registry
@@ -85,12 +88,15 @@ type Daemon struct {
85 animFrame int88 animFrame int
86 animWake chan struct{}89 animWake chan struct{}
87 90
88- ptys ports.PTYFactory91+ ptys ports.PTYFactory
89- clock ports.Clock92+ clock ports.Clock
90- log *slog.Logger93+ log *slog.Logger
91- baseEnv []string94+ baseEnv []string
92- shell string95+ shell string
93- shellArgs []string96+ shellArgs []string
97+ persist *persist.Persister
98+ persistEnabled bool
99+ procCwd func(int) (string, error)
94 100
95 serveCtx context.Context101 serveCtx context.Context
96 serveCancel context.CancelFunc102 serveCancel context.CancelFunc
@@ -110,6 +116,12 @@ type Daemon struct {
110 116
111 // session is a single multiplexed session. It owns one or more full-screen117 // session is a single multiplexed session. It owns one or more full-screen
112 118
119+type stoppedSession struct {
120+ name string
121+ cwd string
122+ createdAt int64
123+}
124+
113 type Option func(*Daemon)125 type Option func(*Daemon)
114 126
115 // WithShell overrides the command (and its args) each session spawns. The127 // WithShell overrides the command (and its args) each session spawns. The
@@ -122,6 +134,24 @@ func WithShell(cmd string, args []string) Option {
122 }134 }
123 }135 }
124 136
137+// WithStore enables persisted named session metadata. A nil store keeps the
138+// daemon in no-op persistence mode.
139+func WithStore(store ports.Store) Option {
140+ return func(d *Daemon) {
141+ d.persist = persist.New(store)
142+ d.persistEnabled = store != nil
143+ }
144+}
145+
146+// WithCwdReader overrides the process cwd reader used for persistence tests.
147+func WithCwdReader(fn func(int) (string, error)) Option {
148+ return func(d *Daemon) {
149+ if fn != nil {
150+ d.procCwd = fn
151+ }
152+ }
153+}
154+
125 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the155 // New constructs a Daemon. ptys spawns PTY-backed children, clock drives the
126 // render debounce, and log receives diagnostics (defaults to slog.Default).156 // render debounce, and log receives diagnostics (defaults to slog.Default).
127 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {157 func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Option) *Daemon {
@@ -134,17 +164,33 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
134 }164 }
135 d := &Daemon{165 d := &Daemon{
136 sessions: make(map[domain.SessionID]*session),166 sessions: make(map[domain.SessionID]*session),
167+ stopped: make(map[string]stoppedSession),
137 ptys: ptys,168 ptys: ptys,
138 clock: clock,169 clock: clock,
139 log: log,170 log: log,
140 baseEnv: os.Environ(),171 baseEnv: os.Environ(),
141 shell: shell,172 shell: shell,
173+ persist: persist.New(nil),
174+ procCwd: platform.ProcessCwd,
142 done: make(chan struct{}),175 done: make(chan struct{}),
143 animWake: make(chan struct{}, 1),176 animWake: make(chan struct{}, 1),
144 }177 }
145 for _, o := range opts {178 for _, o := range opts {
146 o(d)179 o(d)
147 }180 }
181+ if d.persist == nil {
182+ d.persist = persist.New(nil)
183+ }
184+ if d.procCwd == nil {
185+ d.procCwd = platform.ProcessCwd
186+ }
187+ if records, err := d.persist.LoadAll(); err != nil {
188+ d.log.Warn("loading persisted sessions failed", "err", err)
189+ } else {
190+ for _, r := range records {
191+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt}
192+ }
193+ }
148 return d194 return d
149 }195 }
150 196
@@ -160,6 +206,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
160 d.sessWg.Go(func() {206 d.sessWg.Go(func() {
161 d.attentionAnimator(d.serveCtx)207 d.attentionAnimator(d.serveCtx)
162 })208 })
209+ d.sessWg.Go(func() {
210+ d.cwdSampler(d.serveCtx)
211+ })
163 212
164 // Break the accept loop when either the parent context is cancelled or the213 // Break the accept loop when either the parent context is cancelled or the
165 // registry drains to empty: both close the listener, which fails Accept.214 // registry drains to empty: both close the listener, which fails Accept.
@@ -198,6 +247,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
198 d.shutdownAll(ports.ReasonServerShutdown)247 d.shutdownAll(ports.ReasonServerShutdown)
199 d.sessWg.Wait()248 d.sessWg.Wait()
200 d.waitNotifies()249 d.waitNotifies()
250+ if err := d.persist.Close(); err != nil {
251+ d.log.Warn("closing session persister failed", "err", err)
252+ }
201 return nil253 return nil
202 }254 }
203 255
@@ -216,7 +268,7 @@ func (d *Daemon) shutdownAll(reason uint8) {
216 return268 return
217 }269 }
218 for _, s := range snapshot {270 for _, s := range snapshot {
219- d.killSession(s, reason)271+ _ = d.killSession(s, reason, false)
220 }272 }
221 }273 }
222 274
@@ -259,7 +311,8 @@ func (d *Daemon) handleList(tr ports.Transport) {
259 defer func() { _ = tr.Close() }()311 defer func() { _ = tr.Close() }()
260 312
261 d.mu.Lock()313 d.mu.Lock()
262- infos := make([]ports.SessionInfo, 0, len(d.sessions))314+ infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))
315+ liveNames := make(map[string]struct{}, len(d.sessions))
263 for _, s := range d.sessions {316 for _, s := range d.sessions {
264 s.mu.Lock()317 s.mu.Lock()
265 info := ports.SessionInfo{318 info := ports.SessionInfo{
@@ -269,9 +322,16 @@ func (d *Daemon) handleList(tr ports.Transport) {
269 Tabs: uint16(len(s.tabs)),322 Tabs: uint16(len(s.tabs)),
270 Attached: s.client != nil,323 Attached: s.client != nil,
271 }324 }
325+ liveNames[s.name] = struct{}{}
272 s.mu.Unlock()326 s.mu.Unlock()
273 infos = append(infos, info)327 infos = append(infos, info)
274 }328 }
329+ for name := range d.stopped {
330+ if _, live := liveNames[name]; live {
331+ continue
332+ }
333+ infos = append(infos, ports.SessionInfo{Name: name, Stopped: true})
334+ }
275 d.mu.Unlock()335 d.mu.Unlock()
276 336
277 sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })337 sort.Slice(infos, func(i, j int) bool { return infos[i].Name < infos[j].Name })
@@ -296,13 +356,31 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
296 356
297 d.mu.Lock()357 d.mu.Lock()
298 target := d.findByNameLocked(k.Name)358 target := d.findByNameLocked(k.Name)
359+ if target == nil {
360+ if stopped, ok := d.stopped[k.Name]; ok {
361+ d.mu.Unlock()
362+ if err := d.persist.Delete(k.Name); err != nil {
363+ d.log.Warn("deleting persisted stopped session failed", "err", err, "session", k.Name)
364+ _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))
365+ return
366+ }
367+ d.mu.Lock()
368+ if cur, ok := d.stopped[k.Name]; ok && cur == stopped {
369+ delete(d.stopped, k.Name)
370+ }
371+ d.mu.Unlock()
372+ return
373+ }
374+ }
299 d.mu.Unlock()375 d.mu.Unlock()
300 376
301 if target == nil {377 if target == nil {
302 _ = tr.Send(frameError(ports.ErrNoSuchSession, "no such session: "+k.Name))378 _ = tr.Send(frameError(ports.ErrNoSuchSession, "no such session: "+k.Name))
303 return379 return
304 }380 }
305- d.killSession(target, ports.ReasonSessionKilled)381+ if err := d.killSession(target, ports.ReasonSessionKilled, true); err != nil {
382+ _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted session failed"))
383+ }
306 }384 }
307 385
308 // handleHello runs the attach handshake: version check, intent routing,386 // handleHello runs the attach handshake: version check, intent routing,
@@ -387,7 +465,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
387 d.mu.Unlock()465 d.mu.Unlock()
388 return nil, nil, &protoErr{ports.ErrInternal, "empty session name"}466 return nil, nil, &protoErr{ports.ErrInternal, "empty session name"}
389 }467 }
390- if d.nameTakenLocked(h.Name) {468+ if d.nameLiveOrStoppedLocked(h.Name) {
391 d.mu.Unlock()469 d.mu.Unlock()
392 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}470 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
393 }471 }
@@ -404,8 +482,18 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
404 case ports.IntentAttach:482 case ports.IntentAttach:
405 sess := d.findByNameLocked(h.Name)483 sess := d.findByNameLocked(h.Name)
406 if sess == nil {484 if sess == nil {
407- d.mu.Unlock()485+ stopped, ok := d.stopped[h.Name]
408- return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}486+ if !ok {
487+ d.mu.Unlock()
488+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
489+ }
490+ cwd := platform.DirOrHome(stopped.cwd)
491+ var err error
492+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
493+ if err != nil {
494+ d.mu.Unlock()
495+ return nil, nil, err
496+ }
409 }497 }
410 ac, old := d.attachClient(sess, tr, sz)498 ac, old := d.attachClient(sess, tr, sz)
411 d.mu.Unlock()499 d.mu.Unlock()
M internal/usecase/daemon/picker.go +102 -1
@@ -30,6 +30,7 @@ import (
3030 "strconv"
3131
3232 "github.com/bnema/vev/internal/domain"
33+ "github.com/bnema/vev/internal/platform"
3334 "github.com/bnema/vev/internal/ports"
3435 "github.com/bnema/vev/internal/usecase/keys"
3536 "github.com/bnema/vev/internal/usecase/picker"
@@ -53,10 +54,15 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
5354 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
5455 d.mu.Lock()
5556 sessions := d.sessionsSnapshotLocked()
57+ stopped := make([]stoppedSession, 0, len(d.stopped))
58+ for _, s := range d.stopped {
59+ stopped = append(stopped, s)
60+ }
5661 d.mu.Unlock()
5762 sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
63+ sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
5864
59- views := make([]picker.SessionView, 0, len(sessions))
65+ views := make([]picker.SessionView, 0, len(sessions)+len(stopped))
6066 curTab := 0
6167 for _, s := range sessions {
6268 s.mu.Lock()
@@ -79,6 +85,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
7985 s.mu.Unlock()
8086 views = append(views, view)
8187 }
88+ for _, s := range stopped {
89+ views = append(views, picker.SessionView{
90+ ID: domain.SessionID("stopped:" + s.name),
91+ Name: s.name,
92+ Tabs: []string{"(stopped)"},
93+ Stopped: true,
94+ })
95+ }
8296 return views, curTab
8397 }
8498
@@ -111,6 +125,15 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
111125 switchTarget := false
112126 for i := 0; i < len(data); i++ {
113127 switch data[i] {
128+ case 'x':
129+ target, ok := ac.picker.Selected()
130+ ac.pickerMu.Unlock()
131+ if ok {
132+ d.killPickerTarget(target)
133+ }
134+ d.refreshPicker(ac)
135+ d.paint(sess, ac, true)
136+ return
114137 case 'j':
115138 ac.picker.Down()
116139 changed = true
@@ -325,6 +348,10 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
325348 }
326349
327350 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
351+ if target.Stopped {
352+ d.resumeStoppedAndSwitch(sess, ac, target)
353+ return
354+ }
328355 targetSess := d.sessionByID(target.Session)
329356 if targetSess == nil {
330357 d.paint(sess, ac, true)
@@ -376,6 +403,80 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
376403 return old
377404 }
378405
406+func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) {
407+ d.mu.Lock()
408+ stopped, ok := d.stopped[target.Name]
409+ if !ok {
410+ d.mu.Unlock()
411+ d.paint(from, ac, true)
412+ return
413+ }
414+ from.mu.Lock()
415+ if from.client != ac {
416+ from.mu.Unlock()
417+ d.mu.Unlock()
418+ return
419+ }
420+ cwd := platform.DirOrHome(stopped.cwd)
421+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size)
422+ if err != nil {
423+ from.mu.Unlock()
424+ d.mu.Unlock()
425+ d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
426+ d.paint(from, ac, true)
427+ return
428+ }
429+ from.client = nil
430+ ac.setSession(nil)
431+ from.mu.Unlock()
432+ targetSess.mu.Lock()
433+ targetSess.client = ac
434+ targetSess.mu.Unlock()
435+ ac.setSession(targetSess)
436+ d.mu.Unlock()
437+ d.firstPaint(targetSess, ac, ac.size)
438+}
439+
440+func (d *Daemon) killPickerTarget(target picker.Target) {
441+ if target.Stopped {
442+ d.mu.Lock()
443+ stopped, ok := d.stopped[target.Name]
444+ d.mu.Unlock()
445+ if ok {
446+ if err := d.persist.Delete(target.Name); err != nil {
447+ d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
448+ return
449+ }
450+ d.mu.Lock()
451+ if cur, ok := d.stopped[target.Name]; ok && cur == stopped {
452+ delete(d.stopped, target.Name)
453+ }
454+ d.mu.Unlock()
455+ }
456+ return
457+ }
458+ d.mu.Lock()
459+ targetSess := d.sessions[target.Session]
460+ d.mu.Unlock()
461+ if targetSess != nil {
462+ _ = d.killSession(targetSess, ports.ReasonSessionKilled, true)
463+ }
464+}
465+
466+func (d *Daemon) refreshPicker(ac *attachedClient) {
467+ sess := ac.currentSession()
468+ if sess == nil {
469+ return
470+ }
471+ views, curTab := d.pickerViews(sess)
472+ ac.pickerMu.Lock()
473+ if ac.picker != nil {
474+ ac.picker = picker.New(views, sess.id, curTab)
475+ }
476+ ac.pickerMu.Unlock()
477+ d.registerPreviewForSelection(ac)
478+}
479+
379480 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
380481 styleSet := resolveThemeStyles(styles)
381482 inner := pickerModal.Composite(base, styleSet.border)
@@ -30,6 +30,7 @@ import (
30 "strconv"30 "strconv"
31 31
32 "github.com/bnema/vev/internal/domain"32 "github.com/bnema/vev/internal/domain"
33+ "github.com/bnema/vev/internal/platform"
33 "github.com/bnema/vev/internal/ports"34 "github.com/bnema/vev/internal/ports"
34 "github.com/bnema/vev/internal/usecase/keys"35 "github.com/bnema/vev/internal/usecase/keys"
35 "github.com/bnema/vev/internal/usecase/picker"36 "github.com/bnema/vev/internal/usecase/picker"
@@ -53,10 +54,15 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
53 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {54 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
54 d.mu.Lock()55 d.mu.Lock()
55 sessions := d.sessionsSnapshotLocked()56 sessions := d.sessionsSnapshotLocked()
57+ stopped := make([]stoppedSession, 0, len(d.stopped))
58+ for _, s := range d.stopped {
59+ stopped = append(stopped, s)
60+ }
56 d.mu.Unlock()61 d.mu.Unlock()
57 sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })62 sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
63+ sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
58 64
59- views := make([]picker.SessionView, 0, len(sessions))65+ views := make([]picker.SessionView, 0, len(sessions)+len(stopped))
60 curTab := 066 curTab := 0
61 for _, s := range sessions {67 for _, s := range sessions {
62 s.mu.Lock()68 s.mu.Lock()
@@ -79,6 +85,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
79 s.mu.Unlock()85 s.mu.Unlock()
80 views = append(views, view)86 views = append(views, view)
81 }87 }
88+ for _, s := range stopped {
89+ views = append(views, picker.SessionView{
90+ ID: domain.SessionID("stopped:" + s.name),
91+ Name: s.name,
92+ Tabs: []string{"(stopped)"},
93+ Stopped: true,
94+ })
95+ }
82 return views, curTab96 return views, curTab
83 }97 }
84 98
@@ -111,6 +125,15 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
111 switchTarget := false125 switchTarget := false
112 for i := 0; i < len(data); i++ {126 for i := 0; i < len(data); i++ {
113 switch data[i] {127 switch data[i] {
128+ case 'x':
129+ target, ok := ac.picker.Selected()
130+ ac.pickerMu.Unlock()
131+ if ok {
132+ d.killPickerTarget(target)
133+ }
134+ d.refreshPicker(ac)
135+ d.paint(sess, ac, true)
136+ return
114 case 'j':137 case 'j':
115 ac.picker.Down()138 ac.picker.Down()
116 changed = true139 changed = true
@@ -325,6 +348,10 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
325 }348 }
326 349
327 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {350 func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
351+ if target.Stopped {
352+ d.resumeStoppedAndSwitch(sess, ac, target)
353+ return
354+ }
328 targetSess := d.sessionByID(target.Session)355 targetSess := d.sessionByID(target.Session)
329 if targetSess == nil {356 if targetSess == nil {
330 d.paint(sess, ac, true)357 d.paint(sess, ac, true)
@@ -376,6 +403,80 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
376 return old403 return old
377 }404 }
378 405
406+func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) {
407+ d.mu.Lock()
408+ stopped, ok := d.stopped[target.Name]
409+ if !ok {
410+ d.mu.Unlock()
411+ d.paint(from, ac, true)
412+ return
413+ }
414+ from.mu.Lock()
415+ if from.client != ac {
416+ from.mu.Unlock()
417+ d.mu.Unlock()
418+ return
419+ }
420+ cwd := platform.DirOrHome(stopped.cwd)
421+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size)
422+ if err != nil {
423+ from.mu.Unlock()
424+ d.mu.Unlock()
425+ d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
426+ d.paint(from, ac, true)
427+ return
428+ }
429+ from.client = nil
430+ ac.setSession(nil)
431+ from.mu.Unlock()
432+ targetSess.mu.Lock()
433+ targetSess.client = ac
434+ targetSess.mu.Unlock()
435+ ac.setSession(targetSess)
436+ d.mu.Unlock()
437+ d.firstPaint(targetSess, ac, ac.size)
438+}
439+
440+func (d *Daemon) killPickerTarget(target picker.Target) {
441+ if target.Stopped {
442+ d.mu.Lock()
443+ stopped, ok := d.stopped[target.Name]
444+ d.mu.Unlock()
445+ if ok {
446+ if err := d.persist.Delete(target.Name); err != nil {
447+ d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
448+ return
449+ }
450+ d.mu.Lock()
451+ if cur, ok := d.stopped[target.Name]; ok && cur == stopped {
452+ delete(d.stopped, target.Name)
453+ }
454+ d.mu.Unlock()
455+ }
456+ return
457+ }
458+ d.mu.Lock()
459+ targetSess := d.sessions[target.Session]
460+ d.mu.Unlock()
461+ if targetSess != nil {
462+ _ = d.killSession(targetSess, ports.ReasonSessionKilled, true)
463+ }
464+}
465+
466+func (d *Daemon) refreshPicker(ac *attachedClient) {
467+ sess := ac.currentSession()
468+ if sess == nil {
469+ return
470+ }
471+ views, curTab := d.pickerViews(sess)
472+ ac.pickerMu.Lock()
473+ if ac.picker != nil {
474+ ac.picker = picker.New(views, sess.id, curTab)
475+ }
476+ ac.pickerMu.Unlock()
477+ d.registerPreviewForSelection(ac)
478+}
479+
379 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {480 func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
380 styleSet := resolveThemeStyles(styles)481 styleSet := resolveThemeStyles(styles)
381 inner := pickerModal.Composite(base, styleSet.border)482 inner := pickerModal.Composite(base, styleSet.border)
M internal/usecase/daemon/session.go +150 -12
@@ -34,6 +34,7 @@ import (
3434 "time"
3535
3636 "github.com/bnema/vev/internal/domain"
37+ "github.com/bnema/vev/internal/persist"
3738 "github.com/bnema/vev/internal/ports"
3839 scopy "github.com/bnema/vev/internal/usecase/copy"
3940 "github.com/bnema/vev/pkg/vt"
@@ -47,11 +48,12 @@ type session struct {
4748 ctx context.Context
4849 cancel context.CancelFunc
4950
50- mu sync.Mutex // guards tabs, active, and client
51- tabs []*tab
52- active int
53- client *attachedClient
54- cwd string
51+ mu sync.Mutex // guards tabs, active, and client
52+ tabs []*tab
53+ active int
54+ client *attachedClient
55+ cwd string
56+ createdAt int64
5557 }
5658
5759 // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
@@ -90,6 +92,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
9092
9193 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
9294 d.nextID++
95+ createdAt := time.Now().UnixNano()
9396
9497 tb := newTab(pty, tbSize)
9598 sctx, cancel := context.WithCancel(d.serveCtx)
@@ -102,6 +105,15 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
102105 cancel: cancel,
103106 tabs: []*tab{tb},
104107 cwd: cwd,
108+ createdAt: createdAt,
109+ }
110+ if !ephemeral {
111+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt}); err != nil {
112+ _ = pty.Close()
113+ cancel()
114+ return nil, err
115+ }
116+ delete(d.stopped, name)
105117 }
106118 d.sessions[id] = sess
107119 d.startTabGoroutines(sess, tb)
@@ -118,7 +130,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
118130 d.mu.Unlock()
119131 return errors.New("daemon is shutting down")
120132 }
121- if d.nameTakenLocked(name) {
133+ if d.nameLiveOrStoppedLocked(name) {
122134 d.mu.Unlock()
123135 return errors.New("name already in use")
124136 }
@@ -140,7 +152,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
140152 if from.client != ac {
141153 from.mu.Unlock()
142154 d.mu.Unlock()
143- d.killSession(newSess, ports.ReasonSessionKilled)
155+ _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
144156 return errors.New("client detached")
145157 }
146158 from.client = nil
@@ -269,8 +281,30 @@ func (d *Daemon) renameSession(sess *session, name string) error {
269281 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
270282 return errors.New("name already in use")
271283 }
284+ if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {
285+ return errors.New("name already in use")
286+ }
272287 sess.mu.Lock()
273288 defer sess.mu.Unlock()
289+ oldName := sess.name
290+ wasEphemeral := sess.ephemeral
291+ createdAt := sess.createdAt
292+ if createdAt == 0 {
293+ createdAt = time.Now().UnixNano()
294+ sess.createdAt = createdAt
295+ }
296+ if !wasEphemeral && oldName != name {
297+ if err := d.persist.Delete(oldName); err != nil {
298+ return err
299+ }
300+ }
301+ if wasEphemeral || oldName != name {
302+ if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano()}); err != nil {
303+ return err
304+ }
305+ }
306+ delete(d.stopped, oldName)
307+ delete(d.stopped, name)
274308 sess.name = name
275309 sess.ephemeral = false
276310 return nil
@@ -291,7 +325,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
291325 }
292326 if len(sess.tabs) == 1 {
293327 sess.mu.Unlock()
294- d.killSession(sess, ports.ReasonSessionKilled)
328+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
295329 return
296330 }
297331 ringing := tb.attention
@@ -320,14 +354,33 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
320354 // ptyReader drains child output into the VT screen and pokes the dirty channel
321355 // (non-blocking: a full channel already means a render is pending). On any read
322356
323-func (d *Daemon) killSession(sess *session, reason uint8) {
357+func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
324358 ringing := sess.anyAttention()
359+ sess.mu.Lock()
360+ isEphemeral := sess.ephemeral
361+ sess.mu.Unlock()
362+ if !purge && !isEphemeral && d.persistEnabled {
363+ d.refreshSessionCwd(sess)
364+ }
325365 d.mu.Lock()
326366 if _, ok := d.sessions[sess.id]; !ok {
327367 d.mu.Unlock()
328- return
368+ return nil
329369 }
330370 delete(d.sessions, sess.id)
371+ sess.mu.Lock()
372+ stoppedName := sess.name
373+ stoppedCwd := sess.cwd
374+ createdAt := sess.createdAt
375+ ephemeral := sess.ephemeral
376+ sess.mu.Unlock()
377+ if !ephemeral {
378+ if purge {
379+ delete(d.stopped, stoppedName)
380+ } else {
381+ d.stopped[stoppedName] = stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt}
382+ }
383+ }
331384 empty := len(d.sessions) == 0
332385 if empty {
333386 // Shutdown is now inevitable and irreversible (doneOnce below): stop
@@ -337,6 +390,19 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
337390 }
338391 d.mu.Unlock()
339392
393+ var purgeErr error
394+ if !ephemeral && purge {
395+ if err := d.persist.Delete(stoppedName); err != nil {
396+ purgeErr = err
397+ d.log.Warn("deleting persisted session failed", "err", err, "session", stoppedName)
398+ d.mu.Lock()
399+ if _, live := d.sessions[sess.id]; !live {
400+ d.stopped[stoppedName] = stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt}
401+ }
402+ d.mu.Unlock()
403+ }
404+ }
405+
340406 sess.mu.Lock()
341407 ac := sess.client
342408 sess.client = nil
@@ -363,15 +429,19 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
363429 if ac != nil {
364430 d.notifyDetachedAsync(ac, reason)
365431 }
432+ return purgeErr
366433 }
367434
368435 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
369436 // d.mu.
370437 func (d *Daemon) allocEphemeralNameLocked() string {
371- used := make(map[string]struct{}, len(d.sessions))
438+ used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))
372439 for _, s := range d.sessions {
373440 used[s.name] = struct{}{}
374441 }
442+ for name := range d.stopped {
443+ used[name] = struct{}{}
444+ }
375445 for i := 0; ; i++ {
376446 n := strconv.Itoa(i)
377447 if _, taken := used[n]; !taken {
@@ -389,7 +459,75 @@ func (d *Daemon) findByNameLocked(name string) *session {
389459 return nil
390460 }
391461
392-func (d *Daemon) nameTakenLocked(name string) bool { return d.findByNameLocked(name) != nil }
462+func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
463+ if d.findByNameLocked(name) != nil {
464+ return true
465+ }
466+ _, ok := d.stopped[name]
467+ return ok
468+}
469+
470+func (d *Daemon) cwdSampler(ctx context.Context) {
471+ t := time.NewTicker(5 * time.Second)
472+ defer t.Stop()
473+ for {
474+ select {
475+ case <-ctx.Done():
476+ return
477+ case <-t.C:
478+ d.refreshNamedSessionCwds()
479+ }
480+ }
481+}
482+
483+func (d *Daemon) refreshNamedSessionCwds() {
484+ d.mu.Lock()
485+ sessions := d.sessionsSnapshotLocked()
486+ d.mu.Unlock()
487+ for _, sess := range sessions {
488+ sess.mu.Lock()
489+ ephemeral := sess.ephemeral
490+ sess.mu.Unlock()
491+ if !ephemeral {
492+ d.refreshSessionCwd(sess)
493+ }
494+ }
495+}
496+
497+func (d *Daemon) refreshSessionCwd(sess *session) {
498+ if d.procCwd == nil {
499+ return
500+ }
501+ tb := sess.activeTab()
502+ if tb == nil {
503+ return
504+ }
505+ cwd, err := d.procCwd(tb.pty.Pid())
506+ if err != nil || cwd == "" {
507+ return
508+ }
509+ d.mu.Lock()
510+ if d.sessions[sess.id] != sess {
511+ d.mu.Unlock()
512+ return
513+ }
514+ sess.mu.Lock()
515+ if sess.cwd == cwd {
516+ sess.mu.Unlock()
517+ d.mu.Unlock()
518+ return
519+ }
520+ sess.cwd = cwd
521+ name := sess.name
522+ ephemeral := sess.ephemeral
523+ sess.mu.Unlock()
524+ if !ephemeral {
525+ if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil {
526+ d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)
527+ }
528+ }
529+ d.mu.Unlock()
530+}
393531
394532 // childEnv builds the session child's environment: the daemon's own, with TERM
395533 // and VEV forced to well-known values.
@@ -34,6 +34,7 @@ import (
34 "time"34 "time"
35 35
36 "github.com/bnema/vev/internal/domain"36 "github.com/bnema/vev/internal/domain"
37+ "github.com/bnema/vev/internal/persist"
37 "github.com/bnema/vev/internal/ports"38 "github.com/bnema/vev/internal/ports"
38 scopy "github.com/bnema/vev/internal/usecase/copy"39 scopy "github.com/bnema/vev/internal/usecase/copy"
39 "github.com/bnema/vev/pkg/vt"40 "github.com/bnema/vev/pkg/vt"
@@ -47,11 +48,12 @@ type session struct {
47 ctx context.Context48 ctx context.Context
48 cancel context.CancelFunc49 cancel context.CancelFunc
49 50
50- mu sync.Mutex // guards tabs, active, and client51+ mu sync.Mutex // guards tabs, active, and client
51- tabs []*tab52+ tabs []*tab
52- active int53+ active int
53- client *attachedClient54+ client *attachedClient
54- cwd string55+ cwd string
56+ createdAt int64
55 }57 }
56 58
57 // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output59 // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
@@ -90,6 +92,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
90 92
91 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))93 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
92 d.nextID++94 d.nextID++
95+ createdAt := time.Now().UnixNano()
93 96
94 tb := newTab(pty, tbSize)97 tb := newTab(pty, tbSize)
95 sctx, cancel := context.WithCancel(d.serveCtx)98 sctx, cancel := context.WithCancel(d.serveCtx)
@@ -102,6 +105,15 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
102 cancel: cancel,105 cancel: cancel,
103 tabs: []*tab{tb},106 tabs: []*tab{tb},
104 cwd: cwd,107 cwd: cwd,
108+ createdAt: createdAt,
109+ }
110+ if !ephemeral {
111+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt}); err != nil {
112+ _ = pty.Close()
113+ cancel()
114+ return nil, err
115+ }
116+ delete(d.stopped, name)
105 }117 }
106 d.sessions[id] = sess118 d.sessions[id] = sess
107 d.startTabGoroutines(sess, tb)119 d.startTabGoroutines(sess, tb)
@@ -118,7 +130,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
118 d.mu.Unlock()130 d.mu.Unlock()
119 return errors.New("daemon is shutting down")131 return errors.New("daemon is shutting down")
120 }132 }
121- if d.nameTakenLocked(name) {133+ if d.nameLiveOrStoppedLocked(name) {
122 d.mu.Unlock()134 d.mu.Unlock()
123 return errors.New("name already in use")135 return errors.New("name already in use")
124 }136 }
@@ -140,7 +152,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
140 if from.client != ac {152 if from.client != ac {
141 from.mu.Unlock()153 from.mu.Unlock()
142 d.mu.Unlock()154 d.mu.Unlock()
143- d.killSession(newSess, ports.ReasonSessionKilled)155+ _ = d.killSession(newSess, ports.ReasonSessionKilled, true)
144 return errors.New("client detached")156 return errors.New("client detached")
145 }157 }
146 from.client = nil158 from.client = nil
@@ -269,8 +281,30 @@ func (d *Daemon) renameSession(sess *session, name string) error {
269 if taken := d.findByNameLocked(name); taken != nil && taken != sess {281 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
270 return errors.New("name already in use")282 return errors.New("name already in use")
271 }283 }
284+ if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {
285+ return errors.New("name already in use")
286+ }
272 sess.mu.Lock()287 sess.mu.Lock()
273 defer sess.mu.Unlock()288 defer sess.mu.Unlock()
289+ oldName := sess.name
290+ wasEphemeral := sess.ephemeral
291+ createdAt := sess.createdAt
292+ if createdAt == 0 {
293+ createdAt = time.Now().UnixNano()
294+ sess.createdAt = createdAt
295+ }
296+ if !wasEphemeral && oldName != name {
297+ if err := d.persist.Delete(oldName); err != nil {
298+ return err
299+ }
300+ }
301+ if wasEphemeral || oldName != name {
302+ if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano()}); err != nil {
303+ return err
304+ }
305+ }
306+ delete(d.stopped, oldName)
307+ delete(d.stopped, name)
274 sess.name = name308 sess.name = name
275 sess.ephemeral = false309 sess.ephemeral = false
276 return nil310 return nil
@@ -291,7 +325,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
291 }325 }
292 if len(sess.tabs) == 1 {326 if len(sess.tabs) == 1 {
293 sess.mu.Unlock()327 sess.mu.Unlock()
294- d.killSession(sess, ports.ReasonSessionKilled)328+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
295 return329 return
296 }330 }
297 ringing := tb.attention331 ringing := tb.attention
@@ -320,14 +354,33 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
320 // ptyReader drains child output into the VT screen and pokes the dirty channel354 // ptyReader drains child output into the VT screen and pokes the dirty channel
321 // (non-blocking: a full channel already means a render is pending). On any read355 // (non-blocking: a full channel already means a render is pending). On any read
322 356
323-func (d *Daemon) killSession(sess *session, reason uint8) {357+func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
324 ringing := sess.anyAttention()358 ringing := sess.anyAttention()
359+ sess.mu.Lock()
360+ isEphemeral := sess.ephemeral
361+ sess.mu.Unlock()
362+ if !purge && !isEphemeral && d.persistEnabled {
363+ d.refreshSessionCwd(sess)
364+ }
325 d.mu.Lock()365 d.mu.Lock()
326 if _, ok := d.sessions[sess.id]; !ok {366 if _, ok := d.sessions[sess.id]; !ok {
327 d.mu.Unlock()367 d.mu.Unlock()
328- return368+ return nil
329 }369 }
330 delete(d.sessions, sess.id)370 delete(d.sessions, sess.id)
371+ sess.mu.Lock()
372+ stoppedName := sess.name
373+ stoppedCwd := sess.cwd
374+ createdAt := sess.createdAt
375+ ephemeral := sess.ephemeral
376+ sess.mu.Unlock()
377+ if !ephemeral {
378+ if purge {
379+ delete(d.stopped, stoppedName)
380+ } else {
381+ d.stopped[stoppedName] = stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt}
382+ }
383+ }
331 empty := len(d.sessions) == 0384 empty := len(d.sessions) == 0
332 if empty {385 if empty {
333 // Shutdown is now inevitable and irreversible (doneOnce below): stop386 // Shutdown is now inevitable and irreversible (doneOnce below): stop
@@ -337,6 +390,19 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
337 }390 }
338 d.mu.Unlock()391 d.mu.Unlock()
339 392
393+ var purgeErr error
394+ if !ephemeral && purge {
395+ if err := d.persist.Delete(stoppedName); err != nil {
396+ purgeErr = err
397+ d.log.Warn("deleting persisted session failed", "err", err, "session", stoppedName)
398+ d.mu.Lock()
399+ if _, live := d.sessions[sess.id]; !live {
400+ d.stopped[stoppedName] = stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt}
401+ }
402+ d.mu.Unlock()
403+ }
404+ }
405+
340 sess.mu.Lock()406 sess.mu.Lock()
341 ac := sess.client407 ac := sess.client
342 sess.client = nil408 sess.client = nil
@@ -363,15 +429,19 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
363 if ac != nil {429 if ac != nil {
364 d.notifyDetachedAsync(ac, reason)430 d.notifyDetachedAsync(ac, reason)
365 }431 }
432+ return purgeErr
366 }433 }
367 434
368 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds435 // allocEphemeralNameLocked returns the lowest free decimal name. Caller holds
369 // d.mu.436 // d.mu.
370 func (d *Daemon) allocEphemeralNameLocked() string {437 func (d *Daemon) allocEphemeralNameLocked() string {
371- used := make(map[string]struct{}, len(d.sessions))438+ used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))
372 for _, s := range d.sessions {439 for _, s := range d.sessions {
373 used[s.name] = struct{}{}440 used[s.name] = struct{}{}
374 }441 }
442+ for name := range d.stopped {
443+ used[name] = struct{}{}
444+ }
375 for i := 0; ; i++ {445 for i := 0; ; i++ {
376 n := strconv.Itoa(i)446 n := strconv.Itoa(i)
377 if _, taken := used[n]; !taken {447 if _, taken := used[n]; !taken {
@@ -389,7 +459,75 @@ func (d *Daemon) findByNameLocked(name string) *session {
389 return nil459 return nil
390 }460 }
391 461
392-func (d *Daemon) nameTakenLocked(name string) bool { return d.findByNameLocked(name) != nil }462+func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
463+ if d.findByNameLocked(name) != nil {
464+ return true
465+ }
466+ _, ok := d.stopped[name]
467+ return ok
468+}
469+
470+func (d *Daemon) cwdSampler(ctx context.Context) {
471+ t := time.NewTicker(5 * time.Second)
472+ defer t.Stop()
473+ for {
474+ select {
475+ case <-ctx.Done():
476+ return
477+ case <-t.C:
478+ d.refreshNamedSessionCwds()
479+ }
480+ }
481+}
482+
483+func (d *Daemon) refreshNamedSessionCwds() {
484+ d.mu.Lock()
485+ sessions := d.sessionsSnapshotLocked()
486+ d.mu.Unlock()
487+ for _, sess := range sessions {
488+ sess.mu.Lock()
489+ ephemeral := sess.ephemeral
490+ sess.mu.Unlock()
491+ if !ephemeral {
492+ d.refreshSessionCwd(sess)
493+ }
494+ }
495+}
496+
497+func (d *Daemon) refreshSessionCwd(sess *session) {
498+ if d.procCwd == nil {
499+ return
500+ }
501+ tb := sess.activeTab()
502+ if tb == nil {
503+ return
504+ }
505+ cwd, err := d.procCwd(tb.pty.Pid())
506+ if err != nil || cwd == "" {
507+ return
508+ }
509+ d.mu.Lock()
510+ if d.sessions[sess.id] != sess {
511+ d.mu.Unlock()
512+ return
513+ }
514+ sess.mu.Lock()
515+ if sess.cwd == cwd {
516+ sess.mu.Unlock()
517+ d.mu.Unlock()
518+ return
519+ }
520+ sess.cwd = cwd
521+ name := sess.name
522+ ephemeral := sess.ephemeral
523+ sess.mu.Unlock()
524+ if !ephemeral {
525+ if err := d.persist.Touch(name, cwd, time.Now().UnixNano()); err != nil {
526+ d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)
527+ }
528+ }
529+ d.mu.Unlock()
530+}
393 531
394 // childEnv builds the session child's environment: the daemon's own, with TERM532 // childEnv builds the session child's environment: the daemon's own, with TERM
395 // and VEV forced to well-known values.533 // and VEV forced to well-known values.
M internal/usecase/daemon/session_test.go +231 -5
@@ -12,8 +12,10 @@ import (
1212 "github.com/stretchr/testify/require"
1313
1414 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/persist"
1516 "github.com/bnema/vev/internal/ports"
1617 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/picker"
1719 )
1820
1921 // --- test doubles -----------------------------------------------------------
@@ -100,7 +102,7 @@ func TestHandshakeNewHappy(t *testing.T) {
100102 require.NotNil(t, sess)
101103
102104 releaseConn()
103- d.killSession(sess, ports.ReasonServerShutdown)
105+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
104106 releasePTY()
105107 hg.Wait()
106108 d.sessWg.Wait()
@@ -214,7 +216,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
214216 errCh := make(chan error, 1)
215217 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()
216218 <-opened
217- d.killSession(sess, ports.ReasonSessionKilled)
219+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
218220 close(releaseOpen)
219221
220222 require.Error(t, <-errCh)
@@ -275,7 +277,7 @@ func TestAttachReplaceDetachesOld(t *testing.T) {
275277
276278 releaseA()
277279 releaseB()
278- d.killSession(sess, ports.ReasonServerShutdown)
280+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
279281 releasePTY()
280282 hg.Wait()
281283 d.sessWg.Wait()
@@ -324,7 +326,7 @@ func TestDetachKeepsNamed(t *testing.T) {
324326 require.Nil(t, sess.client, "named session is headless after detach")
325327 sess.mu.Unlock()
326328
327- d.killSession(sess, ports.ReasonServerShutdown)
329+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
328330 releasePTY()
329331 d.sessWg.Wait()
330332 d.waitNotifies()
@@ -472,7 +474,7 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
472474 require.NotNil(t, sess)
473475
474476 // The last session dies: shutdown begins irreversibly.
475- d.killSession(sess, ports.ReasonSessionKilled)
477+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
476478 select {
477479 case <-d.done:
478480 default:
@@ -580,3 +582,227 @@ func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
580582
581583 // TestSendErrorKillsEphemeral: a failed output send detaches the client, and —
582584 // like every other detach path — that kills an ephemeral session rather than
585+
586+type mockStoreState struct {
587+ mu sync.Mutex
588+ data map[string][]byte
589+ sets int
590+ dels []string
591+ syncs int
592+ closed bool
593+}
594+
595+func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
596+ t.Helper()
597+ state := &mockStoreState{data: make(map[string][]byte)}
598+ store := portsmocks.NewMockStore(t)
599+ store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(k, v []byte) error {
600+ state.mu.Lock()
601+ defer state.mu.Unlock()
602+ state.sets++
603+ state.data[string(k)] = append([]byte(nil), v...)
604+ return nil
605+ }).Maybe()
606+ store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {
607+ state.mu.Lock()
608+ defer state.mu.Unlock()
609+ state.dels = append(state.dels, string(k))
610+ delete(state.data, string(k))
611+ return nil
612+ }).Maybe()
613+ store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) {
614+ state.mu.Lock()
615+ defer state.mu.Unlock()
616+ for k, v := range state.data {
617+ if !fn([]byte(k), append([]byte(nil), v...)) {
618+ return
619+ }
620+ }
621+ }).Maybe()
622+ store.EXPECT().Sync().RunAndReturn(func() error {
623+ state.mu.Lock()
624+ defer state.mu.Unlock()
625+ state.syncs++
626+ return nil
627+ }).Maybe()
628+ store.EXPECT().Close().RunAndReturn(func() error {
629+ state.mu.Lock()
630+ defer state.mu.Unlock()
631+ state.closed = true
632+ return nil
633+ }).Maybe()
634+ return store, state
635+}
636+
637+func (s *mockStoreState) has(name string) bool {
638+ s.mu.Lock()
639+ defer s.mu.Unlock()
640+ _, ok := s.data[name]
641+ return ok
642+}
643+
644+func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
645+ store, _ := newMockStore(t)
646+ seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
647+ require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8}))
648+
649+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
650+ d.mu.Lock()
651+ stopped := d.stopped["work"]
652+ d.mu.Unlock()
653+ require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7}, stopped)
654+}
655+
656+func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
657+ sz := domain.Size{Cols: 80, Rows: 24}
658+ p, release := newBlockingPTY(t)
659+ defer release()
660+ store, state := newMockStore(t)
661+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
662+ WithStore(store)(d)
663+
664+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
665+ require.NoError(t, err)
666+ require.True(t, state.has("work"))
667+
668+ require.NoError(t, d.renameSession(sess, "renamed"))
669+ require.False(t, state.has("work"))
670+ require.True(t, state.has("renamed"))
671+
672+ _ = d.killSession(sess, ports.ReasonSessionKilled, true)
673+ require.False(t, state.has("renamed"))
674+}
675+
676+func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
677+ sz := domain.Size{Cols: 80, Rows: 24}
678+ p, release := newBlockingPTY(t)
679+ defer release()
680+ store, state := newMockStore(t)
681+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
682+ WithStore(store)(d)
683+ d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
684+
685+ sess, err := d.createSessionLocked("0", true, "/tmp/e", sz)
686+ require.NoError(t, err)
687+ require.False(t, state.has("0"))
688+ require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")
689+ require.NoError(t, d.renameSession(sess, "named"))
690+ require.True(t, state.has("named"))
691+}
692+
693+func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
694+ sz := domain.Size{Cols: 80, Rows: 24}
695+ p, release := newBlockingPTY(t)
696+ defer release()
697+ store, state := newMockStore(t)
698+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
699+ WithStore(store)(d)
700+ cwd := "/tmp/work"
701+ WithCwdReader(func(int) (string, error) { return cwd, nil })(d)
702+
703+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
704+ require.NoError(t, err)
705+ state.mu.Lock()
706+ setsAfterCreate := state.sets
707+ state.mu.Unlock()
708+
709+ d.refreshSessionCwd(sess)
710+ state.mu.Lock()
711+ require.Equal(t, setsAfterCreate, state.sets)
712+ state.mu.Unlock()
713+
714+ cwd = "/tmp/next"
715+ d.refreshSessionCwd(sess)
716+ state.mu.Lock()
717+ require.Equal(t, setsAfterCreate+1, state.sets)
718+ state.mu.Unlock()
719+}
720+
721+func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) {
722+ sz := domain.Size{Cols: 80, Rows: 24}
723+ p, release := newBlockingPTY(t)
724+ defer release()
725+ store, _ := newMockStore(t)
726+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
727+ WithStore(store)(d)
728+ cwd := t.TempDir()
729+ d.stopped["work"] = stoppedSession{name: "work", cwd: cwd, createdAt: 1}
730+ tr := portsmocks.NewMockTransport(t)
731+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
732+ tr.EXPECT().Close().Return(nil).Maybe()
733+
734+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
735+ require.NoError(t, err)
736+ require.NotNil(t, ac)
737+ require.Equal(t, "work", sess.name)
738+ require.Equal(t, cwd, sess.cwd)
739+ d.mu.Lock()
740+ _, stillStopped := d.stopped["work"]
741+ d.mu.Unlock()
742+ require.False(t, stillStopped)
743+}
744+
745+func TestAttachStoppedMissingCwdFallsBackToHome(t *testing.T) {
746+ sz := domain.Size{Cols: 80, Rows: 24}
747+ p, release := newBlockingPTY(t)
748+ defer release()
749+ store, _ := newMockStore(t)
750+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
751+ WithStore(store)(d)
752+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/definitely/missing/vev", createdAt: 1}
753+ tr := portsmocks.NewMockTransport(t)
754+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
755+ tr.EXPECT().Close().Return(nil).Maybe()
756+
757+ sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
758+ require.NoError(t, err)
759+ require.NotEqual(t, "/definitely/missing/vev", sess.cwd)
760+}
761+
762+func TestPickerStoppedTargetKillPurges(t *testing.T) {
763+ store, state := newMockStore(t)
764+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
765+ WithStore(store)(d)
766+ require.NoError(t, d.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
767+ d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}
768+ d.killPickerTarget(picker.Target{Name: "old", Stopped: true})
769+ require.False(t, state.has("old"))
770+ d.mu.Lock()
771+ _, ok := d.stopped["old"]
772+ d.mu.Unlock()
773+ require.False(t, ok)
774+}
775+
776+func TestIntentNewStoppedNameRejected(t *testing.T) {
777+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
778+ d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
779+ tr := portsmocks.NewMockTransport(t)
780+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "taken", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
781+ require.ErrorContains(t, err, "name already in use")
782+}
783+
784+func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
785+ sz := domain.Size{Cols: 80, Rows: 24}
786+ p1, release1 := newBlockingPTY(t)
787+ defer release1()
788+ p2, release2 := newBlockingPTY(t)
789+ defer release2()
790+ store, state := newMockStore(t)
791+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
792+ WithStore(store)(d)
793+ WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)
794+
795+ natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz)
796+ require.NoError(t, err)
797+ other, err := d.createSessionLocked("other", false, "/tmp/other", sz)
798+ require.NoError(t, err)
799+ _ = d.killSession(natural, ports.ReasonSessionKilled, false)
800+ require.True(t, state.has("natural"))
801+ d.mu.Lock()
802+ stopped := d.stopped["natural"]
803+ d.mu.Unlock()
804+ require.Equal(t, "/tmp/latest", stopped.cwd)
805+
806+ _ = d.killSession(other, ports.ReasonSessionKilled, true)
807+ require.False(t, state.has("other"))
808+}
@@ -12,8 +12,10 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/persist"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/picker"
17 )19 )
18 20
19 // --- test doubles -----------------------------------------------------------21 // --- test doubles -----------------------------------------------------------
@@ -100,7 +102,7 @@ func TestHandshakeNewHappy(t *testing.T) {
100 require.NotNil(t, sess)102 require.NotNil(t, sess)
101 103
102 releaseConn()104 releaseConn()
103- d.killSession(sess, ports.ReasonServerShutdown)105+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
104 releasePTY()106 releasePTY()
105 hg.Wait()107 hg.Wait()
106 d.sessWg.Wait()108 d.sessWg.Wait()
@@ -214,7 +216,7 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
214 errCh := make(chan error, 1)216 errCh := make(chan error, 1)
215 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()217 go func() { errCh <- d.createTab(sess, domain.Size{Cols: 80, Rows: 24}) }()
216 <-opened218 <-opened
217- d.killSession(sess, ports.ReasonSessionKilled)219+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
218 close(releaseOpen)220 close(releaseOpen)
219 221
220 require.Error(t, <-errCh)222 require.Error(t, <-errCh)
@@ -275,7 +277,7 @@ func TestAttachReplaceDetachesOld(t *testing.T) {
275 277
276 releaseA()278 releaseA()
277 releaseB()279 releaseB()
278- d.killSession(sess, ports.ReasonServerShutdown)280+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
279 releasePTY()281 releasePTY()
280 hg.Wait()282 hg.Wait()
281 d.sessWg.Wait()283 d.sessWg.Wait()
@@ -324,7 +326,7 @@ func TestDetachKeepsNamed(t *testing.T) {
324 require.Nil(t, sess.client, "named session is headless after detach")326 require.Nil(t, sess.client, "named session is headless after detach")
325 sess.mu.Unlock()327 sess.mu.Unlock()
326 328
327- d.killSession(sess, ports.ReasonServerShutdown)329+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
328 releasePTY()330 releasePTY()
329 d.sessWg.Wait()331 d.sessWg.Wait()
330 d.waitNotifies()332 d.waitNotifies()
@@ -472,7 +474,7 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) {
472 require.NotNil(t, sess)474 require.NotNil(t, sess)
473 475
474 // The last session dies: shutdown begins irreversibly.476 // The last session dies: shutdown begins irreversibly.
475- d.killSession(sess, ports.ReasonSessionKilled)477+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
476 select {478 select {
477 case <-d.done:479 case <-d.done:
478 default:480 default:
@@ -580,3 +582,227 @@ func TestServeReturnsDespiteWedgedClientOnShutdown(t *testing.T) {
580 582
581 // TestSendErrorKillsEphemeral: a failed output send detaches the client, and —583 // TestSendErrorKillsEphemeral: a failed output send detaches the client, and —
582 // like every other detach path — that kills an ephemeral session rather than584 // like every other detach path — that kills an ephemeral session rather than
585+
586+type mockStoreState struct {
587+ mu sync.Mutex
588+ data map[string][]byte
589+ sets int
590+ dels []string
591+ syncs int
592+ closed bool
593+}
594+
595+func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
596+ t.Helper()
597+ state := &mockStoreState{data: make(map[string][]byte)}
598+ store := portsmocks.NewMockStore(t)
599+ store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(k, v []byte) error {
600+ state.mu.Lock()
601+ defer state.mu.Unlock()
602+ state.sets++
603+ state.data[string(k)] = append([]byte(nil), v...)
604+ return nil
605+ }).Maybe()
606+ store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {
607+ state.mu.Lock()
608+ defer state.mu.Unlock()
609+ state.dels = append(state.dels, string(k))
610+ delete(state.data, string(k))
611+ return nil
612+ }).Maybe()
613+ store.EXPECT().Range(mock.Anything).Run(func(fn func(k, v []byte) bool) {
614+ state.mu.Lock()
615+ defer state.mu.Unlock()
616+ for k, v := range state.data {
617+ if !fn([]byte(k), append([]byte(nil), v...)) {
618+ return
619+ }
620+ }
621+ }).Maybe()
622+ store.EXPECT().Sync().RunAndReturn(func() error {
623+ state.mu.Lock()
624+ defer state.mu.Unlock()
625+ state.syncs++
626+ return nil
627+ }).Maybe()
628+ store.EXPECT().Close().RunAndReturn(func() error {
629+ state.mu.Lock()
630+ defer state.mu.Unlock()
631+ state.closed = true
632+ return nil
633+ }).Maybe()
634+ return store, state
635+}
636+
637+func (s *mockStoreState) has(name string) bool {
638+ s.mu.Lock()
639+ defer s.mu.Unlock()
640+ _, ok := s.data[name]
641+ return ok
642+}
643+
644+func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
645+ store, _ := newMockStore(t)
646+ seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
647+ require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8}))
648+
649+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
650+ d.mu.Lock()
651+ stopped := d.stopped["work"]
652+ d.mu.Unlock()
653+ require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7}, stopped)
654+}
655+
656+func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
657+ sz := domain.Size{Cols: 80, Rows: 24}
658+ p, release := newBlockingPTY(t)
659+ defer release()
660+ store, state := newMockStore(t)
661+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
662+ WithStore(store)(d)
663+
664+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
665+ require.NoError(t, err)
666+ require.True(t, state.has("work"))
667+
668+ require.NoError(t, d.renameSession(sess, "renamed"))
669+ require.False(t, state.has("work"))
670+ require.True(t, state.has("renamed"))
671+
672+ _ = d.killSession(sess, ports.ReasonSessionKilled, true)
673+ require.False(t, state.has("renamed"))
674+}
675+
676+func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
677+ sz := domain.Size{Cols: 80, Rows: 24}
678+ p, release := newBlockingPTY(t)
679+ defer release()
680+ store, state := newMockStore(t)
681+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
682+ WithStore(store)(d)
683+ d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
684+
685+ sess, err := d.createSessionLocked("0", true, "/tmp/e", sz)
686+ require.NoError(t, err)
687+ require.False(t, state.has("0"))
688+ require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")
689+ require.NoError(t, d.renameSession(sess, "named"))
690+ require.True(t, state.has("named"))
691+}
692+
693+func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
694+ sz := domain.Size{Cols: 80, Rows: 24}
695+ p, release := newBlockingPTY(t)
696+ defer release()
697+ store, state := newMockStore(t)
698+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
699+ WithStore(store)(d)
700+ cwd := "/tmp/work"
701+ WithCwdReader(func(int) (string, error) { return cwd, nil })(d)
702+
703+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
704+ require.NoError(t, err)
705+ state.mu.Lock()
706+ setsAfterCreate := state.sets
707+ state.mu.Unlock()
708+
709+ d.refreshSessionCwd(sess)
710+ state.mu.Lock()
711+ require.Equal(t, setsAfterCreate, state.sets)
712+ state.mu.Unlock()
713+
714+ cwd = "/tmp/next"
715+ d.refreshSessionCwd(sess)
716+ state.mu.Lock()
717+ require.Equal(t, setsAfterCreate+1, state.sets)
718+ state.mu.Unlock()
719+}
720+
721+func TestAttachResumesStoppedSessionFromStoredCwd(t *testing.T) {
722+ sz := domain.Size{Cols: 80, Rows: 24}
723+ p, release := newBlockingPTY(t)
724+ defer release()
725+ store, _ := newMockStore(t)
726+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
727+ WithStore(store)(d)
728+ cwd := t.TempDir()
729+ d.stopped["work"] = stoppedSession{name: "work", cwd: cwd, createdAt: 1}
730+ tr := portsmocks.NewMockTransport(t)
731+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
732+ tr.EXPECT().Close().Return(nil).Maybe()
733+
734+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
735+ require.NoError(t, err)
736+ require.NotNil(t, ac)
737+ require.Equal(t, "work", sess.name)
738+ require.Equal(t, cwd, sess.cwd)
739+ d.mu.Lock()
740+ _, stillStopped := d.stopped["work"]
741+ d.mu.Unlock()
742+ require.False(t, stillStopped)
743+}
744+
745+func TestAttachStoppedMissingCwdFallsBackToHome(t *testing.T) {
746+ sz := domain.Size{Cols: 80, Rows: 24}
747+ p, release := newBlockingPTY(t)
748+ defer release()
749+ store, _ := newMockStore(t)
750+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
751+ WithStore(store)(d)
752+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/definitely/missing/vev", createdAt: 1}
753+ tr := portsmocks.NewMockTransport(t)
754+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
755+ tr.EXPECT().Close().Return(nil).Maybe()
756+
757+ sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
758+ require.NoError(t, err)
759+ require.NotEqual(t, "/definitely/missing/vev", sess.cwd)
760+}
761+
762+func TestPickerStoppedTargetKillPurges(t *testing.T) {
763+ store, state := newMockStore(t)
764+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
765+ WithStore(store)(d)
766+ require.NoError(t, d.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
767+ d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}
768+ d.killPickerTarget(picker.Target{Name: "old", Stopped: true})
769+ require.False(t, state.has("old"))
770+ d.mu.Lock()
771+ _, ok := d.stopped["old"]
772+ d.mu.Unlock()
773+ require.False(t, ok)
774+}
775+
776+func TestIntentNewStoppedNameRejected(t *testing.T) {
777+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
778+ d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
779+ tr := portsmocks.NewMockTransport(t)
780+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "taken", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
781+ require.ErrorContains(t, err, "name already in use")
782+}
783+
784+func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
785+ sz := domain.Size{Cols: 80, Rows: 24}
786+ p1, release1 := newBlockingPTY(t)
787+ defer release1()
788+ p2, release2 := newBlockingPTY(t)
789+ defer release2()
790+ store, state := newMockStore(t)
791+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
792+ WithStore(store)(d)
793+ WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)
794+
795+ natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz)
796+ require.NoError(t, err)
797+ other, err := d.createSessionLocked("other", false, "/tmp/other", sz)
798+ require.NoError(t, err)
799+ _ = d.killSession(natural, ports.ReasonSessionKilled, false)
800+ require.True(t, state.has("natural"))
801+ d.mu.Lock()
802+ stopped := d.stopped["natural"]
803+ d.mu.Unlock()
804+ require.Equal(t, "/tmp/latest", stopped.cwd)
805+
806+ _ = d.killSession(other, ports.ReasonSessionKilled, true)
807+ require.False(t, state.has("other"))
808+}
M internal/usecase/picker/model.go +19 -7
@@ -15,15 +15,18 @@ const (
1515 )
1616
1717 type SessionView struct {
18- ID domain.SessionID
19- Name string
20- Tabs []string
21- Active int
18+ ID domain.SessionID
19+ Name string
20+ Tabs []string
21+ Active int
22+ Stopped bool
2223 }
2324
2425 type Target struct {
2526 Session domain.SessionID
27+ Name string
2628 TabIndex int
29+ Stopped bool
2730 }
2831
2932 type Preview struct {
@@ -46,21 +49,27 @@ type row struct {
4649 header bool
4750 label string
4851 session domain.SessionID
52+ name string
4953 tabIndex int
54+ stopped bool
5055 }
5156
5257 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
5358 m := &Model{selected: -1}
5459 activeSelection := -1
5560 for _, session := range sessions {
56- m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, tabIndex: -1})
61+ name := ""
62+ if session.Stopped {
63+ name = session.Name
64+ }
65+ m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, name: name, tabIndex: -1, stopped: session.Stopped})
5766 active := session.Active
5867 if active < 0 || active >= len(session.Tabs) {
5968 active = 0
6069 }
6170 for i, tab := range session.Tabs {
6271 idx := len(m.rows)
63- m.rows = append(m.rows, row{label: tab, session: session.ID, tabIndex: i})
72+ m.rows = append(m.rows, row{label: tab, session: session.ID, name: name, tabIndex: i, stopped: session.Stopped})
6473 if session.ID == cur && i == curTab {
6574 m.selected = idx
6675 }
@@ -122,7 +131,7 @@ func (m *Model) Selected() (Target, bool) {
122131 if r.header {
123132 return Target{}, false
124133 }
125- return Target{Session: r.session, TabIndex: r.tabIndex}, true
134+ return Target{Session: r.session, Name: r.name, TabIndex: r.tabIndex, Stopped: r.stopped}, true
126135 }
127136
128137 func (m *Model) Clone() *Model {
@@ -189,6 +198,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection ren
189198 style = selection
190199 }
191200 label := r.label
201+ if r.header && r.stopped {
202+ label += " (stopped)"
203+ }
192204 if !r.header {
193205 label = " " + label
194206 }
@@ -15,15 +15,18 @@ const (
15 )15 )
16 16
17 type SessionView struct {17 type SessionView struct {
18- ID domain.SessionID18+ ID domain.SessionID
19- Name string19+ Name string
20- Tabs []string20+ Tabs []string
21- Active int21+ Active int
22+ Stopped bool
22 }23 }
23 24
24 type Target struct {25 type Target struct {
25 Session domain.SessionID26 Session domain.SessionID
27+ Name string
26 TabIndex int28 TabIndex int
29+ Stopped bool
27 }30 }
28 31
29 type Preview struct {32 type Preview struct {
@@ -46,21 +49,27 @@ type row struct {
46 header bool49 header bool
47 label string50 label string
48 session domain.SessionID51 session domain.SessionID
52+ name string
49 tabIndex int53 tabIndex int
54+ stopped bool
50 }55 }
51 56
52 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {57 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
53 m := &Model{selected: -1}58 m := &Model{selected: -1}
54 activeSelection := -159 activeSelection := -1
55 for _, session := range sessions {60 for _, session := range sessions {
56- m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, tabIndex: -1})61+ name := ""
62+ if session.Stopped {
63+ name = session.Name
64+ }
65+ m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, name: name, tabIndex: -1, stopped: session.Stopped})
57 active := session.Active66 active := session.Active
58 if active < 0 || active >= len(session.Tabs) {67 if active < 0 || active >= len(session.Tabs) {
59 active = 068 active = 0
60 }69 }
61 for i, tab := range session.Tabs {70 for i, tab := range session.Tabs {
62 idx := len(m.rows)71 idx := len(m.rows)
63- m.rows = append(m.rows, row{label: tab, session: session.ID, tabIndex: i})72+ m.rows = append(m.rows, row{label: tab, session: session.ID, name: name, tabIndex: i, stopped: session.Stopped})
64 if session.ID == cur && i == curTab {73 if session.ID == cur && i == curTab {
65 m.selected = idx74 m.selected = idx
66 }75 }
@@ -122,7 +131,7 @@ func (m *Model) Selected() (Target, bool) {
122 if r.header {131 if r.header {
123 return Target{}, false132 return Target{}, false
124 }133 }
125- return Target{Session: r.session, TabIndex: r.tabIndex}, true134+ return Target{Session: r.session, Name: r.name, TabIndex: r.tabIndex, Stopped: r.stopped}, true
126 }135 }
127 136
128 func (m *Model) Clone() *Model {137 func (m *Model) Clone() *Model {
@@ -189,6 +198,9 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection ren
189 style = selection198 style = selection
190 }199 }
191 label := r.label200 label := r.label
201+ if r.header && r.stopped {
202+ label += " (stopped)"
203+ }
192 if !r.header {204 if !r.header {
193 label = " " + label205 label = " " + label
194 }206 }
M internal/usecase/picker/model_test.go +10 -0
@@ -101,6 +101,16 @@ func TestSelectedMapping(t *testing.T) {
101101 require.Equal(t, Target{Session: "beta", TabIndex: 1}, got)
102102 }
103103
104+func TestStoppedSessionSelectableAndRendered(t *testing.T) {
105+ m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []string{"(stopped)"}, Stopped: true}}, "", 0)
106+ got, ok := m.Selected()
107+ require.True(t, ok)
108+ require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)
109+ frame := m.Render(domain.Size{Cols: 24, Rows: 4}, Preview{})
110+ require.Equal(t, 'w', frame.At(0, 0).Rune)
111+ require.Equal(t, '(', frame.At(5, 0).Rune)
112+}
113+
104114 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
105115 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
106116 preview := Preview{
@@ -101,6 +101,16 @@ func TestSelectedMapping(t *testing.T) {
101 require.Equal(t, Target{Session: "beta", TabIndex: 1}, got)101 require.Equal(t, Target{Session: "beta", TabIndex: 1}, got)
102 }102 }
103 103
104+func TestStoppedSessionSelectableAndRendered(t *testing.T) {
105+ m := New([]SessionView{{ID: "stopped:work", Name: "work", Tabs: []string{"(stopped)"}, Stopped: true}}, "", 0)
106+ got, ok := m.Selected()
107+ require.True(t, ok)
108+ require.Equal(t, Target{Session: "stopped:work", Name: "work", TabIndex: 0, Stopped: true}, got)
109+ frame := m.Render(domain.Size{Cols: 24, Rows: 4}, Preview{})
110+ require.Equal(t, 'w', frame.At(0, 0).Rune)
111+ require.Equal(t, '(', frame.At(5, 0).Rune)
112+}
113+
104 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {114 func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
105 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)115 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
106 preview := Preview{116 preview := Preview{
A pkg/kv/codec.go +77 -0
@@ -0,0 +1,77 @@
1+package kv
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "hash/crc32"
7+)
8+
9+const (
10+ opSet byte = 0
11+ opDel byte = 1
12+
13+ headerLen = 8
14+ payloadPrefixLen = 3
15+ compactThreshold = 64
16+ compactWasteRatio = 0.5
17+ maxKeyLen = 1<<16 - 1
18+)
19+
20+var errBadRecord = errors.New("bad record")
21+
22+type record struct {
23+ op byte
24+ key []byte
25+ value []byte
26+}
27+
28+func encodeRecord(op byte, key, value []byte) ([]byte, error) {
29+ if len(key) > maxKeyLen {
30+ return nil, errors.New("key too large")
31+ }
32+ if op != opSet && op != opDel {
33+ return nil, errBadRecord
34+ }
35+
36+ payloadLen := payloadPrefixLen + len(key)
37+ if op == opSet {
38+ payloadLen += len(value)
39+ }
40+
41+ buf := make([]byte, headerLen+payloadLen)
42+ payload := buf[headerLen:]
43+ payload[0] = op
44+ binary.BigEndian.PutUint16(payload[1:3], uint16(len(key)))
45+ copy(payload[3:], key)
46+ if op == opSet {
47+ copy(payload[3+len(key):], value)
48+ }
49+
50+ binary.BigEndian.PutUint32(buf[0:4], uint32(payloadLen))
51+ binary.BigEndian.PutUint32(buf[4:8], crc32.ChecksumIEEE(payload))
52+ return buf, nil
53+}
54+
55+func decodePayload(payload []byte) (record, error) {
56+ if len(payload) < payloadPrefixLen {
57+ return record{}, errBadRecord
58+ }
59+ op := payload[0]
60+ if op != opSet && op != opDel {
61+ return record{}, errBadRecord
62+ }
63+ keyLen := int(binary.BigEndian.Uint16(payload[1:3]))
64+ if len(payload) < payloadPrefixLen+keyLen {
65+ return record{}, errBadRecord
66+ }
67+ if op == opDel && len(payload) != payloadPrefixLen+keyLen {
68+ return record{}, errBadRecord
69+ }
70+
71+ key := append([]byte(nil), payload[payloadPrefixLen:payloadPrefixLen+keyLen]...)
72+ var val []byte
73+ if op == opSet {
74+ val = append([]byte(nil), payload[payloadPrefixLen+keyLen:]...)
75+ }
76+ return record{op: op, key: key, value: val}, nil
77+}
@@ -0,0 +1,77 @@
1+package kv
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "hash/crc32"
7+)
8+
9+const (
10+ opSet byte = 0
11+ opDel byte = 1
12+
13+ headerLen = 8
14+ payloadPrefixLen = 3
15+ compactThreshold = 64
16+ compactWasteRatio = 0.5
17+ maxKeyLen = 1<<16 - 1
18+)
19+
20+var errBadRecord = errors.New("bad record")
21+
22+type record struct {
23+ op byte
24+ key []byte
25+ value []byte
26+}
27+
28+func encodeRecord(op byte, key, value []byte) ([]byte, error) {
29+ if len(key) > maxKeyLen {
30+ return nil, errors.New("key too large")
31+ }
32+ if op != opSet && op != opDel {
33+ return nil, errBadRecord
34+ }
35+
36+ payloadLen := payloadPrefixLen + len(key)
37+ if op == opSet {
38+ payloadLen += len(value)
39+ }
40+
41+ buf := make([]byte, headerLen+payloadLen)
42+ payload := buf[headerLen:]
43+ payload[0] = op
44+ binary.BigEndian.PutUint16(payload[1:3], uint16(len(key)))
45+ copy(payload[3:], key)
46+ if op == opSet {
47+ copy(payload[3+len(key):], value)
48+ }
49+
50+ binary.BigEndian.PutUint32(buf[0:4], uint32(payloadLen))
51+ binary.BigEndian.PutUint32(buf[4:8], crc32.ChecksumIEEE(payload))
52+ return buf, nil
53+}
54+
55+func decodePayload(payload []byte) (record, error) {
56+ if len(payload) < payloadPrefixLen {
57+ return record{}, errBadRecord
58+ }
59+ op := payload[0]
60+ if op != opSet && op != opDel {
61+ return record{}, errBadRecord
62+ }
63+ keyLen := int(binary.BigEndian.Uint16(payload[1:3]))
64+ if len(payload) < payloadPrefixLen+keyLen {
65+ return record{}, errBadRecord
66+ }
67+ if op == opDel && len(payload) != payloadPrefixLen+keyLen {
68+ return record{}, errBadRecord
69+ }
70+
71+ key := append([]byte(nil), payload[payloadPrefixLen:payloadPrefixLen+keyLen]...)
72+ var val []byte
73+ if op == opSet {
74+ val = append([]byte(nil), payload[payloadPrefixLen+keyLen:]...)
75+ }
76+ return record{op: op, key: key, value: val}, nil
77+}
A pkg/kv/kv.go +358 -0
@@ -0,0 +1,358 @@
1+package kv
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "hash/crc32"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+ "sync"
12+ "syscall"
13+)
14+
15+// Store is an append-only WAL-backed key/value store.
16+type Store struct {
17+ mu sync.Mutex
18+ path string
19+ file *os.File
20+ lockFile *os.File
21+ data map[string][]byte
22+ entrySize map[string]int64
23+ total int64
24+ live int64
25+ closed bool
26+}
27+
28+// Open opens or creates a store at path, replays valid records, truncates any
29+// corrupt or torn tail, and compacts if the file contains enough obsolete data.
30+func Open(path string) (*Store, error) {
31+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
32+ return nil, err
33+ }
34+
35+ lockFile, err := acquireLock(path)
36+ if err != nil {
37+ return nil, err
38+ }
39+
40+ f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
41+ if err != nil {
42+ _ = releaseLock(lockFile)
43+ return nil, err
44+ }
45+
46+ data, sizes, total, live, lastGood, err := replayFile(f)
47+ if err != nil {
48+ _ = f.Close()
49+ _ = releaseLock(lockFile)
50+ return nil, err
51+ }
52+ if err := f.Truncate(lastGood); err != nil {
53+ _ = f.Close()
54+ _ = releaseLock(lockFile)
55+ return nil, err
56+ }
57+ if _, err := f.Seek(0, io.SeekEnd); err != nil {
58+ _ = f.Close()
59+ _ = releaseLock(lockFile)
60+ return nil, err
61+ }
62+
63+ s := &Store{path: path, file: f, lockFile: lockFile, data: data, entrySize: sizes, total: total, live: live}
64+ if s.shouldCompact() {
65+ if err := s.compactLocked(); err != nil {
66+ _ = f.Close()
67+ _ = releaseLock(lockFile)
68+ return nil, err
69+ }
70+ }
71+ return s, nil
72+}
73+
74+// Replay reads path without mutating it and returns the state from valid records.
75+func Replay(path string) (map[string][]byte, error) {
76+ f, err := os.Open(path)
77+ if errors.Is(err, os.ErrNotExist) {
78+ return map[string][]byte{}, nil
79+ }
80+ if err != nil {
81+ return nil, err
82+ }
83+ defer func() { _ = f.Close() }()
84+
85+ data, _, _, _, _, err := replayFile(f)
86+ if err != nil {
87+ return nil, err
88+ }
89+ return data, nil
90+}
91+
92+// Get returns a copy of the value for key.
93+func (s *Store) Get(key []byte) ([]byte, bool) {
94+ s.mu.Lock()
95+ defer s.mu.Unlock()
96+
97+ v, ok := s.data[string(key)]
98+ if !ok {
99+ return nil, false
100+ }
101+ return append([]byte(nil), v...), true
102+}
103+
104+// Set appends a SET record. It does not fsync.
105+func (s *Store) Set(key, val []byte) error {
106+ return s.appendRecord(opSet, key, val)
107+}
108+
109+// Delete appends a DEL tombstone. It does not fsync.
110+func (s *Store) Delete(key []byte) error {
111+ return s.appendRecord(opDel, key, nil)
112+}
113+
114+// Range calls fn for each key/value pair, using copies. Iteration stops when fn returns false.
115+func (s *Store) Range(fn func(k, v []byte) bool) {
116+ s.mu.Lock()
117+ keys := make([]string, 0, len(s.data))
118+ for k := range s.data {
119+ keys = append(keys, k)
120+ }
121+ sort.Strings(keys)
122+ pairs := make([]struct{ k, v []byte }, 0, len(keys))
123+ for _, k := range keys {
124+ pairs = append(pairs, struct{ k, v []byte }{[]byte(k), append([]byte(nil), s.data[k]...)})
125+ }
126+ s.mu.Unlock()
127+
128+ for _, p := range pairs {
129+ if !fn(p.k, p.v) {
130+ return
131+ }
132+ }
133+}
134+
135+// Sync fsyncs the WAL file.
136+func (s *Store) Sync() error {
137+ s.mu.Lock()
138+ defer s.mu.Unlock()
139+ if s.closed {
140+ return os.ErrClosed
141+ }
142+ return s.file.Sync()
143+}
144+
145+// Close fsyncs and closes the store.
146+func (s *Store) Close() error {
147+ s.mu.Lock()
148+ defer s.mu.Unlock()
149+ if s.closed {
150+ return nil
151+ }
152+ errSync := s.file.Sync()
153+ errClose := s.file.Close()
154+ errUnlock := releaseLock(s.lockFile)
155+ s.closed = true
156+ if errSync != nil {
157+ return errSync
158+ }
159+ if errClose != nil {
160+ return errClose
161+ }
162+ return errUnlock
163+}
164+
165+func acquireLock(path string) (*os.File, error) {
166+ lf, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
167+ if err != nil {
168+ return nil, err
169+ }
170+ if err := syscall.Flock(int(lf.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
171+ _ = lf.Close()
172+ return nil, err
173+ }
174+ return lf, nil
175+}
176+
177+func releaseLock(f *os.File) error {
178+ if f == nil {
179+ return nil
180+ }
181+ errUnlock := syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
182+ errClose := f.Close()
183+ if errUnlock != nil {
184+ return errUnlock
185+ }
186+ return errClose
187+}
188+
189+func (s *Store) appendRecord(op byte, key, val []byte) error {
190+ buf, err := encodeRecord(op, key, val)
191+ if err != nil {
192+ return err
193+ }
194+
195+ s.mu.Lock()
196+ defer s.mu.Unlock()
197+ if s.closed {
198+ return os.ErrClosed
199+ }
200+ pos, err := s.file.Seek(0, io.SeekCurrent)
201+ if err != nil {
202+ return err
203+ }
204+ if _, err := s.file.Write(buf); err != nil {
205+ _ = s.file.Truncate(pos)
206+ _, _ = s.file.Seek(pos, io.SeekStart)
207+ return err
208+ }
209+
210+ k := string(key)
211+ if old, ok := s.entrySize[k]; ok {
212+ s.live -= old
213+ }
214+ s.total += int64(len(buf))
215+ if op == opSet {
216+ s.data[k] = append([]byte(nil), val...)
217+ s.entrySize[k] = int64(len(buf))
218+ s.live += int64(len(buf))
219+ } else {
220+ delete(s.data, k)
221+ delete(s.entrySize, k)
222+ }
223+ if s.shouldCompact() {
224+ return s.compactLocked()
225+ }
226+ return nil
227+}
228+
229+func (s *Store) shouldCompact() bool {
230+ return s.total > compactThreshold && float64(s.total-s.live)/float64(s.total) > compactWasteRatio
231+}
232+
233+func (s *Store) compactLocked() error {
234+ tmp := s.path + ".compact"
235+ tf, err := os.OpenFile(tmp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
236+ if err != nil {
237+ return err
238+ }
239+
240+ keys := make([]string, 0, len(s.data))
241+ for k := range s.data {
242+ keys = append(keys, k)
243+ }
244+ sort.Strings(keys)
245+
246+ newSizes := make(map[string]int64, len(keys))
247+ var newTotal, newLive int64
248+ for _, k := range keys {
249+ buf, err := encodeRecord(opSet, []byte(k), s.data[k])
250+ if err != nil {
251+ _ = tf.Close()
252+ _ = os.Remove(tmp)
253+ return err
254+ }
255+ if _, err := tf.Write(buf); err != nil {
256+ _ = tf.Close()
257+ _ = os.Remove(tmp)
258+ return err
259+ }
260+ sz := int64(len(buf))
261+ newSizes[k] = sz
262+ newTotal += sz
263+ newLive += sz
264+ }
265+ if err := tf.Sync(); err != nil {
266+ _ = tf.Close()
267+ _ = os.Remove(tmp)
268+ return err
269+ }
270+ if err := tf.Close(); err != nil {
271+ _ = os.Remove(tmp)
272+ return err
273+ }
274+ if err := s.file.Close(); err != nil {
275+ _ = os.Remove(tmp)
276+ return err
277+ }
278+ if err := os.Rename(tmp, s.path); err != nil {
279+ return err
280+ }
281+ f, err := os.OpenFile(s.path, os.O_RDWR, 0o600)
282+ if err != nil {
283+ return err
284+ }
285+ if _, err := f.Seek(0, io.SeekEnd); err != nil {
286+ _ = f.Close()
287+ return err
288+ }
289+ s.file = f
290+ s.entrySize = newSizes
291+ s.total = newTotal
292+ s.live = newLive
293+ return nil
294+}
295+
296+func replayFile(f *os.File) (map[string][]byte, map[string]int64, int64, int64, int64, error) {
297+ info, err := f.Stat()
298+ if err != nil {
299+ return nil, nil, 0, 0, 0, err
300+ }
301+ fileSize := info.Size()
302+ if _, err := f.Seek(0, io.SeekStart); err != nil {
303+ return nil, nil, 0, 0, 0, err
304+ }
305+ data := make(map[string][]byte)
306+ sizes := make(map[string]int64)
307+ var total, live, off int64
308+ header := make([]byte, headerLen)
309+ for {
310+ n, err := io.ReadFull(f, header)
311+ if errors.Is(err, io.EOF) {
312+ break
313+ }
314+ if errors.Is(err, io.ErrUnexpectedEOF) {
315+ _ = n
316+ break
317+ }
318+ if err != nil {
319+ return nil, nil, 0, 0, 0, err
320+ }
321+
322+ payloadLen := binary.BigEndian.Uint32(header[0:4])
323+ if int64(payloadLen) > fileSize-off-headerLen {
324+ break
325+ }
326+ wantCRC := binary.BigEndian.Uint32(header[4:8])
327+ payload := make([]byte, int(payloadLen))
328+ if _, err := io.ReadFull(f, payload); errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
329+ break
330+ } else if err != nil {
331+ return nil, nil, 0, 0, 0, err
332+ }
333+ if crc32.ChecksumIEEE(payload) != wantCRC {
334+ break
335+ }
336+ rec, err := decodePayload(payload)
337+ if err != nil {
338+ break
339+ }
340+
341+ sz := int64(headerLen + len(payload))
342+ k := string(rec.key)
343+ if old, ok := sizes[k]; ok {
344+ live -= old
345+ }
346+ total += sz
347+ if rec.op == opSet {
348+ data[k] = rec.value
349+ sizes[k] = sz
350+ live += sz
351+ } else {
352+ delete(data, k)
353+ delete(sizes, k)
354+ }
355+ off += sz
356+ }
357+ return data, sizes, total, live, off, nil
358+}
@@ -0,0 +1,358 @@
1+package kv
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "hash/crc32"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+ "sync"
12+ "syscall"
13+)
14+
15+// Store is an append-only WAL-backed key/value store.
16+type Store struct {
17+ mu sync.Mutex
18+ path string
19+ file *os.File
20+ lockFile *os.File
21+ data map[string][]byte
22+ entrySize map[string]int64
23+ total int64
24+ live int64
25+ closed bool
26+}
27+
28+// Open opens or creates a store at path, replays valid records, truncates any
29+// corrupt or torn tail, and compacts if the file contains enough obsolete data.
30+func Open(path string) (*Store, error) {
31+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
32+ return nil, err
33+ }
34+
35+ lockFile, err := acquireLock(path)
36+ if err != nil {
37+ return nil, err
38+ }
39+
40+ f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
41+ if err != nil {
42+ _ = releaseLock(lockFile)
43+ return nil, err
44+ }
45+
46+ data, sizes, total, live, lastGood, err := replayFile(f)
47+ if err != nil {
48+ _ = f.Close()
49+ _ = releaseLock(lockFile)
50+ return nil, err
51+ }
52+ if err := f.Truncate(lastGood); err != nil {
53+ _ = f.Close()
54+ _ = releaseLock(lockFile)
55+ return nil, err
56+ }
57+ if _, err := f.Seek(0, io.SeekEnd); err != nil {
58+ _ = f.Close()
59+ _ = releaseLock(lockFile)
60+ return nil, err
61+ }
62+
63+ s := &Store{path: path, file: f, lockFile: lockFile, data: data, entrySize: sizes, total: total, live: live}
64+ if s.shouldCompact() {
65+ if err := s.compactLocked(); err != nil {
66+ _ = f.Close()
67+ _ = releaseLock(lockFile)
68+ return nil, err
69+ }
70+ }
71+ return s, nil
72+}
73+
74+// Replay reads path without mutating it and returns the state from valid records.
75+func Replay(path string) (map[string][]byte, error) {
76+ f, err := os.Open(path)
77+ if errors.Is(err, os.ErrNotExist) {
78+ return map[string][]byte{}, nil
79+ }
80+ if err != nil {
81+ return nil, err
82+ }
83+ defer func() { _ = f.Close() }()
84+
85+ data, _, _, _, _, err := replayFile(f)
86+ if err != nil {
87+ return nil, err
88+ }
89+ return data, nil
90+}
91+
92+// Get returns a copy of the value for key.
93+func (s *Store) Get(key []byte) ([]byte, bool) {
94+ s.mu.Lock()
95+ defer s.mu.Unlock()
96+
97+ v, ok := s.data[string(key)]
98+ if !ok {
99+ return nil, false
100+ }
101+ return append([]byte(nil), v...), true
102+}
103+
104+// Set appends a SET record. It does not fsync.
105+func (s *Store) Set(key, val []byte) error {
106+ return s.appendRecord(opSet, key, val)
107+}
108+
109+// Delete appends a DEL tombstone. It does not fsync.
110+func (s *Store) Delete(key []byte) error {
111+ return s.appendRecord(opDel, key, nil)
112+}
113+
114+// Range calls fn for each key/value pair, using copies. Iteration stops when fn returns false.
115+func (s *Store) Range(fn func(k, v []byte) bool) {
116+ s.mu.Lock()
117+ keys := make([]string, 0, len(s.data))
118+ for k := range s.data {
119+ keys = append(keys, k)
120+ }
121+ sort.Strings(keys)
122+ pairs := make([]struct{ k, v []byte }, 0, len(keys))
123+ for _, k := range keys {
124+ pairs = append(pairs, struct{ k, v []byte }{[]byte(k), append([]byte(nil), s.data[k]...)})
125+ }
126+ s.mu.Unlock()
127+
128+ for _, p := range pairs {
129+ if !fn(p.k, p.v) {
130+ return
131+ }
132+ }
133+}
134+
135+// Sync fsyncs the WAL file.
136+func (s *Store) Sync() error {
137+ s.mu.Lock()
138+ defer s.mu.Unlock()
139+ if s.closed {
140+ return os.ErrClosed
141+ }
142+ return s.file.Sync()
143+}
144+
145+// Close fsyncs and closes the store.
146+func (s *Store) Close() error {
147+ s.mu.Lock()
148+ defer s.mu.Unlock()
149+ if s.closed {
150+ return nil
151+ }
152+ errSync := s.file.Sync()
153+ errClose := s.file.Close()
154+ errUnlock := releaseLock(s.lockFile)
155+ s.closed = true
156+ if errSync != nil {
157+ return errSync
158+ }
159+ if errClose != nil {
160+ return errClose
161+ }
162+ return errUnlock
163+}
164+
165+func acquireLock(path string) (*os.File, error) {
166+ lf, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600)
167+ if err != nil {
168+ return nil, err
169+ }
170+ if err := syscall.Flock(int(lf.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
171+ _ = lf.Close()
172+ return nil, err
173+ }
174+ return lf, nil
175+}
176+
177+func releaseLock(f *os.File) error {
178+ if f == nil {
179+ return nil
180+ }
181+ errUnlock := syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
182+ errClose := f.Close()
183+ if errUnlock != nil {
184+ return errUnlock
185+ }
186+ return errClose
187+}
188+
189+func (s *Store) appendRecord(op byte, key, val []byte) error {
190+ buf, err := encodeRecord(op, key, val)
191+ if err != nil {
192+ return err
193+ }
194+
195+ s.mu.Lock()
196+ defer s.mu.Unlock()
197+ if s.closed {
198+ return os.ErrClosed
199+ }
200+ pos, err := s.file.Seek(0, io.SeekCurrent)
201+ if err != nil {
202+ return err
203+ }
204+ if _, err := s.file.Write(buf); err != nil {
205+ _ = s.file.Truncate(pos)
206+ _, _ = s.file.Seek(pos, io.SeekStart)
207+ return err
208+ }
209+
210+ k := string(key)
211+ if old, ok := s.entrySize[k]; ok {
212+ s.live -= old
213+ }
214+ s.total += int64(len(buf))
215+ if op == opSet {
216+ s.data[k] = append([]byte(nil), val...)
217+ s.entrySize[k] = int64(len(buf))
218+ s.live += int64(len(buf))
219+ } else {
220+ delete(s.data, k)
221+ delete(s.entrySize, k)
222+ }
223+ if s.shouldCompact() {
224+ return s.compactLocked()
225+ }
226+ return nil
227+}
228+
229+func (s *Store) shouldCompact() bool {
230+ return s.total > compactThreshold && float64(s.total-s.live)/float64(s.total) > compactWasteRatio
231+}
232+
233+func (s *Store) compactLocked() error {
234+ tmp := s.path + ".compact"
235+ tf, err := os.OpenFile(tmp, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
236+ if err != nil {
237+ return err
238+ }
239+
240+ keys := make([]string, 0, len(s.data))
241+ for k := range s.data {
242+ keys = append(keys, k)
243+ }
244+ sort.Strings(keys)
245+
246+ newSizes := make(map[string]int64, len(keys))
247+ var newTotal, newLive int64
248+ for _, k := range keys {
249+ buf, err := encodeRecord(opSet, []byte(k), s.data[k])
250+ if err != nil {
251+ _ = tf.Close()
252+ _ = os.Remove(tmp)
253+ return err
254+ }
255+ if _, err := tf.Write(buf); err != nil {
256+ _ = tf.Close()
257+ _ = os.Remove(tmp)
258+ return err
259+ }
260+ sz := int64(len(buf))
261+ newSizes[k] = sz
262+ newTotal += sz
263+ newLive += sz
264+ }
265+ if err := tf.Sync(); err != nil {
266+ _ = tf.Close()
267+ _ = os.Remove(tmp)
268+ return err
269+ }
270+ if err := tf.Close(); err != nil {
271+ _ = os.Remove(tmp)
272+ return err
273+ }
274+ if err := s.file.Close(); err != nil {
275+ _ = os.Remove(tmp)
276+ return err
277+ }
278+ if err := os.Rename(tmp, s.path); err != nil {
279+ return err
280+ }
281+ f, err := os.OpenFile(s.path, os.O_RDWR, 0o600)
282+ if err != nil {
283+ return err
284+ }
285+ if _, err := f.Seek(0, io.SeekEnd); err != nil {
286+ _ = f.Close()
287+ return err
288+ }
289+ s.file = f
290+ s.entrySize = newSizes
291+ s.total = newTotal
292+ s.live = newLive
293+ return nil
294+}
295+
296+func replayFile(f *os.File) (map[string][]byte, map[string]int64, int64, int64, int64, error) {
297+ info, err := f.Stat()
298+ if err != nil {
299+ return nil, nil, 0, 0, 0, err
300+ }
301+ fileSize := info.Size()
302+ if _, err := f.Seek(0, io.SeekStart); err != nil {
303+ return nil, nil, 0, 0, 0, err
304+ }
305+ data := make(map[string][]byte)
306+ sizes := make(map[string]int64)
307+ var total, live, off int64
308+ header := make([]byte, headerLen)
309+ for {
310+ n, err := io.ReadFull(f, header)
311+ if errors.Is(err, io.EOF) {
312+ break
313+ }
314+ if errors.Is(err, io.ErrUnexpectedEOF) {
315+ _ = n
316+ break
317+ }
318+ if err != nil {
319+ return nil, nil, 0, 0, 0, err
320+ }
321+
322+ payloadLen := binary.BigEndian.Uint32(header[0:4])
323+ if int64(payloadLen) > fileSize-off-headerLen {
324+ break
325+ }
326+ wantCRC := binary.BigEndian.Uint32(header[4:8])
327+ payload := make([]byte, int(payloadLen))
328+ if _, err := io.ReadFull(f, payload); errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
329+ break
330+ } else if err != nil {
331+ return nil, nil, 0, 0, 0, err
332+ }
333+ if crc32.ChecksumIEEE(payload) != wantCRC {
334+ break
335+ }
336+ rec, err := decodePayload(payload)
337+ if err != nil {
338+ break
339+ }
340+
341+ sz := int64(headerLen + len(payload))
342+ k := string(rec.key)
343+ if old, ok := sizes[k]; ok {
344+ live -= old
345+ }
346+ total += sz
347+ if rec.op == opSet {
348+ data[k] = rec.value
349+ sizes[k] = sz
350+ live += sz
351+ } else {
352+ delete(data, k)
353+ delete(sizes, k)
354+ }
355+ off += sz
356+ }
357+ return data, sizes, total, live, off, nil
358+}
A pkg/kv/kv_test.go +245 -0
@@ -0,0 +1,245 @@
1+package kv
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+)
10+
11+func closeStore(t *testing.T, s *Store) {
12+ t.Helper()
13+ if err := s.Close(); err != nil {
14+ t.Fatal(err)
15+ }
16+}
17+
18+func TestSetGetDeleteRange(t *testing.T) {
19+ path := filepath.Join(t.TempDir(), "store.kv")
20+ s, err := Open(path)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ defer closeStore(t, s)
25+
26+ if err := s.Set([]byte("b"), []byte("two")); err != nil {
27+ t.Fatal(err)
28+ }
29+ if err := s.Set([]byte("a"), []byte("one")); err != nil {
30+ t.Fatal(err)
31+ }
32+ got, ok := s.Get([]byte("a"))
33+ if !ok || string(got) != "one" {
34+ t.Fatalf("Get(a) = %q, %v", got, ok)
35+ }
36+ got[0] = 'X'
37+ got, _ = s.Get([]byte("a"))
38+ if string(got) != "one" {
39+ t.Fatalf("Get returned mutable value: %q", got)
40+ }
41+ if err := s.Delete([]byte("b")); err != nil {
42+ t.Fatal(err)
43+ }
44+ if _, ok := s.Get([]byte("b")); ok {
45+ t.Fatal("deleted key still present")
46+ }
47+
48+ seen := map[string]string{}
49+ s.Range(func(k, v []byte) bool {
50+ seen[string(k)] = string(v)
51+ return true
52+ })
53+ if len(seen) != 1 || seen["a"] != "one" {
54+ t.Fatalf("Range saw %#v", seen)
55+ }
56+}
57+
58+func TestReopenDurability(t *testing.T) {
59+ path := filepath.Join(t.TempDir(), "store.kv")
60+ s, err := Open(path)
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ if err := s.Set([]byte("k"), []byte("v")); err != nil {
65+ t.Fatal(err)
66+ }
67+ if err := s.Close(); err != nil {
68+ t.Fatal(err)
69+ }
70+
71+ s, err = Open(path)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ defer closeStore(t, s)
76+ got, ok := s.Get([]byte("k"))
77+ if !ok || string(got) != "v" {
78+ t.Fatalf("after reopen Get(k) = %q, %v", got, ok)
79+ }
80+}
81+
82+func TestCompactionShrinksWithDataIntact(t *testing.T) {
83+ path := filepath.Join(t.TempDir(), "store.kv")
84+ s, err := Open(path)
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ if err := s.Set([]byte("keep"), bytes.Repeat([]byte("x"), 128)); err != nil {
89+ t.Fatal(err)
90+ }
91+ info, err := os.Stat(path)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ before := info.Size()
96+ if err := s.Set([]byte("keep"), []byte("y")); err != nil {
97+ t.Fatal(err)
98+ }
99+ info, err = os.Stat(path)
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ after := info.Size()
104+ if after >= before {
105+ t.Fatalf("expected compaction to shrink file: before=%d after=%d", before, after)
106+ }
107+ got, ok := s.Get([]byte("keep"))
108+ if !ok || string(got) != "y" {
109+ t.Fatalf("compacted value = %q, %v", got, ok)
110+ }
111+ if err := s.Close(); err != nil {
112+ t.Fatal(err)
113+ }
114+}
115+
116+func TestOpenTruncatesMidRecordTail(t *testing.T) {
117+ path := filepath.Join(t.TempDir(), "store.kv")
118+ first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
119+ second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
120+ if err := os.WriteFile(path, append(first, second[:len(second)/2]...), 0o600); err != nil {
121+ t.Fatal(err)
122+ }
123+
124+ s, err := Open(path)
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ defer closeStore(t, s)
129+ if got, ok := s.Get([]byte("a")); !ok || string(got) != "one" {
130+ t.Fatalf("good record missing: %q %v", got, ok)
131+ }
132+ if _, ok := s.Get([]byte("b")); ok {
133+ t.Fatal("torn record was replayed")
134+ }
135+ info, err := os.Stat(path)
136+ if err != nil {
137+ t.Fatal(err)
138+ }
139+ if info.Size() != int64(len(first)) {
140+ t.Fatalf("tail not truncated: size=%d want=%d", info.Size(), len(first))
141+ }
142+}
143+
144+func TestCRCcorruptionOfLastRecordDropsIt(t *testing.T) {
145+ path := filepath.Join(t.TempDir(), "store.kv")
146+ first, _ := encodeRecord(opSet, []byte("k"), []byte("old"))
147+ second, _ := encodeRecord(opSet, []byte("k"), []byte("new"))
148+ buf := append(append([]byte{}, first...), second...)
149+ buf[len(first)+headerLen+payloadPrefixLen+1] ^= 0xff
150+ if err := os.WriteFile(path, buf, 0o600); err != nil {
151+ t.Fatal(err)
152+ }
153+
154+ s, err := Open(path)
155+ if err != nil {
156+ t.Fatal(err)
157+ }
158+ defer closeStore(t, s)
159+ got, ok := s.Get([]byte("k"))
160+ if !ok || string(got) != "old" {
161+ t.Fatalf("corrupt last record not dropped: %q %v", got, ok)
162+ }
163+ info, err := os.Stat(path)
164+ if err != nil {
165+ t.Fatal(err)
166+ }
167+ if info.Size() != int64(len(first)) {
168+ t.Fatalf("corrupt tail not truncated: size=%d want=%d", info.Size(), len(first))
169+ }
170+}
171+
172+func TestAbsentAndEmptyFile(t *testing.T) {
173+ path := filepath.Join(t.TempDir(), "missing", "store.kv")
174+ m, err := Replay(path)
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ if len(m) != 0 {
179+ t.Fatalf("Replay(absent) = %#v", m)
180+ }
181+ s, err := Open(path)
182+ if err != nil {
183+ t.Fatal(err)
184+ }
185+ if err := s.Close(); err != nil {
186+ t.Fatal(err)
187+ }
188+ m, err = Replay(path)
189+ if err != nil {
190+ t.Fatal(err)
191+ }
192+ if len(m) != 0 {
193+ t.Fatalf("Replay(empty) = %#v", m)
194+ }
195+}
196+
197+func TestTombstoneSurvivesCompaction(t *testing.T) {
198+ path := filepath.Join(t.TempDir(), "store.kv")
199+ s, err := Open(path)
200+ if err != nil {
201+ t.Fatal(err)
202+ }
203+ if err := s.Set([]byte("dead"), bytes.Repeat([]byte("d"), 128)); err != nil {
204+ t.Fatal(err)
205+ }
206+ if err := s.Set([]byte("keep"), bytes.Repeat([]byte("k"), 128)); err != nil {
207+ t.Fatal(err)
208+ }
209+ if err := s.Delete([]byte("dead")); err != nil {
210+ t.Fatal(err)
211+ }
212+ if err := s.Close(); err != nil {
213+ t.Fatal(err)
214+ }
215+
216+ s, err = Open(path)
217+ if err != nil {
218+ t.Fatal(err)
219+ }
220+ defer closeStore(t, s)
221+ if _, ok := s.Get([]byte("dead")); ok {
222+ t.Fatal("deleted key resurrected after compaction/reopen")
223+ }
224+ if got, ok := s.Get([]byte("keep")); !ok || len(got) != 128 || got[0] != 'k' {
225+ t.Fatalf("live key missing after compaction/reopen: len=%d ok=%v", len(got), ok)
226+ }
227+}
228+
229+func TestReplayStopsAtBadPayloadLengthWithoutAllocatingHugeBuffer(t *testing.T) {
230+ path := filepath.Join(t.TempDir(), "store.kv")
231+ first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
232+ header := make([]byte, headerLen)
233+ binary.BigEndian.PutUint32(header[0:4], 1<<31)
234+ if err := os.WriteFile(path, append(first, header...), 0o600); err != nil {
235+ t.Fatal(err)
236+ }
237+ s, err := Open(path)
238+ if err != nil {
239+ t.Fatal(err)
240+ }
241+ defer closeStore(t, s)
242+ if got, ok := s.Get([]byte("a")); !ok || string(got) != "one" {
243+ t.Fatalf("good record missing before bad length: %q %v", got, ok)
244+ }
245+}
@@ -0,0 +1,245 @@
1+package kv
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+)
10+
11+func closeStore(t *testing.T, s *Store) {
12+ t.Helper()
13+ if err := s.Close(); err != nil {
14+ t.Fatal(err)
15+ }
16+}
17+
18+func TestSetGetDeleteRange(t *testing.T) {
19+ path := filepath.Join(t.TempDir(), "store.kv")
20+ s, err := Open(path)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ defer closeStore(t, s)
25+
26+ if err := s.Set([]byte("b"), []byte("two")); err != nil {
27+ t.Fatal(err)
28+ }
29+ if err := s.Set([]byte("a"), []byte("one")); err != nil {
30+ t.Fatal(err)
31+ }
32+ got, ok := s.Get([]byte("a"))
33+ if !ok || string(got) != "one" {
34+ t.Fatalf("Get(a) = %q, %v", got, ok)
35+ }
36+ got[0] = 'X'
37+ got, _ = s.Get([]byte("a"))
38+ if string(got) != "one" {
39+ t.Fatalf("Get returned mutable value: %q", got)
40+ }
41+ if err := s.Delete([]byte("b")); err != nil {
42+ t.Fatal(err)
43+ }
44+ if _, ok := s.Get([]byte("b")); ok {
45+ t.Fatal("deleted key still present")
46+ }
47+
48+ seen := map[string]string{}
49+ s.Range(func(k, v []byte) bool {
50+ seen[string(k)] = string(v)
51+ return true
52+ })
53+ if len(seen) != 1 || seen["a"] != "one" {
54+ t.Fatalf("Range saw %#v", seen)
55+ }
56+}
57+
58+func TestReopenDurability(t *testing.T) {
59+ path := filepath.Join(t.TempDir(), "store.kv")
60+ s, err := Open(path)
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ if err := s.Set([]byte("k"), []byte("v")); err != nil {
65+ t.Fatal(err)
66+ }
67+ if err := s.Close(); err != nil {
68+ t.Fatal(err)
69+ }
70+
71+ s, err = Open(path)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ defer closeStore(t, s)
76+ got, ok := s.Get([]byte("k"))
77+ if !ok || string(got) != "v" {
78+ t.Fatalf("after reopen Get(k) = %q, %v", got, ok)
79+ }
80+}
81+
82+func TestCompactionShrinksWithDataIntact(t *testing.T) {
83+ path := filepath.Join(t.TempDir(), "store.kv")
84+ s, err := Open(path)
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ if err := s.Set([]byte("keep"), bytes.Repeat([]byte("x"), 128)); err != nil {
89+ t.Fatal(err)
90+ }
91+ info, err := os.Stat(path)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ before := info.Size()
96+ if err := s.Set([]byte("keep"), []byte("y")); err != nil {
97+ t.Fatal(err)
98+ }
99+ info, err = os.Stat(path)
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ after := info.Size()
104+ if after >= before {
105+ t.Fatalf("expected compaction to shrink file: before=%d after=%d", before, after)
106+ }
107+ got, ok := s.Get([]byte("keep"))
108+ if !ok || string(got) != "y" {
109+ t.Fatalf("compacted value = %q, %v", got, ok)
110+ }
111+ if err := s.Close(); err != nil {
112+ t.Fatal(err)
113+ }
114+}
115+
116+func TestOpenTruncatesMidRecordTail(t *testing.T) {
117+ path := filepath.Join(t.TempDir(), "store.kv")
118+ first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
119+ second, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
120+ if err := os.WriteFile(path, append(first, second[:len(second)/2]...), 0o600); err != nil {
121+ t.Fatal(err)
122+ }
123+
124+ s, err := Open(path)
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ defer closeStore(t, s)
129+ if got, ok := s.Get([]byte("a")); !ok || string(got) != "one" {
130+ t.Fatalf("good record missing: %q %v", got, ok)
131+ }
132+ if _, ok := s.Get([]byte("b")); ok {
133+ t.Fatal("torn record was replayed")
134+ }
135+ info, err := os.Stat(path)
136+ if err != nil {
137+ t.Fatal(err)
138+ }
139+ if info.Size() != int64(len(first)) {
140+ t.Fatalf("tail not truncated: size=%d want=%d", info.Size(), len(first))
141+ }
142+}
143+
144+func TestCRCcorruptionOfLastRecordDropsIt(t *testing.T) {
145+ path := filepath.Join(t.TempDir(), "store.kv")
146+ first, _ := encodeRecord(opSet, []byte("k"), []byte("old"))
147+ second, _ := encodeRecord(opSet, []byte("k"), []byte("new"))
148+ buf := append(append([]byte{}, first...), second...)
149+ buf[len(first)+headerLen+payloadPrefixLen+1] ^= 0xff
150+ if err := os.WriteFile(path, buf, 0o600); err != nil {
151+ t.Fatal(err)
152+ }
153+
154+ s, err := Open(path)
155+ if err != nil {
156+ t.Fatal(err)
157+ }
158+ defer closeStore(t, s)
159+ got, ok := s.Get([]byte("k"))
160+ if !ok || string(got) != "old" {
161+ t.Fatalf("corrupt last record not dropped: %q %v", got, ok)
162+ }
163+ info, err := os.Stat(path)
164+ if err != nil {
165+ t.Fatal(err)
166+ }
167+ if info.Size() != int64(len(first)) {
168+ t.Fatalf("corrupt tail not truncated: size=%d want=%d", info.Size(), len(first))
169+ }
170+}
171+
172+func TestAbsentAndEmptyFile(t *testing.T) {
173+ path := filepath.Join(t.TempDir(), "missing", "store.kv")
174+ m, err := Replay(path)
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ if len(m) != 0 {
179+ t.Fatalf("Replay(absent) = %#v", m)
180+ }
181+ s, err := Open(path)
182+ if err != nil {
183+ t.Fatal(err)
184+ }
185+ if err := s.Close(); err != nil {
186+ t.Fatal(err)
187+ }
188+ m, err = Replay(path)
189+ if err != nil {
190+ t.Fatal(err)
191+ }
192+ if len(m) != 0 {
193+ t.Fatalf("Replay(empty) = %#v", m)
194+ }
195+}
196+
197+func TestTombstoneSurvivesCompaction(t *testing.T) {
198+ path := filepath.Join(t.TempDir(), "store.kv")
199+ s, err := Open(path)
200+ if err != nil {
201+ t.Fatal(err)
202+ }
203+ if err := s.Set([]byte("dead"), bytes.Repeat([]byte("d"), 128)); err != nil {
204+ t.Fatal(err)
205+ }
206+ if err := s.Set([]byte("keep"), bytes.Repeat([]byte("k"), 128)); err != nil {
207+ t.Fatal(err)
208+ }
209+ if err := s.Delete([]byte("dead")); err != nil {
210+ t.Fatal(err)
211+ }
212+ if err := s.Close(); err != nil {
213+ t.Fatal(err)
214+ }
215+
216+ s, err = Open(path)
217+ if err != nil {
218+ t.Fatal(err)
219+ }
220+ defer closeStore(t, s)
221+ if _, ok := s.Get([]byte("dead")); ok {
222+ t.Fatal("deleted key resurrected after compaction/reopen")
223+ }
224+ if got, ok := s.Get([]byte("keep")); !ok || len(got) != 128 || got[0] != 'k' {
225+ t.Fatalf("live key missing after compaction/reopen: len=%d ok=%v", len(got), ok)
226+ }
227+}
228+
229+func TestReplayStopsAtBadPayloadLengthWithoutAllocatingHugeBuffer(t *testing.T) {
230+ path := filepath.Join(t.TempDir(), "store.kv")
231+ first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
232+ header := make([]byte, headerLen)
233+ binary.BigEndian.PutUint32(header[0:4], 1<<31)
234+ if err := os.WriteFile(path, append(first, header...), 0o600); err != nil {
235+ t.Fatal(err)
236+ }
237+ s, err := Open(path)
238+ if err != nil {
239+ t.Fatal(err)
240+ }
241+ defer closeStore(t, s)
242+ if got, ok := s.Get([]byte("a")); !ok || string(got) != "one" {
243+ t.Fatalf("good record missing before bad length: %q %v", got, ok)
244+ }
245+}

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 12

Tags 11

↑↓ moveenter openesc close