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

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