v0.2.0 80c61e3

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

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