v0.2.0 13c6a90

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

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