v0.2.0 80c61e3

palette.go RawHistoryFolder

1package daemon
2
3import (
4	"errors"
5	"sort"
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	"github.com/bnema/vev/internal/usecase/command"
12	"github.com/bnema/vev/internal/usecase/layout"
13	"github.com/bnema/vev/internal/usecase/palette"
14	"github.com/bnema/vev/internal/usecase/picker"
15	themeui "github.com/bnema/vev/internal/usecase/theme"
16	"github.com/bnema/vev/internal/usecase/ui"
17)
18
19const (
20	paletteRailBreakpoint = 96
21	paletteRailWidth      = 64
22)
23
24var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Commands ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}}
25
26func paletteModalFor(size domain.Size, cfg domain.PaletteConfig) ui.Modal {
27	modal := paletteModal
28	if size.Cols >= paletteRailBreakpoint {
29		modal.FixedWidth = paletteRailWidth
30	}
31	if cfg.AnchorSet {
32		modal.Anchor = cfg.Anchor
33	} else if size.Cols >= paletteRailBreakpoint {
34		modal.Anchor = domain.AnchorBottomRight
35	}
36	return modal
37}
38
39func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
40	// Capture the client-owned route snapshot before taking paletteMu. The
41	// snapshot is immutable for this palette interaction and the daemon never
42	// consults its own session history for recent-route commands.
43	routeSnapshot := ac.routeSnapshotCopy()
44	commands := d.paletteCommands()
45	results := d.paletteResults(sess, commands, routeSnapshot, ac.remoteOrigin)
46	ac.overlays.paletteMu.Lock()
47	ac.overlays.paletteGeneration++
48	ac.overlays.palette = palette.New(results)
49	ac.overlays.paletteRouteSnapshot = routeSnapshot
50	ac.overlays.paletteHints = palette.ContextualHints{}
51	ac.overlays.paletteFeedback = ""
52	ac.overlays.palettePending = nil
53	ac.overlays.paletteMu.Unlock()
54	d.invalidateRender(sess, ac, true, "palette.go")
55}
56
57// paletteResults captures eligible named sessions before paletteMu so the
58// palette has an immutable lifecycle target without violating lock ordering.
59func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot, currentRemoteOrigin string) []palette.Result {
60	d.mu.Lock()
61	sessions := d.sessionsSnapshotLocked()
62	stopped := make([]stoppedSession, 0, len(d.stopped))
63	for _, entry := range d.stopped {
64		if !entry.purging {
65			stopped = append(stopped, entry)
66		}
67	}
68	d.mu.Unlock()
69
70	results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+len(routeSnapshot.Entries))
71	for _, cmd := range commands {
72		results = append(results, palette.NewCommandResult(cmd))
73	}
74	active := make([]palette.Result, 0, len(sessions))
75	for _, candidate := range sessions {
76		if candidate == current {
77			continue
78		}
79		snap := candidate.snapshotView(viewOptions{})
80		if snap.name != "" && !snap.ephemeral {
81			active = append(active, palette.NewActiveSessionResult(snap.name, time.Unix(0, snap.createdAt), snap.id))
82		}
83	}
84	sort.Slice(active, func(i, j int) bool {
85		left, _ := active[i].SessionName()
86		right, _ := active[j].SessionName()
87		return left < right
88	})
89	sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
90	results = append(results, active...)
91	localNames := make(map[string]struct{}, len(active)+len(stopped))
92	for _, candidate := range active {
93		name, _ := candidate.SessionName()
94		localNames[name] = struct{}{}
95	}
96	for _, candidate := range stopped {
97		results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))
98		localNames[candidate.name] = struct{}{}
99	}
100	formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
101	for i, entry := range routeSnapshot.Entries {
102		if i >= len(formattedRoutes) {
103			break
104		}
105		label := formattedRoutes[i].name
106		if label == "" {
107			continue
108		}
109		if _, represented := localNames[entry.Name]; represented {
110			localRoute := entry.Kind == ports.RouteKindLocal
111			currentRemoteRoute := entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin
112			if localRoute || currentRemoteRoute {
113				continue
114			}
115		}
116		results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
117			SnapshotGeneration: routeSnapshot.Generation,
118			Key:                entry.Key,
119			Generation:         entry.Generation,
120		}))
121	}
122	return results
123}
124
125func recentRouteHints(snapshot ports.RecentRouteSnapshot, args []string) palette.ContextualHints {
126	formatted := formatRecentRouteSnapshot(snapshot)
127	names := make([]string, len(formatted))
128	for i, entry := range formatted {
129		names[i] = entry.name
130	}
131	hints := palette.BuildRecentSessionHints(names, args)
132	for i, entry := range snapshot.Entries {
133		if i >= len(hints.Recent) {
134			break
135		}
136		hints.Recent[i].SnapshotGeneration = snapshot.Generation
137		hints.Recent[i].Key = entry.Key
138		hints.Recent[i].Generation = entry.Generation
139	}
140	return hints
141}
142
143func (d *Daemon) paletteCommands() []command.Command {
144	commands := command.PaletteRegistry()
145	d.paletteRecentMu.Lock()
146	recent := append([]string(nil), d.paletteRecent...)
147	d.paletteRecentMu.Unlock()
148	overrides := d.codeOverrideSnapshot()
149
150	byCode := make(map[string]command.Command, len(commands))
151	for _, cmd := range commands {
152		cmd = commandWithOverrides(cmd, overrides)
153		byCode[cmd.Code] = cmd
154	}
155	out := make([]command.Command, 0, len(commands))
156	used := make(map[string]bool, len(recent))
157	for _, code := range recent {
158		cmd, ok := byCode[code]
159		if !ok || used[code] {
160			continue
161		}
162		out = append(out, cmd)
163		used[code] = true
164	}
165	for _, cmd := range commands {
166		cmd = commandWithOverrides(cmd, overrides)
167		if !used[cmd.Code] {
168			out = append(out, cmd)
169		}
170	}
171	return out
172}
173
174func (d *Daemon) recordPaletteUse(code string) {
175	d.paletteRecentMu.Lock()
176	defer d.paletteRecentMu.Unlock()
177
178	recent := make([]string, 0, len(d.paletteRecent)+1)
179	recent = append(recent, code)
180	for _, existing := range d.paletteRecent {
181		if existing != code {
182			recent = append(recent, existing)
183		}
184	}
185	d.paletteRecent = recent
186}
187
188func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
189	entry := ac.currentAttachmentSession()
190	if entry == nil {
191		return
192	}
193	sess := entry
194	var cmd command.Command
195	var sessionTarget palette.Result
196	var hasSessionTarget bool
197	var routeTarget ports.RouteNavigationAction
198	var hasRouteTarget bool
199	var args []string
200	var routeSnapshot ports.RecentRouteSnapshot
201	var generation uint64
202	var rawQuery string
203	changed, cancel, execute := false, false, false
204	var effect *attachmentEffectTicket
205	if len(effects) != 0 {
206		effect = effects[0]
207	}
208
209	ac.overlays.paletteMu.Lock()
210	if ac.overlays.palette == nil {
211		ac.overlays.palettePending = nil
212		ac.overlays.paletteMu.Unlock()
213		return
214	}
215	routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
216		rune: func(r rune) {
217			if !execute {
218				ac.overlays.palette.Insert(r)
219				changed = true
220			}
221		},
222		backspace: func() {
223			if !execute {
224				ac.overlays.palette.Backspace()
225				changed = true
226			}
227		},
228		tab: func() {
229			if !execute {
230				changed = ac.overlays.palette.CompleteSelected() || changed
231			}
232		},
233		up: func() {
234			if !execute {
235				ac.overlays.palette.Up()
236				changed = true
237			}
238		},
239		down: func() {
240			if !execute {
241				ac.overlays.palette.Down()
242				changed = true
243			}
244		},
245		cancel: func() {
246			if !execute {
247				cancel = true
248			}
249		},
250		enter: func() {
251			if execute {
252				return
253			}
254			selected, ok := ac.overlays.palette.Selected()
255			if !ok {
256				changed = true
257				return
258			}
259			rawQuery = ac.overlays.palette.Query()
260			if selectedCommand, ok := selected.Command(); ok {
261				cmd = selectedCommand
262				// JRS is contextual: fuzzy selection cannot turn an unrelated
263				// query into a jump.
264				if cmd.Slug == "jump-recent-session" {
265					action, valid := palette.ParseAction([]palette.Result{selected}, rawQuery)
266					if !valid {
267						changed = true
268						return
269					}
270					args = action.Args
271					routeSnapshot = ac.overlays.paletteRouteSnapshot
272					routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)
273				}
274			} else if action, ok := selected.RouteNavigationAction(); ok {
275				routeTarget = action
276				hasRouteTarget = true
277			} else if _, ok := selected.SessionName(); ok {
278				sessionTarget = selected
279				hasSessionTarget = true
280			} else {
281				changed = true
282				return
283			}
284			generation = ac.overlays.paletteGeneration
285			execute = true
286		},
287	})
288	if changed {
289		ac.overlays.paletteFeedback = ""
290		active, ok := ac.overlays.palette.ArgumentCommand()
291		if ok && active.Slug == "jump-recent-session" {
292			args := paletteArgs(ac.overlays.palette.Query(), active)
293			if ac.overlays.paletteRouteSnapshot.Generation != 0 {
294				ac.overlays.paletteHints = recentRouteHints(ac.overlays.paletteRouteSnapshot, args)
295			} else {
296				ac.overlays.paletteHints = palette.ContextualHints{}
297			}
298		} else {
299			ac.overlays.paletteHints = palette.ContextualHints{}
300		}
301	}
302	if cancel {
303		ac.clearPaletteLocked()
304	}
305	ac.overlays.paletteMu.Unlock()
306	if cancel {
307		d.invalidateRender(entry, ac, true, "palette.go")
308		return
309	}
310	if !execute {
311		if changed {
312			d.invalidateRender(entry, ac, true, "palette.go")
313		}
314		return
315	}
316
317	if hasRouteTarget {
318		exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
319		if err := exec.NavigateRecentRoute(routeTarget); err != nil {
320			if errors.Is(err, errAttachmentTransition) {
321				return
322			}
323			ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
324			d.invalidateRender(entry, ac, true, "palette.go")
325			return
326		}
327		if ac.closeExecutedPalette(generation, rawQuery) {
328			d.invalidateRender(entry, ac, true, "palette.go")
329		}
330		return
331	}
332
333	if hasSessionTarget {
334		createdAt, _ := sessionTarget.SessionCreatedAt()
335		expectedCreatedAt := createdAt.UnixNano()
336		name, _ := sessionTarget.SessionName()
337		target := picker.Target{Name: name, TabIndex: -1, ExpectedCreatedAt: &expectedCreatedAt}
338		if id, active := sessionTarget.SessionID(); active {
339			target.Session = id
340		} else {
341			target.Stopped = true
342		}
343		var err error
344		if effect != nil {
345			err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session")
346		} else {
347			err = d.switchToTarget(sess, ac, target)
348		}
349		if errors.Is(err, errAttachmentTransition) {
350			return
351		}
352		if err != nil {
353			ac.paletteFailure(generation, rawQuery, "requested session is unavailable")
354			d.invalidateRender(entry, ac, true, "palette.go")
355			return
356		}
357		if ac.closeExecutedPalette(generation, rawQuery) {
358			if current := ac.currentAttachmentSession(); current != nil {
359				d.invalidateRender(current, ac, true, "palette.go")
360			}
361		}
362		return
363	}
364
365	if cmd.Slug == "jump-recent-session" {
366		rank, err := command.ParsePositiveDecimal(args)
367		if err != nil {
368			ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
369			d.invalidateRender(entry, ac, true, "palette.go")
370			return
371		}
372		exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, routeSnapshot: routeSnapshot, effect: effect}
373		if err := exec.JumpRecentSession(rank); err != nil {
374			if errors.Is(err, errAttachmentTransition) {
375				return
376			}
377			ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
378			d.invalidateRender(entry, ac, true, "palette.go")
379			return
380		}
381		if ac.closeExecutedPalette(generation, rawQuery) {
382			d.recordPaletteUse(cmd.Code)
383			if current := ac.currentAttachmentSession(); current != nil {
384				d.invalidateRender(current, ac, true, "palette.go")
385			}
386		}
387		return
388	}
389	attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
390	if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
391		return
392	}
393	sess.dispatchMu.Lock()
394	err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)
395	sess.dispatchMu.Unlock()
396	if attachmentHandoff {
397		if current := ac.currentSession(); current != nil {
398			currentToken := current.attachmentToken(ac, ac.transport())
399			fresh, admitted := ac.beginAttachmentEffect(currentToken)
400			if ac.closeExecutedPalette(generation, rawQuery) {
401				d.invalidateRender(current, ac, true, "palette.go")
402			}
403			if admitted {
404				fresh.End()
405			}
406		} else {
407			ac.closeExecutedPalette(generation, rawQuery)
408		}
409	}
410	if errors.Is(err, errAttachmentTransition) {
411		return
412	}
413	if err != nil {
414		d.log.Error("palette command failed", "err", err, "command", cmd.Code)
415		d.reportError(sess, paletteCommandNoticeError(cmd, err))
416	} else {
417		d.recordPaletteUse(cmd.Code)
418	}
419}
420
421func paletteCommandNoticeError(cmd command.Command, err error) error {
422	if cmd.Scope == command.CommandScopeCrossSession {
423		return movePickerUserError(err)
424	}
425	return err
426}
427
428func paletteArgs(query string, cmd command.Command) []string {
429	action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query)
430	if !ok {
431		return nil
432	}
433	return action.Args
434}
435
436func (ac *attachedClient) clearPaletteLocked() {
437	ac.overlays.paletteGeneration++
438	ac.overlays.palette = nil
439	ac.overlays.paletteRouteSnapshot = ports.RecentRouteSnapshot{}
440	ac.overlays.paletteHints = palette.ContextualHints{}
441	ac.overlays.paletteFeedback = ""
442	ac.overlays.palettePending = nil
443}
444
445func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {
446	ac.overlays.paletteMu.Lock()
447	defer ac.overlays.paletteMu.Unlock()
448	if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
449		return
450	}
451	ac.overlays.paletteFeedback = feedback
452}
453
454func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery string) bool {
455	ac.overlays.paletteMu.Lock()
456	defer ac.overlays.paletteMu.Unlock()
457	if ac.overlays.palette == nil || ac.overlays.paletteGeneration != generation || ac.overlays.palette.Query() != rawQuery {
458		return false
459	}
460	ac.clearPaletteLocked()
461	return true
462}
463
464type paletteExec struct {
465	d                   *Daemon
466	sess                *session
467	attachment          *session
468	ac                  *attachedClient
469	routeSnapshot       ports.RecentRouteSnapshot
470	actions             daemonActionRunner
471	effect              *attachmentEffectTicket
472	redrawClosedPalette bool
473}
474
475func (e paletteExec) runAction(request daemonActionRequest) error {
476	request.effect = e.effect
477	if request.target.session == nil {
478		request.target = resolveDaemonActionTargetForAttachment(e.sess, e.ac)
479	}
480	runner := e.actions
481	if runner == nil {
482		runner = daemonActions{d: e.d}
483	}
484	err := runner.Run(request)
485	if errors.Is(err, errDaemonActionNoChange) {
486		if e.redrawClosedPalette {
487			e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
488		}
489		return nil
490	}
491	if err != nil {
492		return err
493	}
494	if e.actions == nil {
495		finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
496	}
497	return nil
498}
499
500func (e paletteExec) CreateTab() error {
501	return e.runAction(daemonActionRequest{kind: daemonActionCreateTab, viewport: e.sess.fullViewportSize()})
502}
503
504func (e paletteExec) CreateSession() error {
505	entry := e.attachment
506	if entry == nil {
507		entry = e.sess
508	}
509	e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error {
510		if token.ac == nil {
511			if e.sess == nil {
512				return errAttachmentTransition
513			}
514			return e.d.createSessionAndSwitch(e.sess, e.ac, name)
515		}
516		return e.d.createSessionAndSwitchForAttachment(token, name)
517	})
518	return nil
519}
520
521func (e paletteExec) CloseTab() error {
522	return e.runAction(daemonActionRequest{kind: daemonActionCloseTab})
523}
524
525func (e paletteExec) split(direction layout.Direction) error {
526	return e.runAction(daemonActionRequest{kind: daemonActionSplitPane, direction: direction})
527}
528
529func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
530func (e paletteExec) SplitLeft() error  { return e.split(layout.Left) }
531func (e paletteExec) SplitUp() error    { return e.split(layout.Up) }
532func (e paletteExec) SplitDown() error  { return e.split(layout.Down) }
533func (e paletteExec) consumeOrExpelPane(direction layout.Direction) error {
534	return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
535}
536func (e paletteExec) ConsumeOrExpelPaneLeft() error {
537	return e.consumeOrExpelPane(layout.Left)
538}
539func (e paletteExec) ConsumeOrExpelPaneRight() error {
540	return e.consumeOrExpelPane(layout.Right)
541}
542
543func (e paletteExec) StackPane() error {
544	return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
545}
546
547func (e paletteExec) ToggleStack() error {
548	return e.runAction(daemonActionRequest{kind: daemonActionToggleStack})
549}
550
551func (e paletteExec) ClosePane() error {
552	return e.runAction(daemonActionRequest{kind: daemonActionClosePane})
553}
554
555func (e paletteExec) OpenMovePanePicker() error {
556	target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
557	if target.tab == nil || target.pane == nil {
558		return errMovePaneInvalid
559	}
560	return e.d.enterPickerForIntent(e.sess, e.ac, pickerMovePane, moveSourceLocator{
561		Session:    sessionMoveLocator(e.sess),
562		TabID:      domain.TabStableID(target.tab.stableID),
563		PaneID:     domain.PaneStableID(target.pane.stableID),
564		Attachment: e.ac,
565	})
566}
567
568func (e paletteExec) OpenMoveTabPicker() error {
569	target := resolveDaemonActionTargetForAttachment(e.sess, e.ac)
570	if target.tab == nil {
571		return errMovePaneInvalid
572	}
573	return e.d.enterPickerForIntent(e.sess, e.ac, pickerMoveTab, moveSourceLocator{
574		Session:    sessionMoveLocator(e.sess),
575		TabID:      domain.TabStableID(target.tab.stableID),
576		Attachment: e.ac,
577	})
578}
579
580func (e paletteExec) focus(direction layout.Direction) error {
581	return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction})
582}
583
584func (e paletteExec) FocusPaneLeft() error  { return e.focus(layout.Left) }
585func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) }
586func (e paletteExec) FocusPaneUp() error    { return e.focus(layout.Up) }
587func (e paletteExec) FocusPaneDown() error  { return e.focus(layout.Down) }
588
589func (e paletteExec) EnterResizeMode() error { return e.d.enterResizeMode(e.sess, e.ac) }
590func (e paletteExec) resize(axis layout.Axis, delta int) error {
591	return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta}))
592}
593func (e paletteExec) GrowPaneWidth() error    { return e.resize(layout.Width, resizeStepCols) }
594func (e paletteExec) ShrinkPaneWidth() error  { return e.resize(layout.Width, -resizeStepCols) }
595func (e paletteExec) GrowPaneHeight() error   { return e.resize(layout.Height, resizeStepRows) }
596func (e paletteExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) }
597func (e paletteExec) EqualizePanes() error {
598	return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes}))
599}
600
601func (e paletteExec) NextTab() error {
602	return e.runAction(daemonActionRequest{kind: daemonActionNextTab})
603}
604
605func (e paletteExec) PrevTab() error {
606	return e.runAction(daemonActionRequest{kind: daemonActionPreviousTab})
607}
608
609func (e paletteExec) ToggleFloatingPane() error {
610	return e.d.toggleFloating(e.sess, e.ac)
611}
612
613func (e paletteExec) BackSession() error {
614	if e.effect != nil {
615		return e.d.backSessionForAttachment(e.effect.connectionToken())
616	}
617	e.d.backSession(e.sess, e.ac)
618	return nil
619}
620
621func (e paletteExec) Detach() error {
622	if e.effect != nil {
623		if !e.d.clientGoneForAttachment(e.effect.connectionToken(), true) {
624			return errAttachmentTransition
625		}
626		return nil
627	}
628	e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
629	return nil
630}
631
632func (e paletteExec) EnterVisualMode() error {
633	e.d.enterCopyMode(e.sess, e.ac)
634	return nil
635}
636
637func (e paletteExec) RenameSession() error {
638	e.sess.mu.Lock()
639	currentName := e.sess.name
640	e.sess.mu.Unlock()
641	e.d.enterPrompt(e.sess, e.ac, " Rename session ", currentName, func(name string) error {
642		return e.runAction(daemonActionRequest{kind: daemonActionRenameSession, name: name})
643	})
644	return nil
645}
646
647func (e paletteExec) RenameTab() error {
648	tb := e.sess.tabForAttachment(e.ac)
649	if e.ac == nil {
650		tb = e.sess.firstTab()
651	}
652	if tb == nil {
653		return nil
654	}
655	e.sess.mu.Lock()
656	index := 0
657	for i, candidate := range e.sess.tabs {
658		if candidate == tb {
659			index = i
660			break
661		}
662	}
663	currentName := tabDisplayName(tb, index)
664	e.sess.mu.Unlock()
665	e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
666		return e.runAction(daemonActionRequest{kind: daemonActionRenameTab, target: daemonActionTarget{session: e.sess, tab: tb}, name: name})
667	})
668	return nil
669}
670
671func (e paletteExec) OpenSessionPicker() error {
672	if e.ac != nil && e.ac.navigationCapabilities&ports.NavigationCapabilityHomePicker != 0 && e.effect != nil {
673		return e.d.sendNavigationActionForAttachment(e.effect.connectionToken(), ports.NavigationOpenHomePicker)
674	}
675	e.d.enterPicker(e.sess, e.ac)
676	return nil
677}
678
679func (e paletteExec) OpenNotifications() error {
680	e.d.enterNotices(e.sess, e.ac)
681	return nil
682}
683
684func (e paletteExec) YankLastNotification() error {
685	entry := e.attachment
686	if entry == nil {
687		entry = e.sess
688	}
689	n, ok := e.d.notices.latest()
690	if !ok {
691		// Reported directly rather than returned: the generic cmd.Run error
692		// path only logs, so a returned error here would never reach the
693		// user as the warn toast this no-op is supposed to produce.
694		e.d.reportAttachmentError(entry, domain.UserWarn(domain.NoticeClipboard, "no notifications yet", nil))
695		return nil
696	}
697	e.d.yankNotice(entry, e.ac, n)
698	return nil
699}
700
701func (e paletteExec) JumpRecentSession(rank int) error {
702	if e.routeSnapshot.Generation == 0 || rank < 1 || rank > len(e.routeSnapshot.Entries) {
703		return command.ErrInvalidArguments
704	}
705	entry := e.routeSnapshot.Entries[rank-1]
706	return e.NavigateRecentRoute(ports.RouteNavigationAction{
707		SnapshotGeneration: e.routeSnapshot.Generation,
708		Key:                entry.Key,
709		Generation:         entry.Generation,
710	})
711}
712
713func (e paletteExec) NavigateRecentRoute(action ports.RouteNavigationAction) error {
714	if e.d.beforeRecentSessionHandoff != nil {
715		e.d.beforeRecentSessionHandoff()
716	}
717	if e.effect != nil {
718		return e.d.sendRecentRouteNavigationActionForAttachment(e.effect.connectionToken(), action)
719	}
720	if e.ac == nil || e.attachment == nil {
721		return errAttachmentTransition
722	}
723	return e.d.sendRecentRouteNavigationActionForAttachment(e.attachment.attachmentToken(e.ac, e.ac.transport()), action)
724}
725
726func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
727	styleSet := resolveStyles(styles)
728	modal := paletteModalFor(domain.Size{Cols: base.Width, Rows: base.Height}, cfg)
729	return composeModalClientFrame(base, modal, styleSet, func(size domain.Size) renderer.Frame {
730		return model.Render(size, palette.RenderOptions{Styles: palette.RenderStyles{Base: styleSet.PickerBase, Row: styleSet.PickerBase, Selection: styleSet.PickerSelection, Description: styleSet.PickerDescription, SelectionDescription: styleSet.PickerSelectionMuted}, Guidance: guidance})
731	})
732}

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