v0.7.0 4c16dd8

copymode.go RawHistoryFolder

1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5	"strconv"
6	"time"
7
8	renderer "github.com/bnema/vev-vt"
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/ports"
11	scopy "github.com/bnema/vev/internal/usecase/copy"
12	"github.com/bnema/vev/internal/usecase/keys"
13	"github.com/bnema/vev/internal/usecase/mouse"
14	themeui "github.com/bnema/vev/internal/usecase/theme"
15	"github.com/bnema/vev/internal/usecase/ui"
16	"github.com/bnema/vev/internal/usecase/visualsearch"
17)
18
19var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
20
21const copyDoubleClickInterval = 500 * time.Millisecond
22
23func sameCopyPos(a, b scopy.Pos) bool { return a.Row == b.Row && a.Col == b.Col }
24
25func (d *Daemon) isCopyDoubleClickLocked(rt *overlayRuntime, pane *pane, pos scopy.Pos, now time.Time) bool {
26	candidate := rt.copyClick
27	return candidate.valid && !candidate.dragged && candidate.pane == pane &&
28		sameCopyPos(candidate.pos, pos) && !now.Before(candidate.at) &&
29		now.Sub(candidate.at) <= copyDoubleClickInterval
30}
31
32func copyTargetPane(rt *overlayRuntime) *pane {
33	if rt == nil {
34		return nil
35	}
36	rt.copyMu.Lock()
37	defer rt.copyMu.Unlock()
38	if rt.copyMode == nil {
39		return nil
40	}
41	return rt.copyPane
42}
43
44func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
45	rt := ac.overlays
46	rt.copyMu.Lock()
47	changed, exit := rt.moveCopyWheelLocked(delta)
48	rt.copyMu.Unlock()
49	if !changed && !exit {
50		return
51	}
52	// Moving within the immutable copy document does not invalidate live pane
53	// captures. The compositor still redraws the overlay; leaving it resets live
54	// state so attachment-local snapshots cannot survive the return to the PTY.
55	d.invalidateRender(sess, ac, exit, "copymode.go")
56}
57
58func (rt *overlayRuntime) moveCopyWheelLocked(delta int) (changed, exit bool) {
59	if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
60		return false, false
61	}
62	changed = rt.copyMode.ScrollRows(delta)
63	exit = delta > 0 && rt.copyMode.AtBottom()
64	if exit {
65		rt.clearCopyModeLocked()
66	}
67	return changed, exit
68}
69
70func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
71	tb := sess.tabForAttachment(ac)
72	if tb == nil {
73		return
74	}
75	tb.mu.Lock()
76	p := tb.terminalTargetLocked()
77	tb.mu.Unlock()
78	if p == nil {
79		return
80	}
81	p.mu.Lock()
82	snapshot := scopy.NewSnapshotFromScreen(p.history, p.screen)
83	p.mu.Unlock()
84	cfg := d.currentCopyConfig()
85	document := scopy.NewDocument(snapshot, cfg.WordSeparators)
86	if !d.publishCopyMode(sess, ac, tb, p, document, nil, nil) {
87		return
88	}
89	d.invalidateRender(sess, ac, true, "copymode.go")
90}
91
92// publishCopyMode serializes overlay publication after pane membership is
93// revalidated. Document is the sole immutable copy payload.
94func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *pane, document *scopy.Document, prepare func(*scopy.Mode), activate func(*overlayRuntime, *scopy.Mode)) bool {
95	if sess == nil || ac == nil || tb == nil || p == nil || document == nil {
96		return false
97	}
98	mode := scopy.NewMode(document)
99	if prepare != nil {
100		prepare(mode)
101	}
102	rt := ac.overlays
103	rt.copyMu.Lock()
104	d.stopCopyPendingTimerLocked(ac)
105	rt.copyPending = nil
106	rt.copyMode = nil
107	rt.copyCandidate = mode
108	rt.copyDocument = document
109	rt.copyPane = p
110	rt.copySearch = nil
111	rt.copySearchPending = nil
112	if activate != nil {
113		rt.clearCopyPointerForTransferLocked()
114	} else {
115		rt.invalidateCopyPointerLocked(true)
116	}
117	pointerEpoch := rt.copyPointerEpoch
118	rt.copyMu.Unlock()
119	if d.beforeCopyModeRevalidate != nil {
120		d.beforeCopyModeRevalidate()
121	}
122	valid := sess.tabForAttachment(ac) == tb
123	if valid {
124		tb.mu.Lock()
125		valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)
126		tb.mu.Unlock()
127	}
128	rt.copyMu.Lock()
129	defer rt.copyMu.Unlock()
130	if rt.copyCandidate != mode || rt.copyPane != p || rt.copyDocument != document {
131		return false
132	}
133	if pointerEpoch != rt.copyPointerEpoch {
134		rt.discardCopyCandidateLocked(mode)
135		return false
136	}
137	if !valid {
138		rt.clearCopyModeLocked()
139		return false
140	}
141	rt.copyCandidate = nil
142	rt.copyMode = mode
143	if activate != nil {
144		activate(rt, mode)
145	}
146	return true
147}
148
149// copyMouse receives an already mapped client-frame position. It revalidates
150// the immutable snapshot under copyMu before changing selection state, so a
151// concurrent scroll or pointer lifecycle transition cannot apply stale rows.
152func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event, mapped mappedCopyMouse, snapshot copyMouseInputSnapshot, geometry copyMouseGeometry) {
153	rt := ac.overlays
154	if ev.Button != mouse.Left {
155		return
156	}
157	rt.copyMu.Lock()
158	if rt.copyMode != snapshot.mode || rt.copyPane != snapshot.pane || rt.copyDocument != snapshot.document ||
159		rt.copyPointerEpoch != snapshot.epoch || rt.copyMode == nil || rt.copyDocument != rt.copyMode.Document() ||
160		rt.copyMode.ViewportTop != snapshot.viewportTop {
161		rt.copyMu.Unlock()
162		return
163	}
164	changed := false
165	switch ev.Type {
166	case mouse.Press:
167		changed = rt.copyMode.SetPosition(mapped.pos)
168		pointer := rt.copyPointer
169		if !pointer.valid || pointer.pane != mapped.pane || pointer.document != rt.copyDocument {
170			rt.beginCopyPointerLocked(copyPointerState{pane: mapped.pane, document: rt.copyDocument, geometry: geometry, press: mapped.pos})
171			pointer = rt.copyPointer
172		}
173		now := d.clock.Now()
174		if d.isCopyDoubleClickLocked(rt, mapped.pane, mapped.pos, now) {
175			pointer.wordDrag = rt.copyMode.SelectWordAt(mapped.pos)
176			changed = pointer.wordDrag || changed
177			rt.copyClick = copyClickCandidate{}
178		} else {
179			rt.copyClick = copyClickCandidate{valid: true, pane: mapped.pane, pos: mapped.pos, at: now}
180		}
181		rt.copyPointer = pointer
182	case mouse.Motion:
183		pointer := rt.copyPointer
184		if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument {
185			if !pointer.dragging {
186				if !pointer.wordDrag {
187					rt.copyMode.StartCharacterSelection(pointer.press)
188				}
189				pointer.dragging = true
190			}
191			if rt.copyClick.valid && rt.copyClick.pane == pointer.pane {
192				rt.copyClick.dragged = true
193			}
194			if pointer.wordDrag {
195				changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed
196			} else {
197				changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed
198			}
199			rt.copyPointer = pointer
200		}
201	case mouse.Release:
202		pointer := rt.copyPointer
203		if pointer.valid && pointer.epoch == snapshot.epoch && pointer.pane == mapped.pane && pointer.document == rt.copyDocument && pointer.dragging {
204			if pointer.wordDrag {
205				changed = rt.copyMode.ExtendWordSelection(mapped.pos) || changed
206			} else {
207				changed = rt.copyMode.ExtendCharacterSelection(mapped.pos) || changed
208			}
209		}
210		rt.invalidateCopyPointerLocked(false)
211	}
212	rt.copyMu.Unlock()
213	if changed {
214		d.invalidateRender(sess, ac, true, "copymode.go")
215	}
216}
217
218func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
219	rt := ac.overlays
220	sess := ac.currentSession()
221	if sess == nil {
222		return
223	}
224	rt.copyMu.Lock()
225	if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil {
226		rt.copyPending = nil
227		d.stopCopyPendingTimerLocked(ac)
228		rt.copyMu.Unlock()
229		return
230	}
231	if len(rt.copyPending) > 0 {
232		d.stopCopyPendingTimerLocked(ac)
233		data = append(append([]byte(nil), rt.copyPending...), data...)
234		rt.copyPending = nil
235	}
236	if rt.copySearch != nil {
237		changed, close, accepted := d.routeCopySearchInputLocked(rt, data)
238		rt.copyMu.Unlock()
239		if changed || close || accepted {
240			d.invalidateRender(sess, ac, true, "copymode.go")
241		}
242		return
243	}
244	changed, copyOut, exit := false, false, false
245	for i := 0; i < len(data); i++ {
246		switch data[i] {
247		case 'h':
248			rt.copyMode.Left()
249			changed = true
250		case 'j':
251			rt.copyMode.Down()
252			changed = true
253		case 'k':
254			rt.copyMode.Up()
255			changed = true
256		case 'l':
257			rt.copyMode.Right()
258			changed = true
259		case 'w':
260			rt.copyMode.WordNext()
261			changed = true
262		case 'b':
263			rt.copyMode.WordBackward()
264			changed = true
265		case 'e':
266			rt.copyMode.WordEnd()
267			changed = true
268		case 'g':
269			rt.copyMode.Top()
270			changed = true
271		case 'G':
272			rt.copyMode.Bottom()
273			changed = true
274		case ' ', 'v':
275			rt.copyMode.ToggleLineSelection()
276			changed = true
277		case '/':
278			rt.copySearch = visualsearch.New(rt.copyMode.Document().Snapshot())
279			rt.copySearchPending = nil
280			changed = true
281			if i+1 < len(data) {
282				a, b, c := d.routeCopySearchInputLocked(rt, data[i+1:])
283				changed = changed || a || c
284				_ = b
285			}
286			i = len(data)
287		case 'n':
288			changed = rt.copyMode.NextSearchMatch(1) || changed
289		case 'N':
290			changed = rt.copyMode.NextSearchMatch(-1) || changed
291		case '\r', '\n', 'y':
292			copyOut = true
293			exit = true
294		case 'q', 0x03, 0x1b:
295			if data[i] == 0x1b {
296				tail := data[i:]
297				consumed, ok := routeCopyEscape(rt.copyMode, tail)
298				if ok {
299					i += consumed - 1
300					changed = true
301					continue
302				}
303				if len(tail) == 1 {
304					d.retainCopyESCLocked(ac)
305					break
306				}
307				if isCopyEscapePrefix(tail) {
308					rt.copyPending = append(rt.copyPending[:0], tail...)
309					break
310				}
311			}
312			exit = true
313		}
314	}
315	text := ""
316	if copyOut {
317		text = rt.copyMode.SelectedText()
318	}
319	if exit {
320		d.stopCopyPendingTimerLocked(ac)
321		rt.clearCopyModeLocked()
322	}
323	rt.copyMu.Unlock()
324	if copyOut && text != "" {
325		chunks := scopy.OSC52(text)
326		for _, chunk := range chunks {
327			failed, err := d.boundedSendOutputErrTransport(ac, chunk)
328			if err != nil {
329				d.detachOnSendError(sess, ac, failed)
330				return
331			}
332		}
333		rt.copyMu.Lock()
334		if len(chunks) > 0 {
335			rt.statusFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
336		} else {
337			rt.statusFeedback = "selection too large to copy"
338		}
339		rt.copyMu.Unlock()
340	}
341	if exit || changed {
342		d.invalidateRender(sess, ac, true, "copymode.go")
343	}
344}
345func (d *Daemon) routeCopySearchInputLocked(rt *overlayRuntime, data []byte) (changed, closeSearch, accepted bool) {
346	routeOverlayBytes(data, &rt.copySearchPending, overlayEvents{rune: func(r rune) { rt.copySearch.Insert(r); changed = true }, backspace: func() { rt.copySearch.Backspace(); changed = true }, enter: func() {
347		if _, ok := rt.copySearch.Selected(); ok {
348			accepted = rt.copyMode.SetSearchMatches(rt.copySearch.Query(), rt.copySearch.Matches(), rt.copySearch.SelectedIndex())
349			closeSearch = accepted
350		}
351	}, cancel: func() { closeSearch = true }, up: func() { rt.copySearch.Up(); changed = true }, down: func() { rt.copySearch.Down(); changed = true }})
352	if changed && !closeSearch {
353		d.previewCopySearchSelectionLocked(rt)
354	}
355	if closeSearch {
356		rt.copySearch = nil
357		rt.copySearchPending = nil
358	}
359	return
360}
361func (d *Daemon) previewCopySearchSelectionLocked(rt *overlayRuntime) {
362	if rt == nil || rt.copyMode == nil || rt.copySearch == nil {
363		return
364	}
365	if match, ok := rt.copySearch.Selected(); ok {
366		rt.copyMode.SetPosition(scopy.Pos{Row: match.Row, Col: match.Start})
367	}
368}
369func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
370	rt := ac.overlays
371	mode := rt.copyMode
372	rt.copyPending = append(rt.copyPending[:0], keys.ESC)
373	rt.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
374		rt.copyMu.Lock()
375		if rt.copyESC.timer != timer {
376			rt.copyMu.Unlock()
377			return
378		}
379		rt.copyPending = nil
380		rt.copyESC.timer = nil
381		rt.copyESC.done = nil
382		if rt.copyMode != mode || rt.copyMode == nil {
383			rt.copyMu.Unlock()
384			return
385		}
386		rt.clearCopyModeLocked()
387		rt.copyMu.Unlock()
388		if sess := ac.currentSession(); sess != nil {
389			d.invalidateRender(sess, ac, true, "copymode.go")
390		}
391	})
392}
393func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) { ac.overlays.copyESC.stop() }
394func routeCopyEscape(m *scopy.Mode, data []byte) (int, bool) {
395	if len(data) >= 3 && (data[1] == '[' || data[1] == 'O') {
396		switch data[2] {
397		case 'A':
398			m.Up()
399			return 3, true
400		case 'B':
401			m.Down()
402			return 3, true
403		}
404	}
405	if len(data) >= 4 && data[1] == '[' && data[3] == '~' {
406		switch data[2] {
407		case '5':
408			m.Page(-1)
409			return 4, true
410		case '6':
411			m.Page(1)
412			return 4, true
413		}
414	}
415	return 0, false
416}
417func isCopyEscapePrefix(data []byte) bool {
418	return len(data) == 2 && data[0] == 0x1b && (data[1] == '[' || data[1] == 'O') || len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
419}
420func composeCopyClientFrame(mode *scopy.Mode, target domain.Rect, frame renderer.Frame, styles themeui.Styles) (renderer.Frame, []renderer.Damage) {
421	if mode == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
422		return frame, nil
423	}
424	statusY := frame.Height - 1
425	frame.FillRow(statusY, 0, frame.Width, renderer.Cell{Rune: ' ', Style: styles.SurfaceBar})
426	mode.RenderRows(func(y int, row []renderer.Cell) {
427		if y == mode.Document().Height() {
428			frame.WriteRow(statusY, 0, row[:min(frame.Width, len(row))])
429			return
430		}
431		if y >= target.Height || target.Y+y < 0 || target.Y+y >= statusY {
432			return
433		}
434		dstX := max(target.X, 0)
435		srcX := max(-target.X, 0)
436		width := min(target.Width-srcX, len(row)-srcX, frame.Width-dstX)
437		if width > 0 {
438			frame.WriteRow(target.Y+y, dstX, row[srcX:srcX+width])
439		}
440	}, styles.CopyStatus, styles.Selection)
441	return frame, []renderer.Damage{renderer.FullRedraw()}
442}

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