1package palette
2
3import (
4 "time"
5 "unicode/utf8"
6
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/protocol"
9 "github.com/bnema/vev/internal/usecase/command"
10)
11
12// ResultKind identifies the immutable target represented by a palette row.
13type ResultKind uint8
14
15const (
16 ResultKindCommand ResultKind = iota
17 ResultKindActiveSession
18 ResultKindStoppedSession
19 ResultKindRemoteSession
20 ResultKindRecentRoute
21 ResultKindCreateSessionDestination
22 ResultKindImportedSession
23)
24
25// CreateSessionDestinationKind identifies where a CNS result creates a session.
26type CreateSessionDestinationKind uint8
27
28const (
29 CreateSessionOnServingDaemon CreateSessionDestinationKind = iota + 1
30 CreateSessionOnLocalRoute
31 CreateSessionOnRemoteHost
32)
33
34// Result is an immutable palette target. Its kind is the sole discriminator
35// for its private command, local-session, remote-session, imported-session,
36// or route payload.
37type Result struct {
38 kind ResultKind
39 command command.Command
40 session sessionPayload
41 remoteSession remoteSessionPayload
42 importedSession importedSessionPayload
43 route routePayload
44 createDestination createSessionDestinationPayload
45}
46
47type sessionPayload struct {
48 display string
49 createdAt time.Time
50 target protocol.ExactSessionTarget
51}
52
53type remoteSessionPayload struct {
54 key domain.RemoteSessionKey
55 target domain.RemoteSessionTarget
56 unavailableReason string
57}
58
59// importedSessionPayload is one sanitized navigation-inventory row relayed
60// from the client's local control source. It carries display facts and the
61// opaque selection keys only: no endpoint, connection parameters, CWD,
62// environment, preview, or pane content. Resolution happens source-side
63// through the keys.
64type importedSessionPayload struct {
65 sourceKey string
66 entryKey string
67 name string
68 displayOrigin string
69 state string
70 reason string
71}
72
73type routePayload struct {
74 name string
75 label string
76 action protocol.RouteNavigationAction
77}
78
79type createSessionDestinationPayload struct {
80 name string
81 kind CreateSessionDestinationKind
82 displayOrigin string
83 endpoint string
84 route protocol.RouteRef
85 snapshotGen uint64
86}
87
88// NewCommandResult creates a static command palette target.
89func NewCommandResult(cmd command.Command) Result {
90 return Result{kind: ResultKindCommand, command: cmd}
91}
92
93// NewActiveSessionResult creates an immutable active named-session target.
94func NewActiveSessionResult(target protocol.ExactSessionTarget, createdAt time.Time) Result {
95 return NewActiveSessionResultWithDisplayOrigin(target, createdAt, "")
96}
97
98// NewActiveSessionResultWithDisplayOrigin creates an active named-session target
99// qualified for presentation through a remote attachment origin.
100func NewActiveSessionResultWithDisplayOrigin(target protocol.ExactSessionTarget, createdAt time.Time, displayOrigin string) Result {
101 return Result{
102 kind: ResultKindActiveSession,
103 session: newSessionPayload(target, createdAt, displayOrigin),
104 }
105}
106
107// NewStoppedSessionResult creates an immutable stopped named-session target.
108func NewStoppedSessionResult(target protocol.ExactSessionTarget, createdAt time.Time) Result {
109 return NewStoppedSessionResultWithDisplayOrigin(target, createdAt, "")
110}
111
112// NewStoppedSessionResultWithDisplayOrigin creates a stopped named-session target
113// qualified for presentation through a remote attachment origin.
114func NewStoppedSessionResultWithDisplayOrigin(target protocol.ExactSessionTarget, createdAt time.Time, displayOrigin string) Result {
115 return Result{
116 kind: ResultKindStoppedSession,
117 session: newSessionPayload(target, createdAt, displayOrigin),
118 }
119}
120
121// SessionDisplay qualifies palette destinations relative to the client.
122func SessionDisplay(name, origin string) string {
123 if origin == "" {
124 return name
125 }
126 return name + "@" + domain.RemoteDisplayOrigin(origin)
127}
128
129func remoteSessionDisplay(key domain.RemoteSessionKey) string {
130 origin := key.DisplayOrigin
131 if origin == "" {
132 origin = domain.RemoteDisplayOrigin(key.Host)
133 }
134 return SessionDisplay(key.Name, origin)
135}
136
137func newSessionPayload(target protocol.ExactSessionTarget, createdAt time.Time, displayOrigin string) sessionPayload {
138 return sessionPayload{display: SessionDisplay(target.SessionName, displayOrigin), createdAt: createdAt, target: target}
139}
140
141// NewImportedSessionResult creates an immutable inventory-relayed row.
142// Labels are never parsed back into selection authority: Enter resolves
143// through the opaque source/entry keys.
144func NewImportedSessionResult(sourceKey, entryKey, name, displayOrigin, state, reason string) Result {
145 return Result{
146 kind: ResultKindImportedSession,
147 importedSession: importedSessionPayload{
148 sourceKey: sourceKey, entryKey: entryKey, name: name,
149 displayOrigin: displayOrigin, state: state, reason: reason,
150 },
151 }
152}
153
154// NewRecentRouteResult creates an immutable client-ledger route target.
155func NewRecentRouteResult(name, label string, action protocol.RouteNavigationAction) Result {
156 return Result{kind: ResultKindRecentRoute, route: routePayload{name: name, label: label, action: action}}
157}
158
159// NewCreateSessionDestination creates an immutable destination template. The
160// palette binds the current validated CNS name without parsing rendered text.
161func NewCreateSessionDestination(kind CreateSessionDestinationKind, displayOrigin, endpoint string, route protocol.RouteRef, snapshotGeneration uint64) Result {
162 return Result{kind: ResultKindCreateSessionDestination, createDestination: createSessionDestinationPayload{
163 kind: kind, displayOrigin: displayOrigin, endpoint: endpoint, route: route, snapshotGen: snapshotGeneration,
164 }}
165}
166
167func (r Result) withCreateSessionName(name string) Result {
168 r.createDestination.name = name
169 return r
170}
171
172func (r Result) Kind() ResultKind { return r.kind }
173
174func (r Result) sameTarget(other Result) bool {
175 if r.kind != other.kind {
176 return false
177 }
178 switch r.kind {
179 case ResultKindCommand:
180 return r.command.Slug == other.command.Slug
181 case ResultKindActiveSession, ResultKindStoppedSession:
182 return r.session.target == other.session.target
183 case ResultKindRemoteSession:
184 return r.remoteSession.key == other.remoteSession.key && r.remoteSession.target == other.remoteSession.target
185 case ResultKindImportedSession:
186 return r.importedSession.sourceKey == other.importedSession.sourceKey && r.importedSession.entryKey == other.importedSession.entryKey
187 case ResultKindRecentRoute:
188 return r.route.action == other.route.action
189 case ResultKindCreateSessionDestination:
190 return r.createDestination.kind == other.createDestination.kind &&
191 r.createDestination.endpoint == other.createDestination.endpoint &&
192 r.createDestination.route == other.createDestination.route &&
193 r.createDestination.snapshotGen == other.createDestination.snapshotGen
194 default:
195 return false
196 }
197}
198
199func (r Result) DisplayText() string {
200 if r.kind == ResultKindCommand {
201 return r.command.Code
202 }
203 if r.kind == ResultKindRemoteSession {
204 return activeSessionDisplayPrefix + remoteSessionDisplay(r.remoteSession.key)
205 }
206 if r.kind == ResultKindImportedSession {
207 return activeSessionDisplayPrefix + r.importedSession.display()
208 }
209 if r.kind == ResultKindRecentRoute {
210 return activeSessionDisplayPrefix + r.route.label
211 }
212 if r.kind == ResultKindCreateSessionDestination {
213 return r.createSessionDestinationDisplay()
214 }
215 return r.sessionDisplayPrefix() + r.session.display
216}
217
218func (r Result) createSessionDestinationDisplay() string {
219 destination := r.createDestination
220 if destination.name == "" {
221 if destination.kind == CreateSessionOnLocalRoute {
222 return "Create session locally…"
223 }
224 return "Create session on " + destination.displayOrigin + "…"
225 }
226 text := "Create session “" + destination.name + "”"
227 if destination.kind != CreateSessionOnLocalRoute {
228 text += " on " + destination.displayOrigin
229 }
230 return text
231}
232
233func (r Result) sessionDisplayPrefix() string {
234 if r.kind == ResultKindStoppedSession {
235 return stoppedSessionDisplayPrefix
236 }
237 return activeSessionDisplayPrefix
238}
239
240func (r Result) SearchText() string {
241 return r.DisplayText()
242}
243
244// searchTerms returns the user-facing identity and qualified label without the
245// action prefix. The offset maps term rune positions back into DisplayText.
246func (r Result) searchTerms() (identity, label string, offset int, ok bool) {
247 switch r.kind {
248 case ResultKindCommand:
249 return r.command.Code, r.command.Code, 0, true
250 case ResultKindActiveSession, ResultKindStoppedSession:
251 return r.session.target.SessionName, r.session.display, utf8.RuneCountInString(r.sessionDisplayPrefix()), true
252 case ResultKindRemoteSession:
253 return r.remoteSession.key.Name, remoteSessionDisplay(r.remoteSession.key), utf8.RuneCountInString(activeSessionDisplayPrefix), true
254 case ResultKindImportedSession:
255 return r.importedSession.name, r.importedSession.display(), utf8.RuneCountInString(activeSessionDisplayPrefix), true
256 case ResultKindRecentRoute:
257 return r.route.name, r.route.label, utf8.RuneCountInString(activeSessionDisplayPrefix), true
258 default:
259 return "", "", 0, false
260 }
261}
262
263// Command returns the command payload only for command results.
264func (r Result) Command() (command.Command, bool) {
265 return r.command, r.kind == ResultKindCommand
266}
267
268// SessionName returns the session name only for session results.
269func (r Result) SessionName() (string, bool) {
270 return r.session.target.SessionName, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
271}
272
273// SessionCreatedAt returns the session creation time only for session results.
274func (r Result) SessionCreatedAt() (time.Time, bool) {
275 return r.session.createdAt, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
276}
277
278// SessionTarget returns the exact lifecycle UUID and name for local active and
279// stopped session results.
280func (r Result) SessionTarget() (protocol.ExactSessionTarget, bool) {
281 return r.session.target, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
282}
283
284// RemoteSessionTarget returns the structured catalog target only for a remote
285// session result.
286func (r Result) RemoteSessionTarget() (domain.RemoteSessionTarget, bool) {
287 return r.remoteSession.target, r.kind == ResultKindRemoteSession
288}
289
290// RemoteSessionKey returns the structured catalog identity only for a remote
291// session result.
292func (r Result) RemoteSessionKey() (domain.RemoteSessionKey, bool) {
293 return r.remoteSession.key, r.kind == ResultKindRemoteSession
294}
295
296// RemoteSessionUnavailableReason returns the catalog availability reason only
297// for a remote session result.
298func (r Result) RemoteSessionUnavailableReason() (string, bool) {
299 return r.remoteSession.unavailableReason, r.kind == ResultKindRemoteSession
300}
301
302// importedDisplay qualifies an inventory row with its explicit origin.
303// Unqualified names would collapse homonyms across local and remote
304// sources into the wrong selection.
305func (p importedSessionPayload) qualified() string {
306 return SessionDisplay(p.name, p.displayOrigin)
307}
308
309func (p importedSessionPayload) display() string {
310 base := p.qualified()
311 // Remote vision is read-only availability tagging: availability
312 // reasons surface independently of lifecycle state (a cached-up row
313 // can still be unreachable), and any non-live state tags inline so
314 // stale or broken entries never look attachable. Live rows without a
315 // reason stay untagged.
316 tag := p.state
317 if tag == "" || tag == "up" {
318 tag = ""
319 }
320 if p.reason != "" {
321 if tag == "" {
322 return base + " (" + p.reason + ")"
323 }
324 return base + " (" + tag + ": " + p.reason + ")"
325 }
326 if tag == "" {
327 return base
328 }
329 return base + " (" + tag + ")"
330}
331
332// ImportedSessionKey returns the opaque inventory selection keys only for
333// an imported session result.
334func (r Result) ImportedSessionKey() (sourceKey, entryKey string, ok bool) {
335 return r.importedSession.sourceKey, r.importedSession.entryKey, r.kind == ResultKindImportedSession
336}
337
338// ImportedSessionDisplay returns the qualified display label and reason only
339// for an imported session result.
340func (r Result) ImportedSessionDisplay() (display, reason string, ok bool) {
341 if r.kind != ResultKindImportedSession {
342 return "", "", false
343 }
344 return r.importedSession.qualified(), r.importedSession.reason, true
345}
346
347// RouteNavigationAction returns the exact client-ledger target only for a
348// recent-route result.
349func (r Result) RouteNavigationAction() (protocol.RouteNavigationAction, bool) {
350 return r.route.action, r.kind == ResultKindRecentRoute
351}
352
353// CreateSessionDestination returns structured creation authority only for CNS
354// destination rows. Labels are never parsed back into these values.
355func (r Result) CreateSessionDestination() (name string, kind CreateSessionDestinationKind, displayOrigin, endpoint string, route protocol.RouteRef, ok bool) {
356 d := r.createDestination
357 return d.name, d.kind, d.displayOrigin, d.endpoint, d.route, r.kind == ResultKindCreateSessionDestination
358}
359
360// CreateSessionSnapshotGeneration returns the route snapshot generation bound
361// to a local destination. Other destination kinds carry zero.
362func (r Result) CreateSessionSnapshotGeneration() (uint64, bool) {
363 return r.createDestination.snapshotGen, r.kind == ResultKindCreateSessionDestination
364}
365
366const (
367 activeSessionDisplayPrefix = "Switch to session "
368 stoppedSessionDisplayPrefix = "Resume session "
369)
370
371// CommandResults converts static commands to immutable palette targets.
372func CommandResults(commands []command.Command) []Result {
373 results := make([]Result, len(commands))
374 for i, cmd := range commands {
375 results[i] = NewCommandResult(cmd)
376 }
377 return results
378}