v0.3.0 99dc266

move_picker.go RawHistoryFolder

1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5	"github.com/bnema/vev/internal/domain"
6	"github.com/bnema/vev/internal/usecase/picker"
7)
8
9func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
10	if d == nil || source.Session.ID == "" || source.TabID == "" {
11		return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
12	}
13	sess := d.sessionByID(source.Session.ID)
14	if sess == nil || sess.incarnation != source.Session.Incarnation {
15		return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
16	}
17	sess.mu.Lock()
18	defer sess.mu.Unlock()
19	if source.AttachmentToken.ac != nil && !moveAttachmentTokenCurrentLocked(source.AttachmentToken, sess) {
20		return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid)
21	}
22	if source.Attachment != nil && !attachmentRegisteredLocked(sess, source.Attachment) {
23		return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid)
24	}
25	tb := findMoveTabLocked(sess, source.TabID)
26	if tb == nil {
27		return domain.UserWarn(domain.NoticeSessionUnavailable, "Tab no longer exists.", errMovePaneInvalid)
28	}
29	if source.PaneID == "" {
30		return nil
31	}
32	tb.mu.Lock()
33	defer tb.mu.Unlock()
34	if paneByStableIDLocked(tb, string(source.PaneID)) == nil {
35		return domain.UserWarn(domain.NoticeSessionUnavailable, "Pane no longer exists.", errMovePaneInvalid)
36	}
37	return nil
38}
39
40type pickerIntent uint8
41
42const (
43	pickerNavigate pickerIntent = iota
44	pickerMovePane
45	pickerMoveTab
46)
47
48type moveSessionLocator struct {
49	ID          domain.SessionID
50	Incarnation domain.IncarnationID
51	Name        string
52}
53
54type moveSourceLocator struct {
55	Session         moveSessionLocator
56	TabID           domain.TabStableID
57	PaneID          domain.PaneStableID
58	Attachment      *attachedClient
59	AttachmentToken attachmentConnectionToken
60}
61
62// enterPickerForIntent returns errNoMoveDestination only for move intents.
63func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator) error {
64	if intent != pickerNavigate && !sess.capabilities().yieldsMoves() {
65		return errSessionCannotYieldMoves
66	}
67	if source.Attachment != nil && source.AttachmentToken.ac == nil {
68		source.AttachmentToken = sess.attachmentToken(source.Attachment, source.Attachment.transport())
69	}
70	model := d.newPickerModel(sess, ac, intent, source, picker.SourceFilter{})
71	if intent != pickerNavigate {
72		if _, ok := model.Selected(); !ok {
73			return errNoMoveDestination
74		}
75	}
76
77	d.publishPicker(sess, ac, model, intent, source)
78	return nil
79}
80
81func pickerSelectionMode(intent pickerIntent) picker.SelectionMode {
82	switch intent {
83	case pickerMovePane:
84		return picker.SelectMovePaneTab
85	case pickerMoveTab:
86		return picker.SelectMoveTabSession
87	default:
88		return picker.SelectNavigationTab
89	}
90}
91
92func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourceLocator, target picker.Target) error {
93	destination := moveSessionLocator{ID: target.Session, Incarnation: target.Incarnation, Name: target.Name}
94	switch intent {
95	case pickerMovePane:
96		if target.TabID == "" {
97			return errMovePaneInvalid
98		}
99		return d.movePane(movePaneRequest{
100			Attachment:       source.Attachment,
101			AttachmentToken:  source.AttachmentToken,
102			Source:           source.Session,
103			SourceTabID:      source.TabID,
104			SourcePaneID:     source.PaneID,
105			Destination:      destination,
106			DestinationTabID: target.TabID,
107		})
108	case pickerMoveTab:
109		return d.moveTab(moveTabRequest{
110			Attachment:      source.Attachment,
111			AttachmentToken: source.AttachmentToken,
112			Source:          source.Session,
113			SourceTabID:     source.TabID,
114			Destination:     destination,
115		})
116	default:
117		return errMovePaneInvalid
118	}
119}
120
121func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
122	d.mu.Lock()
123	sess := d.sessions[target.Session]
124	if sess == nil {
125		d.mu.Unlock()
126		return nil, nil
127	}
128	sess.mu.Lock()
129	d.mu.Unlock()
130	defer sess.mu.Unlock()
131	if sess.incarnation != target.Incarnation || !targetMatchesLifecycle(target, sess.name, sess.createdAt) {
132		return nil, nil
133	}
134	if intent == pickerMoveTab {
135		if len(sess.tabs) == 0 {
136			return nil, nil
137		}
138		return sess, sess.tabs[0]
139	}
140	if target.TabID != "" {
141		for _, tb := range sess.tabs {
142			if domain.TabStableID(tb.stableID) == target.TabID {
143				return sess, tb
144			}
145		}
146		return nil, nil
147	}
148	if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
149		return nil, nil
150	}
151	return sess, sess.tabs[target.TabIndex]
152}
153
154type pickerRefreshOptions struct {
155	// preserveSelection keeps the currently selected target selected across the
156	// rebuild even for navigate intent (used by the sort toggle).
157	preserveSelection bool
158	// nearestRow, when >= 0, selects the row occupying this index after the
159	// rebuild (used after deletes). -1 disables it.
160	nearestRow int
161}
162
163func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions) {
164	sess := ac.currentSession()
165	if sess == nil {
166		return
167	}
168	rt := ac.overlays
169	rt.pickerMu.Lock()
170	if rt.picker == nil {
171		rt.pickerMu.Unlock()
172		return
173	}
174	intent, source := rt.pickerIntent, rt.pickerSource
175	observedModel, observedGeneration := rt.picker, rt.pickerGeneration
176	current := picker.SourceFilter{}
177	if intent != pickerNavigate || opts.preserveSelection {
178		cursor, _ := rt.picker.Cursor()
179		current = picker.SourceFilter{
180			Session: cursor.Session, Incarnation: cursor.Incarnation, TabID: cursor.TabID, RemoteKey: cursor.RemoteKey,
181		}
182	}
183	rt.pickerMu.Unlock()
184	model := d.newPickerModel(sess, ac, intent, source, current)
185	if rt.afterPickerRefreshBuild != nil {
186		rt.afterPickerRefreshBuild(model)
187	}
188	if intent != pickerNavigate {
189		if _, ok := model.Selected(); !ok {
190			d.closePickerIfCurrent(ac, observedModel, observedGeneration)
191			return
192		}
193	}
194	// Navigate-only: move intents keep the selection-or-close logic above
195	// authoritative, so a stale row index can never override it.
196	if intent == pickerNavigate && opts.nearestRow >= 0 {
197		model.SelectNearestRow(opts.nearestRow)
198	}
199	rt.pickerMu.Lock()
200	updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerIntent == intent && rt.pickerSource == source
201	if updated {
202		rt.picker = model
203		rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
204	}
205	rt.pickerMu.Unlock()
206	if updated {
207		d.registerPreviewForSelection(ac)
208	}
209}

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