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

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