v0.4.0 b6be3a8

slots.go RawHistoryFolder

1package core
2
3import (
4	"fmt"
5	"slices"
6
7	"github.com/bnema/neferwl/internal/ports"
8)
9
10// Slots of declared workspaces (ADR 011): workspace.<name>.column.N reserves
11// column N for the window of one command. The slot, not the window, has the
12// identity: neferwl spawns the command with SlotEnv set to a random token,
13// and the first window of a client carrying that token fills the slot.
14//
15// Only the token of the pending spawn is accepted: once the slot is filled,
16// or the command changes, other windows with that token (children of the slot
17// app inherit its environment) open as normal windows.
18//
19// A slot is spawned at startup, and again when the user shows its workspace
20// while it is empty. Nothing is relaunched on its own.
21
22type slotKey struct {
23	workspace string
24	index     int
25}
26
27type slotState struct {
28	argv   []string
29	width  Width
30	window WindowID // 0 while empty
31	// stale is set when the workspace is shown while the spawn is pending.
32	// Shown again still pending, the command is deemed failed and respawned;
33	// a slow app therefore gets one show to map its window.
34	stale bool
35}
36
37// slotSpec is one parsed slot of the config.
38type slotSpec struct {
39	key   slotKey
40	argv  []string
41	width Width
42}
43
44func parseSlots(ws []ports.WorkspaceConfig) ([]slotSpec, error) {
45	var specs []slotSpec
46	for _, w := range ws {
47		for _, s := range w.Slots {
48			width, err := ParseWidth(s.Width)
49			if err != nil {
50				return nil, fmt.Errorf("workspace %s column %d: %w", w.Name, s.Index, err)
51			}
52			if len(s.Argv) == 0 || s.Index < 1 {
53				return nil, fmt.Errorf("workspace %s column %d: invalid slot", w.Name, s.Index)
54			}
55			specs = append(specs, slotSpec{key: slotKey{w.Name, s.Index}, argv: slices.Clone(s.Argv), width: width})
56		}
57	}
58	return specs, nil
59}
60
61// updateSlots applies the config and returns the slots to spawn: new ones,
62// and empty ones whose command changed. A slot dropped from config keeps its
63// window as a normal column. Call before named workspaces are applied, so a
64// renamed or dropped workspace can still be found.
65func (c *Core) updateSlots(specs []slotSpec) []slotKey {
66	want := map[slotKey]slotSpec{}
67	for _, s := range specs {
68		want[s.key] = s
69	}
70	for key := range c.slots {
71		if _, ok := want[key]; ok {
72			continue
73		}
74		if _, w := c.byName(key.workspace); w != nil {
75			w.unslot(key.index)
76		}
77		c.placement.dropSlot(key)
78		delete(c.slots, key)
79	}
80	var spawn []slotKey
81	for _, s := range specs {
82		st, ok := c.slots[s.key]
83		if !ok {
84			c.slots[s.key] = &slotState{argv: s.argv, width: s.width}
85			spawn = append(spawn, s.key)
86			continue
87		}
88		if st.width != s.width {
89			st.width = s.width
90			if _, w := c.byName(s.key.workspace); w != nil {
91				w.setSlotWidth(s.key.index, s.width)
92			}
93		}
94		if !slices.Equal(st.argv, s.argv) {
95			st.argv = s.argv
96			if st.window == 0 {
97				// A pending spawn ran the old command: its window opens as
98				// a normal one; the new command replaces it.
99				c.placement.dropSlot(s.key)
100				spawn = append(spawn, s.key)
101			}
102		}
103	}
104	return spawn
105}
106
107// spawnSlot starts the slot's command with a fresh token.
108func (c *Core) spawnSlot(key slotKey) ports.SpawnRequest {
109	st := c.slots[key]
110	c.placement.dropSlot(key)
111	st.stale = false
112	return c.placement.request(st.argv, spawnTarget{slot: key})
113}
114
115// releaseSlots empties slots whose window is gone or no longer in its slot
116// column (closed, or moved to another workspace by the user).
117func (c *Core) releaseSlots() {
118	for key, st := range c.slots {
119		if st.window == 0 {
120			continue
121		}
122		_, w := c.byName(key.workspace)
123		if w == nil || !w.inSlot(st.window, key.index) {
124			st.window = 0
125		}
126	}
127}
128
129// refill returns the empty slots of the workspace on screen to spawn. A slot
130// still pending for the second show in a row is respawned: its command
131// failed, exited or handed off to another instance without a window.
132func (c *Core) refill() []slotKey {
133	name := c.cur().mon.Current().Name
134	if name == "" {
135		return nil
136	}
137	var keys []slotKey
138	for key, st := range c.slots {
139		switch {
140		case key.workspace != name || st.window != 0:
141		case c.placement.slotPending(key) && !st.stale:
142			st.stale = true
143		default:
144			c.placement.dropSlot(key)
145			keys = append(keys, key)
146		}
147	}
148	slices.SortFunc(keys, func(a, b slotKey) int { return a.index - b.index })
149	return keys
150}

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