detached ac0ff68

test(core): cover stash spawn ordering, suppression, expiry and fullscreen

The publication-order test waits on channels instead of a timing window,
claim tests use a fixed mock clock and check the termRetry boundary, and new
tests cover a full spawn queue, a failed publication and a window spawned
over a fullscreen tile or stash member.
commit
ac0ff6806a79771c7773116f1109d8e617db71d6
author
brice · · 2026-10-14 20:36 UTC
parent
baa0832
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +201 -34
M internal/core/stash_spawn_internal_test.go +201 -34
@@ -2,19 +2,32 @@ package core
22
33 import (
44 "context"
5+ "errors"
56 "strings"
67 "testing"
78 "time"
89
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
911 "github.com/bnema/neferwl/internal/ports"
1012 )
1113
12-func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest) *Core {
14+// claimBase is the instant of the fixed test clock.
15+var claimBase = time.Unix(1000, 0)
16+
17+// fixedClock is a mock clock at *now, which a test moves by assigning.
18+func fixedClock(t *testing.T, now *time.Time) ports.Clock {
19+ t.Helper()
20+ clock := portsmocks.NewMockClock(t)
21+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return *now }).Maybe()
22+ return clock
23+}
24+
25+func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest, clock ports.Clock) *Core {
1326 t.Helper()
1427 var cfg ports.Config
1528 cfg.Keyboard.CmdKey = "alt"
1629 cfg.Layout.MaxColumns = 2
17- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{})
30+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{Clock: clock})
1831 if err != nil {
1932 t.Fatal(err)
2033 }
@@ -26,34 +39,41 @@ func claimToken(req ports.SpawnRequest) string {
2639 return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
2740 }
2841
29-// Wayland reads tokens only while slots are pending: the flag is sent before
30-// the spawn can reach the launcher.
42+// Wayland reads tokens only while slots are pending, so the flag is sent
43+// before the spawn can reach the launcher. The command channel is unbuffered
44+// and read last: the spawn can only follow the flag, never come first.
3145 func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
32- commands := make(chan ports.ClientCommand) // unbuffered: sends are observable
46+ now := claimBase
47+ commands := make(chan ports.ClientCommand)
3348 spawn := make(chan ports.SpawnRequest, 1)
34- c := stashSpawnCore(t, commands, spawn)
49+ c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
50+ ctx, cancel := context.WithCancel(context.Background())
51+ t.Cleanup(cancel)
3552 done := make(chan error, 1)
36- go func() {
37- done <- c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"firefox"}}, false)
38- }()
39- // The send blocks on the unread command: nothing may be spawned yet.
40- select {
41- case req := <-spawn:
42- t.Fatalf("spawned before the pending flag was delivered: %v", req)
43- case <-time.After(50 * time.Millisecond):
44- }
53+ go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()
54+ // Both channels are awaited at once: the first to communicate wins.
55+ timeout := time.After(5 * time.Second)
4556 select {
4657 case v := <-commands:
4758 if p, ok := v.(ports.SlotsPending); !ok || !p.Pending {
4859 t.Fatalf("command %v", v)
4960 }
50- case <-time.After(time.Second):
61+ case req := <-spawn:
62+ t.Fatalf("spawned before the pending flag was delivered: %v", req)
63+ case <-timeout:
5164 t.Fatal("no SlotsPending")
5265 }
53- if err := <-done; err != nil {
54- t.Fatal(err)
66+ select {
67+ case err := <-done:
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ case <-timeout:
72+ t.Fatal("spawnStash did not return")
5573 }
56- if len(spawn) != 1 {
74+ select {
75+ case <-spawn:
76+ default:
5777 t.Fatal("no spawn")
5878 }
5979 }
@@ -61,8 +81,9 @@ func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
6181 // A send the launcher queue suppresses leaves no claim behind, and the
6282 // pending flag falls again at the next publish.
6383 func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
84+ now := claimBase
6485 commands := make(chan ports.ClientCommand, 8)
65- c := stashSpawnCore(t, commands, nil) // no launcher queue: every send is suppressed
86+ c := stashSpawnCore(t, commands, nil, fixedClock(t, &now)) // no launcher queue: every send is suppressed
6687 if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil {
6788 t.Fatal(err)
6889 }
@@ -80,25 +101,91 @@ func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
80101 }
81102 }
82103
83-// An unanswered claim expires like a terminal's, even if no publish ran
84-// between its age and the window mapping.
85-func TestStashClaimExpires(t *testing.T) {
86- c := stashSpawnCore(t, nil, nil)
87- m := c.cur().mon
88- m.AddWindow(1)
89- target := m.Current()
90- m.show(m.Workspaces[1])
91- req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: time.Now().Add(-2 * termRetry)})
92- c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
93- if _, w := c.screenOf(5); w != m.Current() || len(target.Stash) != 0 {
94- t.Fatal("expired claim still placed the window in the stash")
104+// From the first command that is not issued, the rest are not sent and their
105+// claims are cancelled; the issued ones keep theirs.
106+func TestSpawnStashCancelsClaimsFromFirstUnissued(t *testing.T) {
107+ for _, tc := range []struct {
108+ name string
109+ queue int
110+ cancelled bool
111+ issued int
112+ wantErr error
113+ }{
114+ {"queue of one", 1, false, 1, nil},
115+ {"publication fails", 4, true, 0, context.Canceled},
116+ } {
117+ t.Run(tc.name, func(t *testing.T) {
118+ now := claimBase
119+ commands := make(chan ports.ClientCommand) // unread: a send blocks until the context ends
120+ spawn := make(chan ports.SpawnRequest, tc.queue)
121+ c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
122+ ctx, cancel := context.WithCancel(context.Background())
123+ t.Cleanup(cancel)
124+ if tc.cancelled {
125+ cancel()
126+ } else {
127+ // An unread unbuffered channel would block the flag: read it.
128+ go func() {
129+ select {
130+ case <-commands:
131+ case <-ctx.Done():
132+ }
133+ }()
134+ }
135+ err := c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false)
136+ if !errors.Is(err, tc.wantErr) {
137+ t.Fatalf("error %v, want %v", err, tc.wantErr)
138+ }
139+ if len(spawn) != tc.issued {
140+ t.Fatalf("%d spawns, want %d", len(spawn), tc.issued)
141+ }
142+ if len(c.placement.pending) != tc.issued {
143+ t.Fatalf("%d claims kept, want %d", len(c.placement.pending), tc.issued)
144+ }
145+ if tc.issued == 1 {
146+ if _, ok := c.placement.pending[claimToken(<-spawn)]; !ok {
147+ t.Fatal("the issued command lost its claim")
148+ }
149+ }
150+ })
151+ }
152+}
153+
154+// A claim is placed up to termRetry after it was made, and not a moment
155+// later, even if no publish ran between its age and the window mapping.
156+func TestStashClaimExpiry(t *testing.T) {
157+ for _, tc := range []struct {
158+ name string
159+ age time.Duration
160+ placed bool
161+ }{
162+ {"fresh", 0, true},
163+ {"at the limit", termRetry, true},
164+ {"just after", termRetry + time.Nanosecond, false},
165+ } {
166+ t.Run(tc.name, func(t *testing.T) {
167+ now := claimBase
168+ c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
169+ m := c.cur().mon
170+ m.AddWindow(1)
171+ target := m.Current()
172+ m.show(m.Workspaces[1])
173+ req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: now})
174+ now = claimBase.Add(tc.age)
175+ c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
176+ _, w := c.screenOf(5)
177+ if placed := w == target && len(target.Stash) == 1; placed != tc.placed {
178+ t.Fatalf("placed %t, want %t", placed, tc.placed)
179+ }
180+ })
95181 }
96182 }
97183
98184 // A workspace that left every screen no longer takes the window.
99185 func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) {
100- c := stashSpawnCore(t, nil, nil)
101- req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: time.Now()})
186+ now := claimBase
187+ c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
188+ req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: now})
102189 c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
103190 if _, w := c.screenOf(5); w != c.cur().mon.Current() {
104191 t.Fatal("window did not map onto the current workspace")
@@ -107,3 +194,83 @@ func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) {
107194 t.Fatal("claim kept")
108195 }
109196 }
197+
198+// A window opened by a stash spawn is shown, selected and focused in the
199+// stash whatever fullscreen state the workspace and the stash are in.
200+func TestStashSpawnedAlwaysShown(t *testing.T) {
201+ // base has tile 1 and stash [2 3], 3 selected, the stash shown.
202+ base := func() *Workspace {
203+ w := workspace()
204+ w.AddWindow(1)
205+ w.AddWindow(2)
206+ w.AddWindow(3)
207+ w.FocusID(2)
208+ w.ToggleWindowStash()
209+ w.FocusID(3)
210+ w.ToggleWindowStash()
211+ return w
212+ }
213+ shown := func(t *testing.T, w *Workspace) {
214+ t.Helper()
215+ if id, _ := w.Focused(); id != 5 {
216+ t.Fatalf("focused %d, want 5", id)
217+ }
218+ if p := previewOf(t, w.Layout(), 5); p.Hidden || !p.Focused {
219+ t.Fatalf("new window %+v", p)
220+ }
221+ if w.Stash[w.stashAt].ID != 5 {
222+ t.Fatalf("selected %d", w.Stash[w.stashAt].ID)
223+ }
224+ }
225+
226+ t.Run("covering tile outside the stash", func(t *testing.T) {
227+ w := base()
228+ w.FocusID(1)
229+ w.ToggleFullscreen()
230+ w.stashSpawned(5)
231+ shown(t, w)
232+ if w.cover() != 1 || !w.stashOverCover() {
233+ t.Fatalf("cover %d: the tile must stay fullscreen under the stash", w.cover())
234+ }
235+ if p := previewOf(t, w.Layout(), 1); !p.Fullscreen || p.Hidden {
236+ t.Fatalf("tile %+v", p)
237+ }
238+ w.ToggleStashVisible()
239+ if id, _ := w.Focused(); id != 1 || w.cover() != 1 {
240+ t.Fatalf("hidden stash: focused %d cover %d, want the tile", id, w.cover())
241+ }
242+ })
243+
244+ t.Run("fullscreen stash member", func(t *testing.T) {
245+ w := base()
246+ w.ToggleFullscreen() // 3
247+ if w.cover() != 3 {
248+ t.Fatalf("setup: cover %d", w.cover())
249+ }
250+ w.stashSpawned(5)
251+ shown(t, w)
252+ if w.cover() != 0 {
253+ t.Fatalf("cover %d: the stash member still covers", w.cover())
254+ }
255+ if p := previewOf(t, w.Layout(), 3); p.Fullscreen {
256+ t.Fatalf("old member %+v", p)
257+ }
258+ })
259+
260+ t.Run("fullscreen waiting for the stash", func(t *testing.T) {
261+ w := base()
262+ w.ToggleStashVisible() // hidden
263+ w.SetFullscreen(3, true)
264+ if w.hiddenFullscreen != 3 {
265+ t.Fatalf("setup: deferred %d", w.hiddenFullscreen)
266+ }
267+ w.stashSpawned(5)
268+ shown(t, w)
269+ if w.cover() != 0 || w.hiddenFullscreen != 0 {
270+ t.Fatalf("cover %d, deferred %d", w.cover(), w.hiddenFullscreen)
271+ }
272+ w.ToggleStashVisible()
273+ w.ToggleStashVisible()
274+ shown(t, w)
275+ })
276+}
@@ -2,19 +2,32 @@ package core
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "strings"6 "strings"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
10+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9 "github.com/bnema/neferwl/internal/ports"11 "github.com/bnema/neferwl/internal/ports"
10 )12 )
11 13
12-func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest) *Core {14+// claimBase is the instant of the fixed test clock.
15+var claimBase = time.Unix(1000, 0)
16+
17+// fixedClock is a mock clock at *now, which a test moves by assigning.
18+func fixedClock(t *testing.T, now *time.Time) ports.Clock {
19+ t.Helper()
20+ clock := portsmocks.NewMockClock(t)
21+ clock.EXPECT().Now().RunAndReturn(func() time.Time { return *now }).Maybe()
22+ return clock
23+}
24+
25+func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest, clock ports.Clock) *Core {
13 t.Helper()26 t.Helper()
14 var cfg ports.Config27 var cfg ports.Config
15 cfg.Keyboard.CmdKey = "alt"28 cfg.Keyboard.CmdKey = "alt"
16 cfg.Layout.MaxColumns = 229 cfg.Layout.MaxColumns = 2
17- c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{})30+ c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{Clock: clock})
18 if err != nil {31 if err != nil {
19 t.Fatal(err)32 t.Fatal(err)
20 }33 }
@@ -26,34 +39,41 @@ func claimToken(req ports.SpawnRequest) string {
26 return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")39 return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
27 }40 }
28 41
29-// Wayland reads tokens only while slots are pending: the flag is sent before42+// Wayland reads tokens only while slots are pending, so the flag is sent
30-// the spawn can reach the launcher.43+// before the spawn can reach the launcher. The command channel is unbuffered
44+// and read last: the spawn can only follow the flag, never come first.
31 func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {45 func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
32- commands := make(chan ports.ClientCommand) // unbuffered: sends are observable46+ now := claimBase
47+ commands := make(chan ports.ClientCommand)
33 spawn := make(chan ports.SpawnRequest, 1)48 spawn := make(chan ports.SpawnRequest, 1)
34- c := stashSpawnCore(t, commands, spawn)49+ c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
50+ ctx, cancel := context.WithCancel(context.Background())
51+ t.Cleanup(cancel)
35 done := make(chan error, 1)52 done := make(chan error, 1)
36- go func() {53+ go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()
37- done <- c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"firefox"}}, false)54+ // Both channels are awaited at once: the first to communicate wins.
38- }()55+ timeout := time.After(5 * time.Second)
39- // The send blocks on the unread command: nothing may be spawned yet.
40- select {
41- case req := <-spawn:
42- t.Fatalf("spawned before the pending flag was delivered: %v", req)
43- case <-time.After(50 * time.Millisecond):
44- }
45 select {56 select {
46 case v := <-commands:57 case v := <-commands:
47 if p, ok := v.(ports.SlotsPending); !ok || !p.Pending {58 if p, ok := v.(ports.SlotsPending); !ok || !p.Pending {
48 t.Fatalf("command %v", v)59 t.Fatalf("command %v", v)
49 }60 }
50- case <-time.After(time.Second):61+ case req := <-spawn:
62+ t.Fatalf("spawned before the pending flag was delivered: %v", req)
63+ case <-timeout:
51 t.Fatal("no SlotsPending")64 t.Fatal("no SlotsPending")
52 }65 }
53- if err := <-done; err != nil {66+ select {
54- t.Fatal(err)67+ case err := <-done:
68+ if err != nil {
69+ t.Fatal(err)
70+ }
71+ case <-timeout:
72+ t.Fatal("spawnStash did not return")
55 }73 }
56- if len(spawn) != 1 {74+ select {
75+ case <-spawn:
76+ default:
57 t.Fatal("no spawn")77 t.Fatal("no spawn")
58 }78 }
59 }79 }
@@ -61,8 +81,9 @@ func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
61 // A send the launcher queue suppresses leaves no claim behind, and the81 // A send the launcher queue suppresses leaves no claim behind, and the
62 // pending flag falls again at the next publish.82 // pending flag falls again at the next publish.
63 func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {83 func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
84+ now := claimBase
64 commands := make(chan ports.ClientCommand, 8)85 commands := make(chan ports.ClientCommand, 8)
65- c := stashSpawnCore(t, commands, nil) // no launcher queue: every send is suppressed86+ c := stashSpawnCore(t, commands, nil, fixedClock(t, &now)) // no launcher queue: every send is suppressed
66 if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil {87 if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil {
67 t.Fatal(err)88 t.Fatal(err)
68 }89 }
@@ -80,25 +101,91 @@ func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
80 }101 }
81 }102 }
82 103
83-// An unanswered claim expires like a terminal's, even if no publish ran104+// From the first command that is not issued, the rest are not sent and their
84-// between its age and the window mapping.105+// claims are cancelled; the issued ones keep theirs.
85-func TestStashClaimExpires(t *testing.T) {106+func TestSpawnStashCancelsClaimsFromFirstUnissued(t *testing.T) {
86- c := stashSpawnCore(t, nil, nil)107+ for _, tc := range []struct {
87- m := c.cur().mon108+ name string
88- m.AddWindow(1)109+ queue int
89- target := m.Current()110+ cancelled bool
90- m.show(m.Workspaces[1])111+ issued int
91- req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: time.Now().Add(-2 * termRetry)})112+ wantErr error
92- c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})113+ }{
93- if _, w := c.screenOf(5); w != m.Current() || len(target.Stash) != 0 {114+ {"queue of one", 1, false, 1, nil},
94- t.Fatal("expired claim still placed the window in the stash")115+ {"publication fails", 4, true, 0, context.Canceled},
116+ } {
117+ t.Run(tc.name, func(t *testing.T) {
118+ now := claimBase
119+ commands := make(chan ports.ClientCommand) // unread: a send blocks until the context ends
120+ spawn := make(chan ports.SpawnRequest, tc.queue)
121+ c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
122+ ctx, cancel := context.WithCancel(context.Background())
123+ t.Cleanup(cancel)
124+ if tc.cancelled {
125+ cancel()
126+ } else {
127+ // An unread unbuffered channel would block the flag: read it.
128+ go func() {
129+ select {
130+ case <-commands:
131+ case <-ctx.Done():
132+ }
133+ }()
134+ }
135+ err := c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false)
136+ if !errors.Is(err, tc.wantErr) {
137+ t.Fatalf("error %v, want %v", err, tc.wantErr)
138+ }
139+ if len(spawn) != tc.issued {
140+ t.Fatalf("%d spawns, want %d", len(spawn), tc.issued)
141+ }
142+ if len(c.placement.pending) != tc.issued {
143+ t.Fatalf("%d claims kept, want %d", len(c.placement.pending), tc.issued)
144+ }
145+ if tc.issued == 1 {
146+ if _, ok := c.placement.pending[claimToken(<-spawn)]; !ok {
147+ t.Fatal("the issued command lost its claim")
148+ }
149+ }
150+ })
151+ }
152+}
153+
154+// A claim is placed up to termRetry after it was made, and not a moment
155+// later, even if no publish ran between its age and the window mapping.
156+func TestStashClaimExpiry(t *testing.T) {
157+ for _, tc := range []struct {
158+ name string
159+ age time.Duration
160+ placed bool
161+ }{
162+ {"fresh", 0, true},
163+ {"at the limit", termRetry, true},
164+ {"just after", termRetry + time.Nanosecond, false},
165+ } {
166+ t.Run(tc.name, func(t *testing.T) {
167+ now := claimBase
168+ c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
169+ m := c.cur().mon
170+ m.AddWindow(1)
171+ target := m.Current()
172+ m.show(m.Workspaces[1])
173+ req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: now})
174+ now = claimBase.Add(tc.age)
175+ c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
176+ _, w := c.screenOf(5)
177+ if placed := w == target && len(target.Stash) == 1; placed != tc.placed {
178+ t.Fatalf("placed %t, want %t", placed, tc.placed)
179+ }
180+ })
95 }181 }
96 }182 }
97 183
98 // A workspace that left every screen no longer takes the window.184 // A workspace that left every screen no longer takes the window.
99 func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) {185 func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) {
100- c := stashSpawnCore(t, nil, nil)186+ now := claimBase
101- req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: time.Now()})187+ c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
188+ req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: now})
102 c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})189 c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
103 if _, w := c.screenOf(5); w != c.cur().mon.Current() {190 if _, w := c.screenOf(5); w != c.cur().mon.Current() {
104 t.Fatal("window did not map onto the current workspace")191 t.Fatal("window did not map onto the current workspace")
@@ -107,3 +194,83 @@ func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) {
107 t.Fatal("claim kept")194 t.Fatal("claim kept")
108 }195 }
109 }196 }
197+
198+// A window opened by a stash spawn is shown, selected and focused in the
199+// stash whatever fullscreen state the workspace and the stash are in.
200+func TestStashSpawnedAlwaysShown(t *testing.T) {
201+ // base has tile 1 and stash [2 3], 3 selected, the stash shown.
202+ base := func() *Workspace {
203+ w := workspace()
204+ w.AddWindow(1)
205+ w.AddWindow(2)
206+ w.AddWindow(3)
207+ w.FocusID(2)
208+ w.ToggleWindowStash()
209+ w.FocusID(3)
210+ w.ToggleWindowStash()
211+ return w
212+ }
213+ shown := func(t *testing.T, w *Workspace) {
214+ t.Helper()
215+ if id, _ := w.Focused(); id != 5 {
216+ t.Fatalf("focused %d, want 5", id)
217+ }
218+ if p := previewOf(t, w.Layout(), 5); p.Hidden || !p.Focused {
219+ t.Fatalf("new window %+v", p)
220+ }
221+ if w.Stash[w.stashAt].ID != 5 {
222+ t.Fatalf("selected %d", w.Stash[w.stashAt].ID)
223+ }
224+ }
225+
226+ t.Run("covering tile outside the stash", func(t *testing.T) {
227+ w := base()
228+ w.FocusID(1)
229+ w.ToggleFullscreen()
230+ w.stashSpawned(5)
231+ shown(t, w)
232+ if w.cover() != 1 || !w.stashOverCover() {
233+ t.Fatalf("cover %d: the tile must stay fullscreen under the stash", w.cover())
234+ }
235+ if p := previewOf(t, w.Layout(), 1); !p.Fullscreen || p.Hidden {
236+ t.Fatalf("tile %+v", p)
237+ }
238+ w.ToggleStashVisible()
239+ if id, _ := w.Focused(); id != 1 || w.cover() != 1 {
240+ t.Fatalf("hidden stash: focused %d cover %d, want the tile", id, w.cover())
241+ }
242+ })
243+
244+ t.Run("fullscreen stash member", func(t *testing.T) {
245+ w := base()
246+ w.ToggleFullscreen() // 3
247+ if w.cover() != 3 {
248+ t.Fatalf("setup: cover %d", w.cover())
249+ }
250+ w.stashSpawned(5)
251+ shown(t, w)
252+ if w.cover() != 0 {
253+ t.Fatalf("cover %d: the stash member still covers", w.cover())
254+ }
255+ if p := previewOf(t, w.Layout(), 3); p.Fullscreen {
256+ t.Fatalf("old member %+v", p)
257+ }
258+ })
259+
260+ t.Run("fullscreen waiting for the stash", func(t *testing.T) {
261+ w := base()
262+ w.ToggleStashVisible() // hidden
263+ w.SetFullscreen(3, true)
264+ if w.hiddenFullscreen != 3 {
265+ t.Fatalf("setup: deferred %d", w.hiddenFullscreen)
266+ }
267+ w.stashSpawned(5)
268+ shown(t, w)
269+ if w.cover() != 0 || w.hiddenFullscreen != 0 {
270+ t.Fatalf("cover %d, deferred %d", w.cover(), w.hiddenFullscreen)
271+ }
272+ w.ToggleStashVisible()
273+ w.ToggleStashVisible()
274+ shown(t, w)
275+ })
276+}

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 23

Tags 10

↑↓ moveenter openesc close