1package daemon
2
3import (
4 "errors"
5
6 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"
9)
10
11func (d *Daemon) handleRemotePreview(tr ports.Transport, frame ports.Frame) error {
12 defer func() { _ = tr.Close() }()
13 request, err := ports.UnmarshalRemotePreviewRequest(frame.Payload)
14 if err != nil {
15 return d.sendRemotePreview(tr, ports.RemotePreview{Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewMalformed})
16 }
17 preview, err := d.captureRemotePreview(request)
18 if err != nil {
19 status := ports.RemotePreviewUnavailable
20 if errors.Is(err, errRemotePreviewNoSuchTarget) {
21 status = ports.RemotePreviewNoSuchTarget
22 }
23 preview = ports.RemotePreview{Version: ports.RemotePreviewSchemaVersion, Status: status}
24 }
25 return d.sendRemotePreview(tr, preview)
26}
27
28func (d *Daemon) sendRemotePreview(tr ports.Transport, preview ports.RemotePreview) error {
29 payload := ports.MarshalRemotePreview(preview)
30 if payload == nil {
31 payload = ports.MarshalRemotePreview(ports.RemotePreview{Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewMalformed})
32 }
33 return d.boundedControlSend(tr, ports.Frame{Type: ports.MsgRemotePreviewResponse, Payload: payload})
34}
35
36var errRemotePreviewNoSuchTarget = errors.New("daemon: remote preview target does not exist")
37
38func (d *Daemon) captureRemotePreview(request ports.RemotePreviewRequest) (ports.RemotePreview, error) {
39 target := request.Target
40 d.mu.Lock()
41 sess := d.findByNameLocked(target.SessionName)
42 if sess == nil || sess.incarnation != target.LifecycleID {
43 d.mu.Unlock()
44 return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
45 }
46 index, ok := remoteTargetTabIndexLocked(sess, target)
47 d.mu.Unlock()
48 if !ok {
49 return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
50 }
51
52 sess.mu.Lock()
53 if index < 0 || index >= len(sess.tabs) {
54 sess.mu.Unlock()
55 return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
56 }
57 tab := sess.tabs[index]
58 if tab == nil || domain.TabStableID(tab.stableID) != target.LiveTabID {
59 sess.mu.Unlock()
60 return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
61 }
62 sess.mu.Unlock()
63 tab.mu.Lock()
64 pane := tab.focusedPane()
65 if pane == nil {
66 tab.mu.Unlock()
67 return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
68 }
69 pane.mu.Lock()
70 if pane.screen == nil {
71 pane.mu.Unlock()
72 tab.mu.Unlock()
73 return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
74 }
75 source := pane.screen.Frame.Clone()
76 revision := pane.syncGen
77 pane.mu.Unlock()
78 tab.mu.Unlock()
79 if revision == 0 {
80 revision = 1
81 }
82
83 width := min(int(request.Width), source.Width)
84 height := min(int(request.Height), source.Height)
85 if width <= 0 || height <= 0 {
86 return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
87 }
88 startY, endY := source.Height-height, source.Height
89 if last := remotePreviewLastContentRow(source, width, startY, endY); last >= startY {
90 endY = last + 1
91 } else if last := remotePreviewLastContentRow(source, width, 0, startY); last >= 0 {
92 startY = max(0, last-height+1)
93 endY = last + 1
94 }
95 height = endY - startY
96 cells := make([]renderer.Cell, 0, width*height)
97 for y := 0; y < height; y++ {
98 row := source.Row(startY + y)
99 for x := 0; x < width; x++ {
100 cell, _ := cropRemotePreviewCell(row[x], x, width)
101 cells = append(cells, cell)
102 }
103 }
104 preview := ports.RemotePreview{
105 Version: ports.RemotePreviewSchemaVersion,
106 Status: ports.RemotePreviewOK,
107 LifecycleID: target.LifecycleID,
108 TabID: target.LiveTabID,
109 Revision: revision,
110 Width: uint16(width),
111 Height: uint16(height),
112 Cells: cells,
113 }
114 if err := ports.ValidateRemotePreview(preview); err != nil {
115 // A crop must not split a wide cell. Return a safe unavailable response
116 // instead of exposing a malformed cell stream.
117 return ports.RemotePreview{}, err
118 }
119 return preview, nil
120}
121
122// remotePreviewLastContentRow finds the final row with a visible glyph inside
123// the bounded viewport crop. Remote terminal frames retain their full PTY
124// height even when the only output is near the top; compacting the blank tail lets the
125// picker bottom-anchor short output instead of clipping it away.
126func remotePreviewLastContentRow(frame renderer.Frame, width, start, end int) int {
127 for y := end - 1; y >= start; y-- {
128 for x, cell := range frame.Row(y)[:width] {
129 cell, splitWideRune := cropRemotePreviewCell(cell, x, width)
130 if !splitWideRune && remotePreviewCellVisible(cell) {
131 return y
132 }
133 }
134 }
135 return -1
136}
137
138// cropRemotePreviewCell converts a left wide-rune half at the right crop edge
139// to the styled blank that the picker can safely render. The flag distinguishes
140// that replacement from an actual styled blank in the source frame.
141func cropRemotePreviewCell(cell renderer.Cell, x, width int) (renderer.Cell, bool) {
142 if x == width-1 && !cell.Continuation && renderer.RuneWidth(cell.Rune) == 2 {
143 return renderer.Cell{Rune: ' ', Style: cell.Style}, true
144 }
145 return cell, false
146}
147
148func remotePreviewCellVisible(cell renderer.Cell) bool {
149 return !cell.Continuation &&
150 (cell.Rune != 0 && cell.Rune != ' ' || !cell.Style.Equal(renderer.DefaultStyle()))
151}