detached f3187c5

chore: complete hygiene sweep

commit
f3187c56ded9677e9287d816a1ed1ba068f8879c
author
brice · · 2026-07-08 16:26 UTC
committer
brice · · 2026-07-08 20:59 UTC
parent
b4f3d9b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +211 -10
M AGENTS.md +4 -1
@@ -16,11 +16,14 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem
1616 ```
1717
1818 - Format with **goimports**.
19+- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v2@latest`.
1920 - `make test` runs `go test ./... -race`.
2021 - `make lint` checks `goimports -l`, then `go vet`.
2122 - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`.
23+- Set `VEV_LOG=debug|warn|error` to change log verbosity (default info).
2224 - Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset.
23-- Use feature worktrees under `.worktrees/<branch-name>`.
25+- Use feature worktrees under `.worktrees/<branch-name>`. Remove the worktree once its branch is merged or abandoned (`git worktree remove <path>`).
26+- Create vev-owned private directories with `pkg/safedir.EnsurePrivate`, not `os.MkdirAll`.
2427
2528 ## Architecture rules
2629
@@ -16,11 +16,14 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem
16 ```16 ```
17 17
18 - Format with **goimports**.18 - Format with **goimports**.
19+- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v2@latest`.
19 - `make test` runs `go test ./... -race`.20 - `make test` runs `go test ./... -race`.
20 - `make lint` checks `goimports -l`, then `go vet`.21 - `make lint` checks `goimports -l`, then `go vet`.
21 - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`.22 - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`.
23+- Set `VEV_LOG=debug|warn|error` to change log verbosity (default info).
22 - Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset.24 - Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset.
23-- Use feature worktrees under `.worktrees/<branch-name>`.25+- Use feature worktrees under `.worktrees/<branch-name>`. Remove the worktree once its branch is merged or abandoned (`git worktree remove <path>`).
26+- Create vev-owned private directories with `pkg/safedir.EnsurePrivate`, not `os.MkdirAll`.
24 27
25 ## Architecture rules28 ## Architecture rules
26 29
M README.md +2 -0
@@ -208,6 +208,8 @@ Copy uses OSC 52 clipboard sequences when supported by your terminal.
208208 ## Development
209209
210210 ```sh
211+go install golang.org/x/tools/cmd/goimports@latest
212+go install github.com/vektra/mockery/v2@latest
211213 make test # go test ./... -race
212214 make lint # goimports check, then go vet ./...
213215 make mocks # regenerate mocks
@@ -208,6 +208,8 @@ Copy uses OSC 52 clipboard sequences when supported by your terminal.
208 ## Development208 ## Development
209 209
210 ```sh210 ```sh
211+go install golang.org/x/tools/cmd/goimports@latest
212+go install github.com/vektra/mockery/v2@latest
211 make test # go test ./... -race213 make test # go test ./... -race
212 make lint # goimports check, then go vet ./...214 make lint # goimports check, then go vet ./...
213 make mocks # regenerate mocks215 make mocks # regenerate mocks
A internal/adapters/clock/clock_test.go +56 -0
@@ -0,0 +1,56 @@
1+package clock
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestRealClockNow(t *testing.T) {
9+ c := New()
10+ before := time.Now().Add(-time.Second)
11+ got := c.Now()
12+ after := time.Now().Add(time.Second)
13+
14+ if got.Before(before) || got.After(after) {
15+ t.Fatalf("Now() = %v, want between %v and %v", got, before, after)
16+ }
17+}
18+
19+func TestRealClockTimerFires(t *testing.T) {
20+ c := New()
21+ timer := c.NewTimer(10 * time.Millisecond)
22+ defer timer.Stop()
23+
24+ select {
25+ case <-timer.C():
26+ case <-time.After(5 * time.Second):
27+ t.Fatal("timer did not fire before deadline")
28+ }
29+}
30+
31+func TestRealClockTimerStopKeepsChannelQuiet(t *testing.T) {
32+ c := New()
33+ timer := c.NewTimer(time.Hour)
34+ _ = timer.Stop()
35+
36+ select {
37+ case <-timer.C():
38+ t.Fatal("stopped timer fired")
39+ case <-time.After(25 * time.Millisecond):
40+ }
41+}
42+
43+func TestRealClockTimerResetRearms(t *testing.T) {
44+ c := New()
45+ timer := c.NewTimer(time.Hour)
46+ defer timer.Stop()
47+
48+ _ = timer.Stop()
49+ timer.Reset(10 * time.Millisecond)
50+
51+ select {
52+ case <-timer.C():
53+ case <-time.After(5 * time.Second):
54+ t.Fatal("reset timer did not fire before deadline")
55+ }
56+}
@@ -0,0 +1,56 @@
1+package clock
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestRealClockNow(t *testing.T) {
9+ c := New()
10+ before := time.Now().Add(-time.Second)
11+ got := c.Now()
12+ after := time.Now().Add(time.Second)
13+
14+ if got.Before(before) || got.After(after) {
15+ t.Fatalf("Now() = %v, want between %v and %v", got, before, after)
16+ }
17+}
18+
19+func TestRealClockTimerFires(t *testing.T) {
20+ c := New()
21+ timer := c.NewTimer(10 * time.Millisecond)
22+ defer timer.Stop()
23+
24+ select {
25+ case <-timer.C():
26+ case <-time.After(5 * time.Second):
27+ t.Fatal("timer did not fire before deadline")
28+ }
29+}
30+
31+func TestRealClockTimerStopKeepsChannelQuiet(t *testing.T) {
32+ c := New()
33+ timer := c.NewTimer(time.Hour)
34+ _ = timer.Stop()
35+
36+ select {
37+ case <-timer.C():
38+ t.Fatal("stopped timer fired")
39+ case <-time.After(25 * time.Millisecond):
40+ }
41+}
42+
43+func TestRealClockTimerResetRearms(t *testing.T) {
44+ c := New()
45+ timer := c.NewTimer(time.Hour)
46+ defer timer.Stop()
47+
48+ _ = timer.Stop()
49+ timer.Reset(10 * time.Millisecond)
50+
51+ select {
52+ case <-timer.C():
53+ case <-time.After(5 * time.Second):
54+ t.Fatal("reset timer did not fire before deadline")
55+ }
56+}
M internal/app/run.go +15 -0
@@ -262,6 +262,18 @@ func snapshotDir() string {
262262 return filepath.Join(platform.StateDir(), "snapshots")
263263 }
264264
265+func pprofAddrIsLoopback(addr string) bool {
266+ host, _, err := net.SplitHostPort(addr)
267+ if err != nil || host == "" {
268+ return false
269+ }
270+ if strings.EqualFold(host, "localhost") {
271+ return true
272+ }
273+ ip := net.ParseIP(host)
274+ return ip != nil && ip.IsLoopback()
275+}
276+
265277 // runDaemon runs the daemon in the foreground (the hidden --daemon path,
266278 // entered by an auto-spawned child): it sets up logging, binds the socket,
267279 // constructs the daemon, and serves until the last session exits or a
@@ -284,6 +296,9 @@ func runDaemon() error {
284296 defer stop()
285297
286298 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
299+ if !pprofAddrIsLoopback(addr) {
300+ log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr)
301+ }
287302 go func() {
288303 if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {
289304 log.Error("pprof server exited", "err", err)
@@ -262,6 +262,18 @@ func snapshotDir() string {
262 return filepath.Join(platform.StateDir(), "snapshots")262 return filepath.Join(platform.StateDir(), "snapshots")
263 }263 }
264 264
265+func pprofAddrIsLoopback(addr string) bool {
266+ host, _, err := net.SplitHostPort(addr)
267+ if err != nil || host == "" {
268+ return false
269+ }
270+ if strings.EqualFold(host, "localhost") {
271+ return true
272+ }
273+ ip := net.ParseIP(host)
274+ return ip != nil && ip.IsLoopback()
275+}
276+
265 // runDaemon runs the daemon in the foreground (the hidden --daemon path,277 // runDaemon runs the daemon in the foreground (the hidden --daemon path,
266 // entered by an auto-spawned child): it sets up logging, binds the socket,278 // entered by an auto-spawned child): it sets up logging, binds the socket,
267 // constructs the daemon, and serves until the last session exits or a279 // constructs the daemon, and serves until the last session exits or a
@@ -284,6 +296,9 @@ func runDaemon() error {
284 defer stop()296 defer stop()
285 297
286 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {298 if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
299+ if !pprofAddrIsLoopback(addr) {
300+ log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr)
301+ }
287 go func() {302 go func() {
288 if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {303 if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {
289 log.Error("pprof server exited", "err", err)304 log.Error("pprof server exited", "err", err)
M internal/app/run_test.go +23 -0
@@ -753,3 +753,26 @@ func TestListenUDPInRange(t *testing.T) {
753753 }
754754 })
755755 }
756+
757+func TestPprofAddrIsLoopback(t *testing.T) {
758+ tests := []struct {
759+ addr string
760+ want bool
761+ }{
762+ {addr: "127.0.0.1:6060", want: true},
763+ {addr: "localhost:6060", want: true},
764+ {addr: "[::1]:6060", want: true},
765+ {addr: ":6060", want: false},
766+ {addr: "0.0.0.0:6060", want: false},
767+ {addr: "192.168.1.5:6060", want: false},
768+ {addr: "garbage", want: false},
769+ }
770+
771+ for _, tt := range tests {
772+ t.Run(tt.addr, func(t *testing.T) {
773+ if got := pprofAddrIsLoopback(tt.addr); got != tt.want {
774+ t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
775+ }
776+ })
777+ }
778+}
@@ -753,3 +753,26 @@ func TestListenUDPInRange(t *testing.T) {
753 }753 }
754 })754 })
755 }755 }
756+
757+func TestPprofAddrIsLoopback(t *testing.T) {
758+ tests := []struct {
759+ addr string
760+ want bool
761+ }{
762+ {addr: "127.0.0.1:6060", want: true},
763+ {addr: "localhost:6060", want: true},
764+ {addr: "[::1]:6060", want: true},
765+ {addr: ":6060", want: false},
766+ {addr: "0.0.0.0:6060", want: false},
767+ {addr: "192.168.1.5:6060", want: false},
768+ {addr: "garbage", want: false},
769+ }
770+
771+ for _, tt := range tests {
772+ t.Run(tt.addr, func(t *testing.T) {
773+ if got := pprofAddrIsLoopback(tt.addr); got != tt.want {
774+ t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
775+ }
776+ })
777+ }
778+}
M internal/platform/dir_test.go +102 -4
@@ -1,24 +1,91 @@
11 package platform
22
33 import (
4+ "os"
45 "path/filepath"
6+ "strings"
57 "testing"
68 )
79
10+func TestStateDir(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ xdgState string
14+ home string
15+ want func(*testing.T, string, string) string
16+ }{
17+ {
18+ name: "xdg state home",
19+ xdgState: filepath.Join(t.TempDir(), "xdg-state"),
20+ home: filepath.Join(t.TempDir(), "home"),
21+ want: func(_ *testing.T, xdgState, _ string) string {
22+ return filepath.Join(xdgState, "vev")
23+ },
24+ },
25+ {
26+ name: "home fallback",
27+ home: filepath.Join(t.TempDir(), "home"),
28+ want: func(_ *testing.T, _, home string) string {
29+ return filepath.Join(home, ".local", "state", "vev")
30+ },
31+ },
32+ {
33+ name: "temp fallback without home",
34+ want: func(t *testing.T, _, _ string) string {
35+ t.Helper()
36+ return filepath.Join(os.TempDir(), "vev-state-")
37+ },
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ t.Setenv("XDG_STATE_HOME", tt.xdgState)
44+ t.Setenv("HOME", tt.home)
45+
46+ want := tt.want(t, tt.xdgState, tt.home)
47+ got := StateDir()
48+ if strings.HasSuffix(want, "vev-state-") {
49+ if !strings.HasPrefix(got, want) {
50+ t.Fatalf("StateDir() = %q, want prefix %q", got, want)
51+ }
52+ return
53+ }
54+ if got != want {
55+ t.Fatalf("StateDir() = %q, want %q", got, want)
56+ }
57+ })
58+ }
59+}
60+
861 func TestConfigPath(t *testing.T) {
962 tests := []struct {
1063 name string
1164 xdgConfig string
1265 home string
66+ want func(*testing.T, string, string) string
1367 }{
1468 {
1569 name: "xdg config home",
1670 xdgConfig: filepath.Join(t.TempDir(), "xdg-config"),
1771 home: filepath.Join(t.TempDir(), "home"),
72+ want: func(_ *testing.T, xdgConfig, _ string) string {
73+ return filepath.Join(xdgConfig, "vev", "config")
74+ },
1875 },
1976 {
2077 name: "home fallback",
2178 home: filepath.Join(t.TempDir(), "home"),
79+ want: func(_ *testing.T, _, home string) string {
80+ return filepath.Join(home, ".config", "vev", "config")
81+ },
82+ },
83+ {
84+ name: "temp fallback without home",
85+ want: func(t *testing.T, _, _ string) string {
86+ t.Helper()
87+ return filepath.Join(os.TempDir(), "vev-config-")
88+ },
2289 },
2390 }
2491
@@ -27,13 +94,44 @@ func TestConfigPath(t *testing.T) {
2794 t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig)
2895 t.Setenv("HOME", tt.home)
2996
30- want := filepath.Join(tt.xdgConfig, "vev", "config")
31- if tt.xdgConfig == "" {
32- want = filepath.Join(tt.home, ".config", "vev", "config")
97+ want := tt.want(t, tt.xdgConfig, tt.home)
98+ got := ConfigPath()
99+ if strings.HasSuffix(want, "vev-config-") {
100+ if !strings.HasPrefix(got, want) || filepath.Base(got) != "config" {
101+ t.Fatalf("ConfigPath() = %q, want prefix %q and base config", got, want)
102+ }
103+ return
33104 }
34- if got := ConfigPath(); got != want {
105+ if got != want {
35106 t.Fatalf("ConfigPath() = %q, want %q", got, want)
36107 }
37108 })
38109 }
39110 }
111+
112+func TestDirOrHome(t *testing.T) {
113+ existing := t.TempDir()
114+ home := filepath.Join(t.TempDir(), "home")
115+ nonexistent := filepath.Join(t.TempDir(), "missing")
116+
117+ tests := []struct {
118+ name string
119+ dir string
120+ home string
121+ want string
122+ }{
123+ {name: "existing dir", dir: existing, home: home, want: existing},
124+ {name: "non-existent dir falls back to home", dir: nonexistent, home: home, want: home},
125+ {name: "non-existent dir without home falls back to root", dir: nonexistent, want: "/"},
126+ }
127+
128+ for _, tt := range tests {
129+ t.Run(tt.name, func(t *testing.T) {
130+ t.Setenv("HOME", tt.home)
131+
132+ if got := DirOrHome(tt.dir); got != tt.want {
133+ t.Fatalf("DirOrHome(%q) = %q, want %q", tt.dir, got, tt.want)
134+ }
135+ })
136+ }
137+}
@@ -1,24 +1,91 @@
1 package platform1 package platform
2 2
3 import (3 import (
4+ "os"
4 "path/filepath"5 "path/filepath"
6+ "strings"
5 "testing"7 "testing"
6 )8 )
7 9
10+func TestStateDir(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ xdgState string
14+ home string
15+ want func(*testing.T, string, string) string
16+ }{
17+ {
18+ name: "xdg state home",
19+ xdgState: filepath.Join(t.TempDir(), "xdg-state"),
20+ home: filepath.Join(t.TempDir(), "home"),
21+ want: func(_ *testing.T, xdgState, _ string) string {
22+ return filepath.Join(xdgState, "vev")
23+ },
24+ },
25+ {
26+ name: "home fallback",
27+ home: filepath.Join(t.TempDir(), "home"),
28+ want: func(_ *testing.T, _, home string) string {
29+ return filepath.Join(home, ".local", "state", "vev")
30+ },
31+ },
32+ {
33+ name: "temp fallback without home",
34+ want: func(t *testing.T, _, _ string) string {
35+ t.Helper()
36+ return filepath.Join(os.TempDir(), "vev-state-")
37+ },
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ t.Setenv("XDG_STATE_HOME", tt.xdgState)
44+ t.Setenv("HOME", tt.home)
45+
46+ want := tt.want(t, tt.xdgState, tt.home)
47+ got := StateDir()
48+ if strings.HasSuffix(want, "vev-state-") {
49+ if !strings.HasPrefix(got, want) {
50+ t.Fatalf("StateDir() = %q, want prefix %q", got, want)
51+ }
52+ return
53+ }
54+ if got != want {
55+ t.Fatalf("StateDir() = %q, want %q", got, want)
56+ }
57+ })
58+ }
59+}
60+
8 func TestConfigPath(t *testing.T) {61 func TestConfigPath(t *testing.T) {
9 tests := []struct {62 tests := []struct {
10 name string63 name string
11 xdgConfig string64 xdgConfig string
12 home string65 home string
66+ want func(*testing.T, string, string) string
13 }{67 }{
14 {68 {
15 name: "xdg config home",69 name: "xdg config home",
16 xdgConfig: filepath.Join(t.TempDir(), "xdg-config"),70 xdgConfig: filepath.Join(t.TempDir(), "xdg-config"),
17 home: filepath.Join(t.TempDir(), "home"),71 home: filepath.Join(t.TempDir(), "home"),
72+ want: func(_ *testing.T, xdgConfig, _ string) string {
73+ return filepath.Join(xdgConfig, "vev", "config")
74+ },
18 },75 },
19 {76 {
20 name: "home fallback",77 name: "home fallback",
21 home: filepath.Join(t.TempDir(), "home"),78 home: filepath.Join(t.TempDir(), "home"),
79+ want: func(_ *testing.T, _, home string) string {
80+ return filepath.Join(home, ".config", "vev", "config")
81+ },
82+ },
83+ {
84+ name: "temp fallback without home",
85+ want: func(t *testing.T, _, _ string) string {
86+ t.Helper()
87+ return filepath.Join(os.TempDir(), "vev-config-")
88+ },
22 },89 },
23 }90 }
24 91
@@ -27,13 +94,44 @@ func TestConfigPath(t *testing.T) {
27 t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig)94 t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig)
28 t.Setenv("HOME", tt.home)95 t.Setenv("HOME", tt.home)
29 96
30- want := filepath.Join(tt.xdgConfig, "vev", "config")97+ want := tt.want(t, tt.xdgConfig, tt.home)
31- if tt.xdgConfig == "" {98+ got := ConfigPath()
32- want = filepath.Join(tt.home, ".config", "vev", "config")99+ if strings.HasSuffix(want, "vev-config-") {
100+ if !strings.HasPrefix(got, want) || filepath.Base(got) != "config" {
101+ t.Fatalf("ConfigPath() = %q, want prefix %q and base config", got, want)
102+ }
103+ return
33 }104 }
34- if got := ConfigPath(); got != want {105+ if got != want {
35 t.Fatalf("ConfigPath() = %q, want %q", got, want)106 t.Fatalf("ConfigPath() = %q, want %q", got, want)
36 }107 }
37 })108 })
38 }109 }
39 }110 }
111+
112+func TestDirOrHome(t *testing.T) {
113+ existing := t.TempDir()
114+ home := filepath.Join(t.TempDir(), "home")
115+ nonexistent := filepath.Join(t.TempDir(), "missing")
116+
117+ tests := []struct {
118+ name string
119+ dir string
120+ home string
121+ want string
122+ }{
123+ {name: "existing dir", dir: existing, home: home, want: existing},
124+ {name: "non-existent dir falls back to home", dir: nonexistent, home: home, want: home},
125+ {name: "non-existent dir without home falls back to root", dir: nonexistent, want: "/"},
126+ }
127+
128+ for _, tt := range tests {
129+ t.Run(tt.name, func(t *testing.T) {
130+ t.Setenv("HOME", tt.home)
131+
132+ if got := DirOrHome(tt.dir); got != tt.want {
133+ t.Fatalf("DirOrHome(%q) = %q, want %q", tt.dir, got, tt.want)
134+ }
135+ })
136+ }
137+}
M internal/usecase/client/client_test.go +5 -2
@@ -617,6 +617,8 @@ func TestAttachForwardsResize(t *testing.T) {
617617 defer in.unblock()
618618
619619 detachAfterResize := make(chan struct{})
620+ firstRecv := make(chan struct{})
621+ var firstRecvOnce sync.Once
620622
621623 tr := portsmocks.NewMockTransport(t)
622624 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
@@ -635,6 +637,7 @@ func TestAttachForwardsResize(t *testing.T) {
635637 recvCh <- recvItem{f: welcome}
636638 done := make(chan struct{})
637639 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
640+ firstRecvOnce.Do(func() { close(firstRecv) })
638641 select {
639642 case it := <-recvCh:
640643 return it.f, it.err
@@ -653,9 +656,9 @@ func TestAttachForwardsResize(t *testing.T) {
653656 }).Maybe()
654657 tr.EXPECT().Close().Return(nil).Once()
655658
656- // Push a resize event shortly after attach begins.
659+ // Push a resize event once attach begins receiving daemon frames.
657660 go func() {
658- time.Sleep(20 * time.Millisecond)
661+ <-firstRecv
659662 resizeCh <- domain.Size{Cols: 120, Rows: 40}
660663 }()
661664
@@ -617,6 +617,8 @@ func TestAttachForwardsResize(t *testing.T) {
617 defer in.unblock()617 defer in.unblock()
618 618
619 detachAfterResize := make(chan struct{})619 detachAfterResize := make(chan struct{})
620+ firstRecv := make(chan struct{})
621+ var firstRecvOnce sync.Once
620 622
621 tr := portsmocks.NewMockTransport(t)623 tr := portsmocks.NewMockTransport(t)
622 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()624 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
@@ -635,6 +637,7 @@ func TestAttachForwardsResize(t *testing.T) {
635 recvCh <- recvItem{f: welcome}637 recvCh <- recvItem{f: welcome}
636 done := make(chan struct{})638 done := make(chan struct{})
637 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {639 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
640+ firstRecvOnce.Do(func() { close(firstRecv) })
638 select {641 select {
639 case it := <-recvCh:642 case it := <-recvCh:
640 return it.f, it.err643 return it.f, it.err
@@ -653,9 +656,9 @@ func TestAttachForwardsResize(t *testing.T) {
653 }).Maybe()656 }).Maybe()
654 tr.EXPECT().Close().Return(nil).Once()657 tr.EXPECT().Close().Return(nil).Once()
655 658
656- // Push a resize event shortly after attach begins.659+ // Push a resize event once attach begins receiving daemon frames.
657 go func() {660 go func() {
658- time.Sleep(20 * time.Millisecond)661+ <-firstRecv
659 resizeCh <- domain.Size{Cols: 120, Rows: 40}662 resizeCh <- domain.Size{Cols: 120, Rows: 40}
660 }()663 }()
661 664
M internal/usecase/daemon/session.go +4 -3
@@ -660,15 +660,16 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
660660 }
661661
662662 func (d *Daemon) cwdSampler(ctx context.Context) {
663- t := time.NewTicker(5 * time.Second)
663+ t := d.clock.NewTimer(5 * time.Second)
664664 defer t.Stop()
665665 for {
666666 select {
667667 case <-ctx.Done():
668668 return
669- case <-t.C:
670- d.refreshNamedSessionCwds()
669+ case <-t.C():
671670 }
671+ d.refreshNamedSessionCwds()
672+ t.Reset(5 * time.Second)
672673 }
673674 }
674675
@@ -660,15 +660,16 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
660 }660 }
661 661
662 func (d *Daemon) cwdSampler(ctx context.Context) {662 func (d *Daemon) cwdSampler(ctx context.Context) {
663- t := time.NewTicker(5 * time.Second)663+ t := d.clock.NewTimer(5 * time.Second)
664 defer t.Stop()664 defer t.Stop()
665 for {665 for {
666 select {666 select {
667 case <-ctx.Done():667 case <-ctx.Done():
668 return668 return
669- case <-t.C:669+ case <-t.C():
670- d.refreshNamedSessionCwds()
671 }670 }
671+ d.refreshNamedSessionCwds()
672+ t.Reset(5 * time.Second)
672 }673 }
673 }674 }
674 675

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