detached cb04ab1

refactor(core): give spawn placement targets an explicit kind

commit
cb04ab13df080b7e71ec7f58405fcbe359bd9d35
author
brice · · 2026-10-14 20:18 UTC
parent
24a63d3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +44 -29
M internal/core/spawn_placement.go +36 -21
@@ -15,11 +15,23 @@ type spawnPlacement struct {
1515 slots map[slotKey]string
1616 }
1717
18-// spawnTarget identifies the destination of one pending spawn.
18+// spawnKind tells what a pending spawn is destined for.
19+type spawnKind uint8
20+
21+const (
22+ // spawnSlot fills a slot; the zero value.
23+ spawnSlot spawnKind = iota
24+ // spawnTerminal is an automatic terminal for an empty workspace.
25+ spawnTerminal
26+)
27+
28+// spawnTarget identifies the destination of one pending spawn. slot is set
29+// for spawnSlot; ws and at for the workspace-bound kinds.
1930 type spawnTarget struct {
20- slot slotKey
21- terminal *Workspace
22- at time.Time
31+ kind spawnKind
32+ slot slotKey
33+ ws *Workspace
34+ at time.Time
2335 }
2436
2537 func newSpawnPlacement() spawnPlacement {
@@ -30,7 +42,7 @@ func newSpawnPlacement() spawnPlacement {
3042 func (p *spawnPlacement) request(argv []string, target spawnTarget) ports.SpawnRequest {
3143 token := rand.Text()
3244 p.pending[token] = target
33- if target.terminal == nil {
45+ if target.kind == spawnSlot {
3446 p.slots[target.slot] = token
3547 }
3648 return ports.SpawnRequest{Argv: slices.Clone(argv), Env: []string{ports.SlotEnv + "=" + token}}
@@ -50,20 +62,20 @@ func (p *spawnPlacement) slotPending(key slotKey) bool { return p.slots[key] !=
5062 // anyPending reports whether any spawn still awaits a window.
5163 func (p *spawnPlacement) anyPending() bool { return len(p.pending) != 0 }
5264
53-// terminalPending reports whether a workspace awaits an automatic terminal.
54-func (p *spawnPlacement) terminalPending(w *Workspace) bool {
65+// workspacePending reports whether a workspace awaits a spawn of the given kind.
66+func (p *spawnPlacement) workspacePending(w *Workspace, kind spawnKind) bool {
5567 for _, target := range p.pending {
56- if target.terminal == w {
68+ if target.kind == kind && target.ws == w {
5769 return true
5870 }
5971 }
6072 return false
6173 }
6274
63-// expireTerminals drops terminal claims older than the retry interval.
64-func (p *spawnPlacement) expireTerminals(now time.Time) {
75+// expireWorkspaceClaims drops workspace-bound claims older than the retry interval.
76+func (p *spawnPlacement) expireWorkspaceClaims(now time.Time) {
6577 for token, target := range p.pending {
66- if target.terminal != nil && now.Sub(target.at) > termRetry {
78+ if target.kind != spawnSlot && now.Sub(target.at) > termRetry {
6779 delete(p.pending, token)
6880 }
6981 }
@@ -95,22 +107,25 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
95107 }
96108 if v.Slot != "" {
97109 if target, ok := p.pending[v.Slot]; ok {
98- if target.terminal != nil {
110+ switch target.kind {
111+ case spawnTerminal:
99112 delete(p.pending, v.Slot)
100113 for _, s := range c.screens {
101- if s.mon.has(target.terminal) {
102- target.terminal.AddWindow(v.ID)
114+ if s.mon.has(target.ws) {
115+ target.ws.AddWindow(v.ID)
103116 s.mon.normalize()
104117 return
105118 }
106119 }
107- } else if st := c.slots[target.slot]; st != nil && st.window == 0 {
108- if sc, w := c.byName(target.slot.workspace); w != nil {
109- p.dropSlot(target.slot)
110- st.window = v.ID
111- w.AddSlotWindow(v.ID, target.slot.index, st.cells)
112- sc.mon.normalize()
113- return
120+ case spawnSlot:
121+ if st := c.slots[target.slot]; st != nil && st.window == 0 {
122+ if sc, w := c.byName(target.slot.workspace); w != nil {
123+ p.dropSlot(target.slot)
124+ st.window = v.ID
125+ w.AddSlotWindow(v.ID, target.slot.index, st.cells)
126+ sc.mon.normalize()
127+ return
128+ }
114129 }
115130 }
116131 }
@@ -15,11 +15,23 @@ type spawnPlacement struct {
15 slots map[slotKey]string15 slots map[slotKey]string
16 }16 }
17 17
18-// spawnTarget identifies the destination of one pending spawn.18+// spawnKind tells what a pending spawn is destined for.
19+type spawnKind uint8
20+
21+const (
22+ // spawnSlot fills a slot; the zero value.
23+ spawnSlot spawnKind = iota
24+ // spawnTerminal is an automatic terminal for an empty workspace.
25+ spawnTerminal
26+)
27+
28+// spawnTarget identifies the destination of one pending spawn. slot is set
29+// for spawnSlot; ws and at for the workspace-bound kinds.
19 type spawnTarget struct {30 type spawnTarget struct {
20- slot slotKey31+ kind spawnKind
21- terminal *Workspace32+ slot slotKey
22- at time.Time33+ ws *Workspace
34+ at time.Time
23 }35 }
24 36
25 func newSpawnPlacement() spawnPlacement {37 func newSpawnPlacement() spawnPlacement {
@@ -30,7 +42,7 @@ func newSpawnPlacement() spawnPlacement {
30 func (p *spawnPlacement) request(argv []string, target spawnTarget) ports.SpawnRequest {42 func (p *spawnPlacement) request(argv []string, target spawnTarget) ports.SpawnRequest {
31 token := rand.Text()43 token := rand.Text()
32 p.pending[token] = target44 p.pending[token] = target
33- if target.terminal == nil {45+ if target.kind == spawnSlot {
34 p.slots[target.slot] = token46 p.slots[target.slot] = token
35 }47 }
36 return ports.SpawnRequest{Argv: slices.Clone(argv), Env: []string{ports.SlotEnv + "=" + token}}48 return ports.SpawnRequest{Argv: slices.Clone(argv), Env: []string{ports.SlotEnv + "=" + token}}
@@ -50,20 +62,20 @@ func (p *spawnPlacement) slotPending(key slotKey) bool { return p.slots[key] !=
50 // anyPending reports whether any spawn still awaits a window.62 // anyPending reports whether any spawn still awaits a window.
51 func (p *spawnPlacement) anyPending() bool { return len(p.pending) != 0 }63 func (p *spawnPlacement) anyPending() bool { return len(p.pending) != 0 }
52 64
53-// terminalPending reports whether a workspace awaits an automatic terminal.65+// workspacePending reports whether a workspace awaits a spawn of the given kind.
54-func (p *spawnPlacement) terminalPending(w *Workspace) bool {66+func (p *spawnPlacement) workspacePending(w *Workspace, kind spawnKind) bool {
55 for _, target := range p.pending {67 for _, target := range p.pending {
56- if target.terminal == w {68+ if target.kind == kind && target.ws == w {
57 return true69 return true
58 }70 }
59 }71 }
60 return false72 return false
61 }73 }
62 74
63-// expireTerminals drops terminal claims older than the retry interval.75+// expireWorkspaceClaims drops workspace-bound claims older than the retry interval.
64-func (p *spawnPlacement) expireTerminals(now time.Time) {76+func (p *spawnPlacement) expireWorkspaceClaims(now time.Time) {
65 for token, target := range p.pending {77 for token, target := range p.pending {
66- if target.terminal != nil && now.Sub(target.at) > termRetry {78+ if target.kind != spawnSlot && now.Sub(target.at) > termRetry {
67 delete(p.pending, token)79 delete(p.pending, token)
68 }80 }
69 }81 }
@@ -95,22 +107,25 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) {
95 }107 }
96 if v.Slot != "" {108 if v.Slot != "" {
97 if target, ok := p.pending[v.Slot]; ok {109 if target, ok := p.pending[v.Slot]; ok {
98- if target.terminal != nil {110+ switch target.kind {
111+ case spawnTerminal:
99 delete(p.pending, v.Slot)112 delete(p.pending, v.Slot)
100 for _, s := range c.screens {113 for _, s := range c.screens {
101- if s.mon.has(target.terminal) {114+ if s.mon.has(target.ws) {
102- target.terminal.AddWindow(v.ID)115+ target.ws.AddWindow(v.ID)
103 s.mon.normalize()116 s.mon.normalize()
104 return117 return
105 }118 }
106 }119 }
107- } else if st := c.slots[target.slot]; st != nil && st.window == 0 {120+ case spawnSlot:
108- if sc, w := c.byName(target.slot.workspace); w != nil {121+ if st := c.slots[target.slot]; st != nil && st.window == 0 {
109- p.dropSlot(target.slot)122+ if sc, w := c.byName(target.slot.workspace); w != nil {
110- st.window = v.ID123+ p.dropSlot(target.slot)
111- w.AddSlotWindow(v.ID, target.slot.index, st.cells)124+ st.window = v.ID
112- sc.mon.normalize()125+ w.AddSlotWindow(v.ID, target.slot.index, st.cells)
113- return126+ sc.mon.normalize()
127+ return
128+ }
114 }129 }
115 }130 }
116 }131 }
M internal/core/spawn_placement_test.go +3 -3
@@ -60,13 +60,13 @@ func TestExpiredTerminalTokenMapsNormally(t *testing.T) {
6060 t.Fatal("focus did not move to another workspace")
6161 }
6262 t0 := time.Unix(123, 0)
63- req := c.placement.request([]string{"foot"}, spawnTarget{terminal: target, at: t0})
63+ req := c.placement.request([]string{"foot"}, spawnTarget{kind: spawnTerminal, ws: target, at: t0})
6464 token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
65- c.placement.expireTerminals(t0.Add(termRetry))
65+ c.placement.expireWorkspaceClaims(t0.Add(termRetry))
6666 if _, ok := c.placement.pending[token]; !ok {
6767 t.Fatal("claim expired at retry boundary")
6868 }
69- c.placement.expireTerminals(t0.Add(termRetry + time.Nanosecond))
69+ c.placement.expireWorkspaceClaims(t0.Add(termRetry + time.Nanosecond))
7070 if _, ok := c.placement.pending[token]; ok {
7171 t.Fatal("claim survived past retry boundary")
7272 }
@@ -60,13 +60,13 @@ func TestExpiredTerminalTokenMapsNormally(t *testing.T) {
60 t.Fatal("focus did not move to another workspace")60 t.Fatal("focus did not move to another workspace")
61 }61 }
62 t0 := time.Unix(123, 0)62 t0 := time.Unix(123, 0)
63- req := c.placement.request([]string{"foot"}, spawnTarget{terminal: target, at: t0})63+ req := c.placement.request([]string{"foot"}, spawnTarget{kind: spawnTerminal, ws: target, at: t0})
64 token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")64 token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
65- c.placement.expireTerminals(t0.Add(termRetry))65+ c.placement.expireWorkspaceClaims(t0.Add(termRetry))
66 if _, ok := c.placement.pending[token]; !ok {66 if _, ok := c.placement.pending[token]; !ok {
67 t.Fatal("claim expired at retry boundary")67 t.Fatal("claim expired at retry boundary")
68 }68 }
69- c.placement.expireTerminals(t0.Add(termRetry + time.Nanosecond))69+ c.placement.expireWorkspaceClaims(t0.Add(termRetry + time.Nanosecond))
70 if _, ok := c.placement.pending[token]; ok {70 if _, ok := c.placement.pending[token]; ok {
71 t.Fatal("claim survived past retry boundary")71 t.Fatal("claim survived past retry boundary")
72 }72 }
M internal/core/terminal.go +5 -5
@@ -23,7 +23,7 @@ func (c *Core) keepsTerminal() bool {
2323 // Workspaces with slots are left to them.
2424 func (c *Core) fillEmpty() []ports.SpawnRequest {
2525 now := c.now()
26- c.placement.expireTerminals(now)
26+ c.placement.expireWorkspaceClaims(now)
2727 if !c.keepsTerminal() {
2828 return nil
2929 }
@@ -39,12 +39,12 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
3939 continue
4040 }
4141 }
42- if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.terminalPending(w) || now.Sub(w.termAt) < termRetry {
42+ if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.workspacePending(w, spawnTerminal) || now.Sub(w.termAt) < termRetry {
4343 continue
4444 }
4545 c.firstTerminalResolved = true
4646 w.termAt = now
47- reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{terminal: w, at: now}))
47+ reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{kind: spawnTerminal, ws: w, at: now}))
4848 }
4949 return reqs
5050 }
@@ -53,8 +53,8 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
5353 func (c *Core) cancelTerminal(req ports.SpawnRequest) {
5454 for _, env := range req.Env {
5555 if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok {
56- if target, ok := c.placement.pending[token]; ok && target.terminal != nil {
57- target.terminal.termAt = time.Time{}
56+ if target, ok := c.placement.pending[token]; ok && target.kind == spawnTerminal {
57+ target.ws.termAt = time.Time{}
5858 delete(c.placement.pending, token)
5959 }
6060 }
@@ -23,7 +23,7 @@ func (c *Core) keepsTerminal() bool {
23 // Workspaces with slots are left to them.23 // Workspaces with slots are left to them.
24 func (c *Core) fillEmpty() []ports.SpawnRequest {24 func (c *Core) fillEmpty() []ports.SpawnRequest {
25 now := c.now()25 now := c.now()
26- c.placement.expireTerminals(now)26+ c.placement.expireWorkspaceClaims(now)
27 if !c.keepsTerminal() {27 if !c.keepsTerminal() {
28 return nil28 return nil
29 }29 }
@@ -39,12 +39,12 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
39 continue39 continue
40 }40 }
41 }41 }
42- if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.terminalPending(w) || now.Sub(w.termAt) < termRetry {42+ if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.workspacePending(w, spawnTerminal) || now.Sub(w.termAt) < termRetry {
43 continue43 continue
44 }44 }
45 c.firstTerminalResolved = true45 c.firstTerminalResolved = true
46 w.termAt = now46 w.termAt = now
47- reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{terminal: w, at: now}))47+ reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{kind: spawnTerminal, ws: w, at: now}))
48 }48 }
49 return reqs49 return reqs
50 }50 }
@@ -53,8 +53,8 @@ func (c *Core) fillEmpty() []ports.SpawnRequest {
53 func (c *Core) cancelTerminal(req ports.SpawnRequest) {53 func (c *Core) cancelTerminal(req ports.SpawnRequest) {
54 for _, env := range req.Env {54 for _, env := range req.Env {
55 if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok {55 if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok {
56- if target, ok := c.placement.pending[token]; ok && target.terminal != nil {56+ if target, ok := c.placement.pending[token]; ok && target.kind == spawnTerminal {
57- target.terminal.termAt = time.Time{}57+ target.ws.termAt = time.Time{}
58 delete(c.placement.pending, token)58 delete(c.placement.pending, token)
59 }59 }
60 }60 }

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