feat/hints a471fa4

overview_named_test.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"slices"
6	"testing"
7
8	"github.com/bnema/neferwl/internal/ports"
9)
10
11func namedOverviewMonitor() *Monitor {
12	m := overviewMonitor()
13	m.SetNamed([]NamedWorkspace{{Name: "dev"}, {Name: "empty"}, {Name: "game"}})
14	m.Focus(1)
15	m.ToggleNamed("dev")
16	m.AddWindow(5)
17	m.AddWindow(6)
18	m.Current().ToggleWindowStash()
19	m.ToggleNamed("game")
20	m.AddWindow(7)
21	m.Focus(1)
22	return m
23}
24
25// The overview rebuilds its row order and slots every frame: with named
26// workspaces to group and sort, from the monitor's scratch, it costs nothing.
27func TestOverviewNamedRowsAllocations(t *testing.T) {
28	m := namedOverviewMonitor()
29	m.Focus(0)
30	m.ToggleNamed("dev")
31	m.ToggleOverview()
32	if len(m.overviewWorkspaces()) < 4 {
33		t.Fatal("setup: named rows missing")
34	}
35	if n := testing.AllocsPerRun(50, func() { m.overviewWorkspaces() }); n != 0 {
36		t.Errorf("overviewWorkspaces allocs = %v, want 0", n)
37	}
38	if n := testing.AllocsPerRun(50, func() { m.overviewRows() }); n != 0 {
39		t.Errorf("overviewRows allocs = %v, want 0", n)
40	}
41}
42
43func TestOverviewNamedFollowsInvocationWorkspace(t *testing.T) {
44	m := namedOverviewMonitor()
45	numbered := slices.Clone(m.Workspaces)
46	m.Focus(0)
47	m.ToggleNamed("dev")
48	m.ToggleNamed("dev")
49	m.ToggleOverview()
50	if !slices.Equal(m.overviewWorkspaces(), []*Workspace{numbered[0], m.byName("dev"), numbered[1], m.byName("game")}) {
51		t.Fatal("named workspace not directly below its invocation workspace")
52	}
53	m.OverviewMove(0, 1)
54	if m.Current().Name != "dev" {
55		t.Fatal("down from workspace 1 did not reach invoked named workspace")
56	}
57	m.OverviewMove(1, 0) // leave the named workspace's stash pile
58	m.OverviewMove(0, 1)
59	if m.Current() != numbered[1] {
60		t.Fatal("down from named did not resume numbered sequence")
61	}
62	m.CancelOverview()
63	m.Focus(1)
64	m.ToggleNamed("dev")
65	m.ToggleNamed("dev")
66	m.ToggleOverview()
67	if !slices.Equal(m.overviewWorkspaces(), []*Workspace{numbered[0], numbered[1], m.byName("dev"), m.byName("game")}) {
68		t.Fatal("calling named workspace elsewhere did not update its placement")
69	}
70	if !slices.Equal(m.Workspaces, numbered) {
71		t.Fatal("overview attachment changed workspace numbering")
72	}
73}
74
75func TestOverviewNamedNavigationDoesNotReattach(t *testing.T) {
76	m := namedOverviewMonitor()
77	m.Focus(0)
78	m.ToggleNamed("dev")
79	m.ToggleNamed("dev")
80	m.ToggleOverview()
81	before := slices.Clone(m.overviewWorkspaces())
82	m.OverviewMove(0, 1)
83	m.OverviewMove(0, 1)
84	m.OverviewMove(0, -1)
85	if !slices.Equal(m.overviewWorkspaces(), before) || m.Current().Name != "dev" {
86		t.Fatal("browsing from below changed named attachment")
87	}
88	_, dividers := m.overviewRows()
89	if len(dividers) != 2 {
90		t.Fatalf("named group needs upper and lower dividers: %+v", dividers)
91	}
92	m.ToggleOverview()
93	m.ToggleNamed("dev")
94	if m.Current() != before[0] {
95		t.Fatal("entering named from below lost invocation return")
96	}
97}
98
99func TestOverviewNamedAnchorRemoved(t *testing.T) {
100	m := namedOverviewMonitor()
101	m.Focus(0)
102	m.take(m.Workspaces[1])
103	m.ToggleOverview()
104	rows := m.overviewWorkspaces()
105	if !slices.Equal(rows, []*Workspace{m.Workspaces[0], m.byName("dev"), m.byName("game")}) {
106		t.Fatal("removed anchor left named rows unreachable")
107	}
108	m.OverviewMove(0, 1)
109	if m.Current().Name != "dev" {
110		t.Fatal("orphan named row not reachable")
111	}
112}
113
114func TestOverviewNamedAnchorSurvivesConfigAndMonitorMove(t *testing.T) {
115	m := namedOverviewMonitor()
116	anchor, dev, game := m.Workspaces[1], m.byName("dev"), m.byName("game")
117	m.SetNamed([]NamedWorkspace{{Name: "game"}, {Name: "dev"}})
118	if !slices.Equal(m.overviewWorkspaces(), []*Workspace{m.Workspaces[0], anchor, game, dev}) {
119		t.Fatal("config reorder lost attachment or ignored named order")
120	}
121	m.Focus(0)
122	m.take(anchor)
123	m.take(dev)
124	m.take(game)
125	dst := newMonitor("", "")
126	dst.SetOutput(300, 200)
127	dst.useSpecs([]NamedWorkspace{{Name: "dev"}, {Name: "game"}})
128	dst.adopt(anchor, false, 0)
129	dst.adopt(game, true, 0)
130	dst.adopt(dev, true, 0)
131	rows := dst.overviewWorkspaces()
132	if at := slices.Index(rows, anchor); at < 0 || at+2 >= len(rows) || rows[at+1] != dev || rows[at+2] != game {
133		t.Fatal("moved named workspaces not reachable on destination monitor")
134	}
135	if dev.overviewAfter != nil && !dst.has(dev.overviewAfter) {
136		t.Fatal("named workspace retained detached monitor anchor")
137	}
138}
139
140func TestOverviewNamedAnchorSurvivesReplug(t *testing.T) {
141	cfg := ports.Config{}
142	cfg.Layout.MaxColumns = 3
143	cfg.Keyboard.CmdKey = "super"
144	cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Monitor: "OUT-1"}}
145	c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
146	if err != nil {
147		t.Fatal(err)
148	}
149	c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200})
150	home := c.cur()
151	home.mon.SetNamed([]NamedWorkspace{{Name: "dev"}})
152	home.mon.AddWindow(1)
153	anchor := home.mon.Current()
154	home.mon.ToggleNamed("dev")
155	home.mon.AddWindow(2)
156	dev := home.mon.Current()
157	home.mon.Focus(1)
158	home.mon.AddWindow(3) // a later row makes bottom fallback detectably wrong
159	c.addScreen(ports.OutputInfo{Name: "OUT-2", Width: 300, Height: 200})
160	c.removeScreen("OUT-1")
161	c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200})
162	c.settleGuests()
163	for _, sc := range c.screens {
164		if sc.name() != "OUT-1" {
165			continue
166		}
167		if !sc.mon.has(dev) || dev.overviewAfter != anchor {
168			t.Fatal("replug lost named invocation anchor")
169		}
170		rows := sc.mon.overviewWorkspaces()
171		at := slices.Index(rows, anchor)
172		if at < 0 || at+1 >= len(rows) || rows[at+1] != dev {
173			t.Fatal("replug did not restore named row below invocation workspace")
174		}
175		return
176	}
177	t.Fatal("missing replugged screen")
178}
179
180func TestOverviewNamedEmptyAnchor(t *testing.T) {
181	m := namedOverviewMonitor()
182	m.Focus(len(m.Workspaces) - 1)
183	anchor := m.Current()
184	m.ToggleNamed("dev")
185	m.ToggleOverview()
186	rows := m.overviewWorkspaces()
187	if at := slices.Index(rows, anchor); at < 0 || at+1 >= len(rows) || rows[at+1] != m.byName("dev") {
188		t.Fatal("empty invocation workspace missing above named row")
189	}
190}
191
192func TestOverviewNamedNavigation(t *testing.T) {
193	m := namedOverviewMonitor()
194	numbered := slices.Clone(m.Workspaces)
195	m.ToggleOverview()
196	m.OverviewMove(0, 1)
197	if m.Current().Name != "dev" {
198		t.Fatalf("down from last occupied numbered workspace: %q", m.Current().Name)
199	}
200	if id := m.card(); id != 6 {
201		t.Fatalf("named stash selection: %d", id)
202	}
203	m.OverviewMove(1, 0)
204	m.OverviewMove(0, 1)
205	if m.Current().Name != "game" || m.card() != 0 {
206		t.Fatalf("next named workspace: %q, card %d", m.Current().Name, m.card())
207	}
208	m.OverviewMove(0, 1)
209	if m.Current().Name != "game" {
210		t.Fatal("past last named workspace")
211	}
212	m.OverviewMove(0, -1)
213	m.OverviewMove(1, 0)
214	m.OverviewMove(0, -1)
215	if m.Current() != numbered[1] || !slices.Equal(m.Workspaces, numbered) {
216		t.Fatal("return to numbered workspaces changed numbering")
217	}
218	m.CancelOverview()
219	m.Focus(0)
220	m.ToggleOverview()
221	_, dividers := m.overviewRows()
222	if len(dividers) != 0 {
223		t.Fatalf("divider within numbered group: %+v", dividers)
224	}
225}
226
227func TestOverviewNamedRowsKeepWidthAndStash(t *testing.T) {
228	m := namedOverviewMonitor()
229	m.ToggleOverview()
230	_, dividers := m.overviewRows()
231	if len(dividers) != 1 || dividers[0].Active || dividers[0].Rect.W != 276 {
232		t.Fatalf("group divider: %+v", dividers)
233	}
234	below := previewOf(t, m.Layout(), 5)
235	pile := previewOf(t, m.Layout(), 6)
236	if below.Hidden || !below.Peek || pile.Hidden || !pile.Peek {
237		t.Fatalf("named neighbor and stash not visible: %+v, %+v", below, pile)
238	}
239	if pile.Preview != overviewCardZoom {
240		t.Fatalf("stash zoom %v", pile.Preview)
241	}
242	m.OverviewMove(0, 1)
243	selected := previewOf(t, m.Layout(), 5)
244	if selected.Hidden || selected.Rect.X != below.Rect.X || selected.Rect.W != below.Rect.W || selected.Preview != below.Preview {
245		t.Fatalf("named row geometry changed: %+v, %+v", below, selected)
246	}
247	above := previewOf(t, m.Layout(), 4)
248	if above.Hidden || !above.Peek || above.Rect.Y >= selected.Rect.Y {
249		t.Fatalf("numbered neighbor above named: %+v", above)
250	}
251	_, dividers = m.overviewRows()
252	if len(dividers) != 1 || dividers[0].Rect.Y <= above.Rect.Y+above.Rect.H || dividers[0].Rect.Y >= selected.Rect.Y {
253		t.Fatalf("divider not between groups: %+v", dividers)
254	}
255	game := previewOf(t, m.Layout(), 7)
256	if game.Hidden || !game.Peek || game.Rect.Y <= selected.Rect.Y {
257		t.Fatalf("next named neighbor: %+v", game)
258	}
259}
260
261func TestOverviewNamedCancelAndToggleReturn(t *testing.T) {
262	m := namedOverviewMonitor()
263	m.ToggleNamed("dev")
264	back := m.back
265	m.ToggleOverview()
266	m.OverviewMove(0, 1)
267	m.CancelOverview()
268	if m.Current().Name != "dev" || m.back != back {
269		t.Fatal("Escape did not restore named workspace and its return target")
270	}
271	m.Focus(1)
272	m.ToggleOverview()
273	m.OverviewMove(0, 1)
274	m.ToggleOverview()
275	m.ToggleNamed("dev")
276	if m.Current() != back {
277		t.Fatal("named toggle did not return to overview entry workspace")
278	}
279}
280
281func TestOverviewNamedBrowseKeepsNumberedReturn(t *testing.T) {
282	for _, click := range []bool{false, true} {
283		m := namedOverviewMonitor()
284		back := m.Current()
285		m.ToggleOverview()
286		m.OverviewMove(0, 1)
287		if click {
288			m.OverviewPick(7)
289		} else {
290			m.OverviewMove(1, 0)
291			m.OverviewMove(0, 1)
292			m.ToggleOverview()
293		}
294		if m.Current().Name != "game" || m.back != back {
295			t.Fatalf("click %v: browsing lost numbered return", click)
296		}
297		m.ToggleNamed("game")
298		if m.Current() != back {
299			t.Fatalf("click %v: toggle did not return to numbered workspace", click)
300		}
301	}
302}
303
304func TestOverviewWorkspaceBindKeepsCurrentSelection(t *testing.T) {
305	m := maximizedOverview()
306	m.ToggleOverview()
307	m.OverviewMove(0, -1)
308	m.OverviewMove(1, 0)
309	front, selected := m.stackFront(m.Current()), m.overviewTarget()
310	if front.kind != stackColumns || selected != 3 {
311		t.Fatal("expected provisional hidden column 3")
312	}
313	for _, action := range []Action{"focus-workspace 1", "workspace unknown"} {
314		m.Apply(action)
315		if m.stackFront(m.Current()) != front || m.overviewTarget() != selected {
316			t.Fatalf("%s reset provisional selection", action)
317		}
318	}
319}
320
321func TestOverviewNamedNeighborPick(t *testing.T) {
322	m := namedOverviewMonitor()
323	back := m.Current()
324	m.ToggleOverview()
325	p := previewOf(t, m.Layout(), 6)
326	if p.Hidden {
327		t.Fatal("named stash neighbor hidden")
328	}
329	id := overviewIn(m.Layout(), float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2))
330	if id != 6 {
331		t.Fatalf("stash hit: %d", id)
332	}
333	m.OverviewPick(id)
334	if m.ov.open || m.Current().Name != "dev" || m.back != back || !m.Current().stashFocused() {
335		t.Fatal("named stash click did not commit selection and return target")
336	}
337}
338
339func TestOverviewNamedFocusBinds(t *testing.T) {
340	for _, action := range []Action{ActionFocusWindowDown, ActionFocusWorkspaceNext} {
341		t.Run(string(action), func(t *testing.T) {
342			m := namedOverviewMonitor()
343			m.ToggleOverview()
344			m.Apply(action)
345			if m.Current().Name != "dev" {
346				t.Fatalf("focus bind selected %q", m.Current().Name)
347			}
348		})
349	}
350	m := namedOverviewMonitor()
351	m.ToggleOverview()
352	m.Apply(Action("workspace dev"))
353	if m.card() != 6 {
354		t.Fatal("named bind did not initialize stash selection")
355	}
356	m.Apply(Action("focus-workspace 2"))
357	if m.card() != 0 || m.ov.row != nil {
358		t.Fatal("numbered bind retained previous row selection")
359	}
360}
361
362func TestOverviewNamedOutsideNavigationUnchanged(t *testing.T) {
363	m := namedOverviewMonitor()
364	m.Apply(ActionFocusWorkspaceNext)
365	if m.shown != nil || !m.Current().empty() {
366		t.Fatal("outside overview, down no longer reaches numbered spare")
367	}
368	m.ToggleNamed("dev")
369	m.Apply(ActionFocusWorkspacePrev)
370	if m.Current().Name != "dev" {
371		t.Fatal("outside overview, named workspace entered numbered list")
372	}
373}
374
375func TestOverviewNamedEmptyCurrentAndRemoval(t *testing.T) {
376	m := namedOverviewMonitor()
377	m.ToggleNamed("empty")
378	m.ToggleOverview()
379	if !m.Current().empty() {
380		t.Fatal("expected empty named current")
381	}
382	m.OverviewMove(0, 1)
383	if m.Current().Name != "game" {
384		t.Fatal("could not leave empty named row")
385	}
386	m.SetNamed([]NamedWorkspace{{Name: "game"}})
387	m.CancelOverview()
388	if !m.has(m.Current()) || m.back != nil && !m.has(m.back) {
389		t.Fatal("config removal left dangling overview return")
390	}
391}
392
393func TestOverviewNamedColumnPickClearsStash(t *testing.T) {
394	m := namedOverviewMonitor()
395	m.ToggleNamed("dev")
396	m.ToggleOverview()
397	if m.card() != 6 {
398		t.Fatal("expected stash selection")
399	}
400	m.OverviewPick(5)
401	if m.Current().stashFocused() || m.ov.open {
402		t.Fatal("column click kept stash selection")
403	}
404	id, _ := m.Focused()
405	if id != 5 {
406		t.Fatalf("column click focus %d", id)
407	}
408}
409
410func TestOverviewNamedDividerPublished(t *testing.T) {
411	scenes := make(chan []ports.Scene, 1)
412	cfg := ports.Config{}
413	cfg.Keyboard.CmdKey = "super"
414	cfg.Layout.MaxColumns = 3
415	c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 256), Scenes: scenes}, Options{})
416	if err != nil {
417		t.Fatal(err)
418	}
419	c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200})
420	c.cur().mon = namedOverviewMonitor()
421	c.cur().mon.ToggleOverview()
422	if err := c.publish(context.Background()); err != nil {
423		t.Fatal(err)
424	}
425	scene := (<-scenes)[0]
426	_, dividers := c.cur().mon.overviewRows()
427	if len(dividers) != 1 || !slices.Contains(scene.Separators, dividers[0]) {
428		t.Fatalf("group divider not published: %+v", scene.Separators)
429	}
430}
431
432func TestOverviewNamedScrollAndSwipe(t *testing.T) {
433	m := namedOverviewMonitor()
434	m.ToggleOverview()
435	m.overviewFocus(ActionFocusWorkspaceNext)
436	if m.Current().Name != "dev" {
437		t.Fatal("swipe did not cross groups")
438	}
439	m.overviewFocus(ActionFocusColumnRight)
440	m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: 120}})
441	if m.Current().Name != "game" {
442		t.Fatal("scroll did not reach next named workspace")
443	}
444}

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 25

Tags 10

↑↓ moveenter openesc close