v0.1.0 d9218d6

slots.go RawHistoryFolder

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

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 26

Tags 10

↑↓ moveenter openesc close