detached 6f41b4b

fix(picker): harden switch and preview cleanup

commit
6f41b4bc6983b973e0e847c71226ed90f3c743fc
author
brice · · 2026-07-03 04:24 UTC
parent
d8255fc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +87 -51
M internal/usecase/daemon/daemon.go +75 -21
@@ -754,16 +754,16 @@ func (s *session) activeTab() *tab {
754754 return s.tabs[s.active]
755755 }
756756
757-func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
757+func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
758758 ac.mouseScan.Scan(data,
759- func(ev mouse.Event) { d.handleMouse(sess, ac, ev) },
759+ func(ev mouse.Event) { d.handleMouse(ac, ev) },
760760 func(b []byte) {
761761 if ac.pickerActive() {
762- d.handlePickerInput(sess, ac, b)
762+ d.handlePickerInput(ac, b)
763763 return
764764 }
765765 if ac.copyModeActive() {
766- d.handleCopyInput(sess, ac, b)
766+ d.handleCopyInput(ac, b)
767767 return
768768 }
769769 ac.keys.Route(b)
@@ -771,7 +771,14 @@ func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
771771 )
772772 }
773773
774-func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) {
774+func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
775+ if ac.pickerActive() {
776+ return
777+ }
778+ sess := ac.currentSession()
779+ if sess == nil {
780+ return
781+ }
775782 tb := sess.activeTab()
776783 if tb == nil {
777784 return
@@ -862,7 +869,11 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
862869 d.paint(sess, ac, true)
863870 }
864871
865-func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) {
872+func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
873+ sess := ac.currentSession()
874+ if sess == nil {
875+ return
876+ }
866877 tb := sess.activeTab()
867878 if tb == nil {
868879 return
@@ -1017,7 +1028,11 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
10171028 return views, curTab
10181029 }
10191030
1020-func (d *Daemon) handlePickerInput(sess *session, ac *attachedClient, data []byte) {
1031+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
1032+ sess := ac.currentSession()
1033+ if sess == nil {
1034+ return
1035+ }
10211036 ac.pickerMu.Lock()
10221037 if ac.picker == nil {
10231038 ac.pickerPending = nil
@@ -1164,6 +1179,25 @@ func (d *Daemon) unregisterPreview(ac *attachedClient) {
11641179 }
11651180 }
11661181
1182+func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
1183+ tb.mu.Lock()
1184+ previewer := tb.previewClient
1185+ tb.mu.Unlock()
1186+ if previewer == nil {
1187+ return
1188+ }
1189+ previewer.pickerMu.Lock()
1190+ if previewer.pickerPreview == tb {
1191+ previewer.pickerPreview = nil
1192+ }
1193+ previewer.pickerMu.Unlock()
1194+ tb.mu.Lock()
1195+ if tb.previewClient == previewer {
1196+ tb.previewClient = nil
1197+ }
1198+ tb.mu.Unlock()
1199+}
1200+
11671201 func (d *Daemon) closePicker(ac *attachedClient) {
11681202 ac.pickerMu.Lock()
11691203 ac.picker = nil
@@ -1204,27 +1238,45 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
12041238 d.paint(sess, ac, true)
12051239 return
12061240 }
1207- if !sess.detachIfCurrent(ac) {
1241+ old := d.stealClientForTarget(sess, ac, targetSess, target)
1242+ if ac.currentSession() != targetSess {
1243+ d.paint(sess, ac, true)
12081244 return
12091245 }
1210- targetSess.stealClient(d, ac, target.TabIndex)
1211- ac.setSession(targetSess)
1212- d.firstPaint(targetSess, ac, ac.size)
1213-}
1214-
1215-func (s *session) stealClient(d *Daemon, ac *attachedClient, idx int) {
1216- s.mu.Lock()
1217- old := s.client
1218- s.client = ac
1219- if idx >= 0 && idx < len(s.tabs) {
1220- s.active = idx
1221- }
1222- s.mu.Unlock()
12231246 if old != nil && old != ac {
12241247 d.unregisterPreview(old)
12251248 old.setSession(nil)
12261249 d.notifyDetachedAsync(old, ports.ReasonDetach)
12271250 }
1251+ d.firstPaint(targetSess, ac, ac.size)
1252+}
1253+
1254+func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
1255+ d.mu.Lock()
1256+ if d.sessions[target.Session] != targetSess {
1257+ d.mu.Unlock()
1258+ return nil
1259+ }
1260+ from.mu.Lock()
1261+ if from.client != ac {
1262+ from.mu.Unlock()
1263+ d.mu.Unlock()
1264+ return nil
1265+ }
1266+ from.client = nil
1267+ ac.setSession(nil)
1268+ from.mu.Unlock()
1269+
1270+ targetSess.mu.Lock()
1271+ old := targetSess.client
1272+ targetSess.client = ac
1273+ if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
1274+ targetSess.active = target.TabIndex
1275+ }
1276+ targetSess.mu.Unlock()
1277+ ac.setSession(targetSess)
1278+ d.mu.Unlock()
1279+ return old
12281280 }
12291281
12301282 type daemonKeyHandler struct {
@@ -1345,6 +1397,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
13451397 ac := sess.client
13461398 sess.mu.Unlock()
13471399
1400+ d.clearDestroyedTabPreview(tb)
13481401 if tb.cancel != nil {
13491402 tb.cancel()
13501403 }
@@ -1833,6 +1886,7 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
18331886 tabs := append([]*tab(nil), sess.tabs...)
18341887 sess.mu.Unlock()
18351888 for _, tb := range tabs {
1889+ d.clearDestroyedTabPreview(tb)
18361890 _ = tb.pty.Close()
18371891 }
18381892 if empty {
@@ -754,16 +754,16 @@ func (s *session) activeTab() *tab {
754 return s.tabs[s.active]754 return s.tabs[s.active]
755 }755 }
756 756
757-func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {757+func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
758 ac.mouseScan.Scan(data,758 ac.mouseScan.Scan(data,
759- func(ev mouse.Event) { d.handleMouse(sess, ac, ev) },759+ func(ev mouse.Event) { d.handleMouse(ac, ev) },
760 func(b []byte) {760 func(b []byte) {
761 if ac.pickerActive() {761 if ac.pickerActive() {
762- d.handlePickerInput(sess, ac, b)762+ d.handlePickerInput(ac, b)
763 return763 return
764 }764 }
765 if ac.copyModeActive() {765 if ac.copyModeActive() {
766- d.handleCopyInput(sess, ac, b)766+ d.handleCopyInput(ac, b)
767 return767 return
768 }768 }
769 ac.keys.Route(b)769 ac.keys.Route(b)
@@ -771,7 +771,14 @@ func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
771 )771 )
772 }772 }
773 773
774-func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) {774+func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
775+ if ac.pickerActive() {
776+ return
777+ }
778+ sess := ac.currentSession()
779+ if sess == nil {
780+ return
781+ }
775 tb := sess.activeTab()782 tb := sess.activeTab()
776 if tb == nil {783 if tb == nil {
777 return784 return
@@ -862,7 +869,11 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
862 d.paint(sess, ac, true)869 d.paint(sess, ac, true)
863 }870 }
864 871
865-func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) {872+func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
873+ sess := ac.currentSession()
874+ if sess == nil {
875+ return
876+ }
866 tb := sess.activeTab()877 tb := sess.activeTab()
867 if tb == nil {878 if tb == nil {
868 return879 return
@@ -1017,7 +1028,11 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
1017 return views, curTab1028 return views, curTab
1018 }1029 }
1019 1030
1020-func (d *Daemon) handlePickerInput(sess *session, ac *attachedClient, data []byte) {1031+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
1032+ sess := ac.currentSession()
1033+ if sess == nil {
1034+ return
1035+ }
1021 ac.pickerMu.Lock()1036 ac.pickerMu.Lock()
1022 if ac.picker == nil {1037 if ac.picker == nil {
1023 ac.pickerPending = nil1038 ac.pickerPending = nil
@@ -1164,6 +1179,25 @@ func (d *Daemon) unregisterPreview(ac *attachedClient) {
1164 }1179 }
1165 }1180 }
1166 1181
1182+func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
1183+ tb.mu.Lock()
1184+ previewer := tb.previewClient
1185+ tb.mu.Unlock()
1186+ if previewer == nil {
1187+ return
1188+ }
1189+ previewer.pickerMu.Lock()
1190+ if previewer.pickerPreview == tb {
1191+ previewer.pickerPreview = nil
1192+ }
1193+ previewer.pickerMu.Unlock()
1194+ tb.mu.Lock()
1195+ if tb.previewClient == previewer {
1196+ tb.previewClient = nil
1197+ }
1198+ tb.mu.Unlock()
1199+}
1200+
1167 func (d *Daemon) closePicker(ac *attachedClient) {1201 func (d *Daemon) closePicker(ac *attachedClient) {
1168 ac.pickerMu.Lock()1202 ac.pickerMu.Lock()
1169 ac.picker = nil1203 ac.picker = nil
@@ -1204,27 +1238,45 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
1204 d.paint(sess, ac, true)1238 d.paint(sess, ac, true)
1205 return1239 return
1206 }1240 }
1207- if !sess.detachIfCurrent(ac) {1241+ old := d.stealClientForTarget(sess, ac, targetSess, target)
1242+ if ac.currentSession() != targetSess {
1243+ d.paint(sess, ac, true)
1208 return1244 return
1209 }1245 }
1210- targetSess.stealClient(d, ac, target.TabIndex)
1211- ac.setSession(targetSess)
1212- d.firstPaint(targetSess, ac, ac.size)
1213-}
1214-
1215-func (s *session) stealClient(d *Daemon, ac *attachedClient, idx int) {
1216- s.mu.Lock()
1217- old := s.client
1218- s.client = ac
1219- if idx >= 0 && idx < len(s.tabs) {
1220- s.active = idx
1221- }
1222- s.mu.Unlock()
1223 if old != nil && old != ac {1246 if old != nil && old != ac {
1224 d.unregisterPreview(old)1247 d.unregisterPreview(old)
1225 old.setSession(nil)1248 old.setSession(nil)
1226 d.notifyDetachedAsync(old, ports.ReasonDetach)1249 d.notifyDetachedAsync(old, ports.ReasonDetach)
1227 }1250 }
1251+ d.firstPaint(targetSess, ac, ac.size)
1252+}
1253+
1254+func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
1255+ d.mu.Lock()
1256+ if d.sessions[target.Session] != targetSess {
1257+ d.mu.Unlock()
1258+ return nil
1259+ }
1260+ from.mu.Lock()
1261+ if from.client != ac {
1262+ from.mu.Unlock()
1263+ d.mu.Unlock()
1264+ return nil
1265+ }
1266+ from.client = nil
1267+ ac.setSession(nil)
1268+ from.mu.Unlock()
1269+
1270+ targetSess.mu.Lock()
1271+ old := targetSess.client
1272+ targetSess.client = ac
1273+ if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
1274+ targetSess.active = target.TabIndex
1275+ }
1276+ targetSess.mu.Unlock()
1277+ ac.setSession(targetSess)
1278+ d.mu.Unlock()
1279+ return old
1228 }1280 }
1229 1281
1230 type daemonKeyHandler struct {1282 type daemonKeyHandler struct {
@@ -1345,6 +1397,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
1345 ac := sess.client1397 ac := sess.client
1346 sess.mu.Unlock()1398 sess.mu.Unlock()
1347 1399
1400+ d.clearDestroyedTabPreview(tb)
1348 if tb.cancel != nil {1401 if tb.cancel != nil {
1349 tb.cancel()1402 tb.cancel()
1350 }1403 }
@@ -1833,6 +1886,7 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
1833 tabs := append([]*tab(nil), sess.tabs...)1886 tabs := append([]*tab(nil), sess.tabs...)
1834 sess.mu.Unlock()1887 sess.mu.Unlock()
1835 for _, tb := range tabs {1888 for _, tb := range tabs {
1889+ d.clearDestroyedTabPreview(tb)
1836 _ = tb.pty.Close()1890 _ = tb.pty.Close()
1837 }1891 }
1838 if empty {1892 if empty {
M internal/usecase/daemon/daemon_test.go +2 -2
@@ -1136,8 +1136,8 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
11361136 d.enterPicker(sess, ac)
11371137 })
11381138 wg.Go(func() {
1139- d.handlePickerInput(sess, ac, []byte("j"))
1140- d.handlePickerInput(sess, ac, []byte("k"))
1139+ d.handlePickerInput(ac, []byte("j"))
1140+ d.handlePickerInput(ac, []byte("k"))
11411141 })
11421142 wg.Go(func() {
11431143 d.closePicker(ac)
@@ -1136,8 +1136,8 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
1136 d.enterPicker(sess, ac)1136 d.enterPicker(sess, ac)
1137 })1137 })
1138 wg.Go(func() {1138 wg.Go(func() {
1139- d.handlePickerInput(sess, ac, []byte("j"))1139+ d.handlePickerInput(ac, []byte("j"))
1140- d.handlePickerInput(sess, ac, []byte("k"))1140+ d.handlePickerInput(ac, []byte("k"))
1141 })1141 })
1142 wg.Go(func() {1142 wg.Go(func() {
1143 d.closePicker(ac)1143 d.closePicker(ac)
M internal/usecase/picker/model.go +3 -23
@@ -2,6 +2,7 @@ package picker
22
33 import (
44 "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/ui"
56 "github.com/bnema/vev/pkg/renderer"
67 )
78
@@ -177,7 +178,8 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) {
177178 if !r.header {
178179 label = " " + label
179180 }
180- drawText(frame, rect.X, rect.Y+y, rect.Width, label, style)
181+ ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
182+ ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style)
181183 }
182184 }
183185
@@ -192,28 +194,6 @@ func (m *Model) scrollOffset(visible int) int {
192194 return min(offset, len(m.rows)-visible)
193195 }
194196
195-func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) {
196- if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 {
197- return
198- }
199- limit := min(width, frame.Width-x)
200- if limit <= 0 {
201- return
202- }
203- row := frame.Row(y)
204- for i := range limit {
205- row[x+i] = renderer.Cell{Rune: ' ', Style: style}
206- }
207- col := 0
208- for _, r := range text {
209- if col >= limit {
210- break
211- }
212- row[x+col] = renderer.Cell{Rune: r, Style: style}
213- col++
214- }
215-}
216-
217197 func clamp(n, low, high int) int {
218198 if n < low {
219199 return low
@@ -2,6 +2,7 @@ package picker
2 2
3 import (3 import (
4 "github.com/bnema/vev/internal/domain"4 "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/ui"
5 "github.com/bnema/vev/pkg/renderer"6 "github.com/bnema/vev/pkg/renderer"
6 )7 )
7 8
@@ -177,7 +178,8 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) {
177 if !r.header {178 if !r.header {
178 label = " " + label179 label = " " + label
179 }180 }
180- drawText(frame, rect.X, rect.Y+y, rect.Width, label, style)181+ ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
182+ ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style)
181 }183 }
182 }184 }
183 185
@@ -192,28 +194,6 @@ func (m *Model) scrollOffset(visible int) int {
192 return min(offset, len(m.rows)-visible)194 return min(offset, len(m.rows)-visible)
193 }195 }
194 196
195-func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) {
196- if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 {
197- return
198- }
199- limit := min(width, frame.Width-x)
200- if limit <= 0 {
201- return
202- }
203- row := frame.Row(y)
204- for i := range limit {
205- row[x+i] = renderer.Cell{Rune: ' ', Style: style}
206- }
207- col := 0
208- for _, r := range text {
209- if col >= limit {
210- break
211- }
212- row[x+col] = renderer.Cell{Rune: r, Style: style}
213- col++
214- }
215-}
216-
217 func clamp(n, low, high int) int {197 func clamp(n, low, high int) int {
218 if n < low {198 if n < low {
219 return low199 return low
M internal/usecase/ui/modal.go +7 -5
@@ -54,16 +54,18 @@ func (m Modal) Inner(base domain.Size) domain.Rect {
5454 // the inner content rectangle. Cells outside the modal bounds are not changed.
5555 func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect {
5656 bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
57- inner := m.Inner(domain.Size{Cols: f.Width, Rows: f.Height})
57+ inner := domain.Rect{
58+ X: bounds.X + 1,
59+ Y: bounds.Y + 1,
60+ Width: max(0, bounds.Width-2),
61+ Height: max(0, bounds.Height-2),
62+ }
5863 DrawBox(f, bounds, border)
5964 FillRect(f, inner, renderer.BlankCell())
6065 if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" {
6166 left := bounds.X + 1
6267 right := bounds.X + bounds.Width - 1
63- start := bounds.X + (bounds.Width-textWidth(m.Title))/2
64- if start < left {
65- start = left
66- }
68+ start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2)
6769 DrawText(f, start, bounds.Y, right, m.Title, border)
6870 }
6971 return inner
@@ -54,16 +54,18 @@ func (m Modal) Inner(base domain.Size) domain.Rect {
54 // the inner content rectangle. Cells outside the modal bounds are not changed.54 // the inner content rectangle. Cells outside the modal bounds are not changed.
55 func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect {55 func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect {
56 bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})56 bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
57- inner := m.Inner(domain.Size{Cols: f.Width, Rows: f.Height})57+ inner := domain.Rect{
58+ X: bounds.X + 1,
59+ Y: bounds.Y + 1,
60+ Width: max(0, bounds.Width-2),
61+ Height: max(0, bounds.Height-2),
62+ }
58 DrawBox(f, bounds, border)63 DrawBox(f, bounds, border)
59 FillRect(f, inner, renderer.BlankCell())64 FillRect(f, inner, renderer.BlankCell())
60 if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" {65 if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" {
61 left := bounds.X + 166 left := bounds.X + 1
62 right := bounds.X + bounds.Width - 167 right := bounds.X + bounds.Width - 1
63- start := bounds.X + (bounds.Width-textWidth(m.Title))/268+ start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2)
64- if start < left {
65- start = left
66- }
67 DrawText(f, start, bounds.Y, right, m.Title, border)69 DrawText(f, start, bounds.Y, right, m.Title, border)
68 }70 }
69 return inner71 return inner

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 12

Tags 11

↑↓ moveenter openesc close