model.go RawHistoryFolder

1package palette
2
3import (
4	"strings"
5	"unicode"
6	"unicode/utf8"
7
8	renderer "github.com/bnema/vev-vt"
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/usecase/command"
11	"github.com/bnema/vev/internal/usecase/ui"
12)
13
14type RenderStyles struct {
15	// Base fills unused modal interior cells; Row is the ordinary inactive
16	// result surface. They are separate to retain the chrome hierarchy.
17	Base                 renderer.Style
18	Row                  renderer.Style
19	Selection            renderer.Style
20	Description          renderer.Style
21	SelectionDescription renderer.Style
22}
23
24// RenderOptions contains presentation supplied by the daemon for one render.
25// Guidance is substituted only into the exact contextual command row; it never
26// adds rows or changes fuzzy matching geometry.
27type RenderOptions struct {
28	Styles   RenderStyles
29	Guidance string
30	// Preview is the current exact command's action description. It replaces
31	// the selected row's static description unless Feedback is present.
32	Preview string
33	// Feedback is interaction-scoped daemon feedback. It replaces the selected
34	// row's contextual detail without adding a searchable result or row.
35	Feedback string
36}
37
38type Model struct {
39	results            []Result
40	argumentResults    []Result
41	createDestinations []Result
42	input              ui.TextInput
43	matches            []Match
44	selected           int
45	scroll             int
46	destinationMode    bool
47	selectionMade      bool
48}
49
50// New accepts typed immutable palette results.
51func New(results []Result) *Model {
52	ordinary, destinations := splitResults(results)
53	m := &Model{
54		results:            ordinary,
55		argumentResults:    requiredArgumentResults(ordinary),
56		createDestinations: destinations,
57	}
58	m.refresh()
59	return m
60}
61
62func NewRegistry() *Model { return New(CommandResults(command.PaletteRegistry())) }
63
64// ReplaceResults replaces the model's immutable result source while preserving
65// the current query and exact selected target when it remains available.
66func (m *Model) ReplaceResults(results []Result) {
67	if m == nil {
68		return
69	}
70	wasDestinationMode := m.destinationMode
71	wasSelectionMade := m.selectionMade
72	selected, hadSelection := m.Selected()
73	selectedDestination := hadSelection && selected.Kind() == ResultKindCreateSessionDestination
74	m.results, m.createDestinations = splitResults(results)
75	m.argumentResults = requiredArgumentResults(m.results)
76	m.refresh()
77	if !hadSelection {
78		if m.destinationMode || wasDestinationMode && !wasSelectionMade {
79			m.selectionMade = false
80		}
81		return
82	}
83	for i, match := range m.matches {
84		if match.Result.sameTarget(selected) {
85			m.selected = i
86			m.selectionMade = true
87			m.clamp()
88			return
89		}
90	}
91	if selectedDestination || m.destinationMode {
92		m.selectionMade = false
93		m.selected, m.scroll = 0, 0
94		m.clamp()
95	}
96}
97
98func DefaultRenderStyles() RenderStyles {
99	selection := renderer.DefaultStyle()
100	selection.Inverse = true
101	description := renderer.DefaultStyle()
102	description.Italic = true
103	selectionDescription := selection
104	selectionDescription.Italic = true
105	base := renderer.DefaultStyle()
106	return RenderStyles{Base: base, Row: base, Selection: selection, Description: description, SelectionDescription: selectionDescription}
107}
108
109func (m *Model) Insert(r rune) {
110	if m != nil {
111		m.input.Insert(r)
112		m.selected, m.scroll, m.selectionMade = 0, 0, false
113		m.refresh()
114	}
115}
116func (m *Model) Backspace() {
117	if m != nil && m.input.Value() != "" {
118		m.input.Backspace()
119		m.selected, m.scroll, m.selectionMade = 0, 0, false
120		m.refresh()
121	}
122}
123
124func (m *Model) Query() string {
125	if m == nil {
126		return ""
127	}
128	return m.input.Value()
129}
130func (m *Model) Up() {
131	if m == nil || len(m.matches) == 0 {
132		return
133	}
134	if !m.selectionMade {
135		m.selected = len(m.matches) - 1
136		m.selectionMade = true
137		m.clamp()
138		return
139	}
140	if m.selected > 0 {
141		m.selected--
142		m.clamp()
143	}
144}
145func (m *Model) Down() {
146	if m == nil || len(m.matches) == 0 {
147		return
148	}
149	if !m.selectionMade {
150		m.selected = 0
151		m.selectionMade = true
152		m.clamp()
153		return
154	}
155	if m.selected+1 < len(m.matches) {
156		m.selected++
157		m.clamp()
158	}
159}
160
161// Selected returns the typed immutable target, not an executable command.
162func (m *Model) Selected() (Result, bool) {
163	if m == nil || !m.selectionMade || m.selected < 0 || m.selected >= len(m.matches) {
164		return Result{}, false
165	}
166	return m.matches[m.selected].Result, true
167}
168
169// CompleteSelected only completes static command results. Session targets can
170// never be parsed, completed, or otherwise treated as commands.
171func (m *Model) CompleteSelected() bool {
172	selected, ok := m.Selected()
173	if !ok {
174		return false
175	}
176	cmd, ok := selected.Command()
177	if !ok {
178		return false
179	}
180
181	query := m.Query()
182	completed := cmd.Code
183	if cmd.Arguments != command.ArgumentsNone {
184		query = strings.TrimLeftFunc(query, unicode.IsSpace)
185		token, args, hasSeparator := completionParts(query)
186		if token == cmd.Code && hasSeparator && args != "" {
187			return false
188		}
189		completed += " "
190		if args != "" {
191			completed += args
192		}
193	}
194	if completed == query {
195		return false
196	}
197
198	m.input.SetValue(completed)
199	m.selected, m.scroll, m.selectionMade = 0, 0, false
200	m.refresh()
201	return true
202}
203
204// completionParts splits the first token at Unicode whitespace and returns
205// the argument beginning at its first non-whitespace byte.
206func completionParts(query string) (token, args string, hasSeparator bool) {
207	for i, r := range query {
208		if !unicode.IsSpace(r) {
209			continue
210		}
211		for j, argumentRune := range query[i:] {
212			if !unicode.IsSpace(argumentRune) {
213				return query[:i], query[i+j:], true
214			}
215		}
216		return query[:i], "", true
217	}
218	return query, "", false
219}
220
221// ArgumentCommand returns the exact argument-taking static command being entered.
222func (m *Model) ArgumentCommand() (command.Command, bool) {
223	if m == nil {
224		return command.Command{}, false
225	}
226	return ArgumentCommand(m.results, m.Query())
227}
228
229func (m *Model) Matches() []Match {
230	if m == nil {
231		return nil
232	}
233	out := make([]Match, len(m.matches))
234	for i, match := range m.matches {
235		out[i] = match
236		out[i].Positions = append([]int(nil), match.Positions...)
237	}
238	return out
239}
240
241func (m *Model) refresh() {
242	query := m.input.Value()
243	wasDestinationMode := m.destinationMode
244	if destinations, ok := m.cnsDestinationMatches(query); ok {
245		m.destinationMode = true
246		if !wasDestinationMode {
247			m.selectionMade = false
248		}
249		m.matches = destinations
250		m.clamp()
251		return
252	}
253	m.destinationMode = false
254	m.selectionMade = true
255	m.matches = Fuzzy(m.results, query)
256	// Keep an exact command row selected over fuzzy session matches.
257	if result, ok := ExactCommandResult(m.results, query); ok {
258		m.prependMatch(result)
259		// Once an argument-taking static token is exact, retain its row while its
260		// arguments make ordinary fuzzy matching inapplicable.
261	} else if result, _, ok := argumentResult(m.results, query); ok {
262		m.prependMatch(result)
263	} else if len(m.matches) == 0 {
264		// Arguments are not part of static command matching. When they would
265		// otherwise clear the list, keep argument-capable candidates matched by
266		// the partial first token so Tab can complete them.
267		token, _, hasSeparator := completionParts(query)
268		if hasSeparator {
269			m.matches = Fuzzy(m.argumentResults, token)
270		}
271	}
272	m.clamp()
273}
274
275func splitResults(results []Result) (ordinary, destinations []Result) {
276	ordinary = make([]Result, 0, len(results))
277	for _, result := range results {
278		if result.Kind() == ResultKindCreateSessionDestination {
279			destinations = append(destinations, result)
280			continue
281		}
282		ordinary = append(ordinary, result)
283	}
284	return ordinary, destinations
285}
286
287func (m *Model) cnsDestinationMatches(query string) ([]Match, bool) {
288	if len(m.createDestinations) < 2 {
289		return nil, false
290	}
291	fields := strings.Fields(query)
292	if len(fields) == 0 || len(fields) > 2 {
293		return nil, false
294	}
295	cmd, ok := ArgumentCommand(m.results, query)
296	if !ok || cmd.Slug != "new-session" {
297		return nil, false
298	}
299	name := ""
300	if len(fields) == 2 {
301		name = fields[1]
302		if domain.ValidateSessionName(name) != nil {
303			return nil, false
304		}
305	}
306	matches := make([]Match, len(m.createDestinations))
307	for i, destination := range m.createDestinations {
308		matches[i] = newMatch(destination.withCreateSessionName(name), 0)
309	}
310	return matches, true
311}
312
313func requiredArgumentResults(results []Result) []Result {
314	commands := make([]Result, 0, len(results))
315	for _, result := range results {
316		cmd, ok := result.Command()
317		if ok && cmd.Arguments != command.ArgumentsNone {
318			commands = append(commands, result)
319		}
320	}
321	return commands
322}
323
324func (m *Model) prependMatch(result Result) {
325	cmd, _ := result.Command()
326	for i, match := range m.matches {
327		candidate, ok := match.Result.Command()
328		if !ok || candidate.Code != cmd.Code {
329			continue
330		}
331		if i > 0 {
332			copy(m.matches[1:i+1], m.matches[:i])
333			m.matches[0] = match
334		}
335		return
336	}
337	m.matches = append([]Match{newMatch(result, 0)}, m.matches...)
338}
339func (m *Model) clamp() {
340	if len(m.matches) == 0 {
341		m.selected, m.scroll = -1, 0
342		return
343	}
344	if m.selected < 0 {
345		m.selected = 0
346	}
347	if m.selected >= len(m.matches) {
348		m.selected = len(m.matches) - 1
349	}
350	if m.scroll < 0 {
351		m.scroll = 0
352	}
353	if m.scroll > m.selected {
354		m.scroll = m.selected
355	}
356}
357
358func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
359	styles := opts.Styles
360	if styles == (RenderStyles{}) {
361		styles = DefaultRenderStyles()
362	}
363	frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
364	if frame.Width == 0 || frame.Height == 0 {
365		return frame
366	}
367	base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description
368	selectionDesc := styles.SelectionDescription
369	if selectionDesc == (renderer.Style{}) {
370		selectionDesc = mergePaletteDescStyle(selection, desc)
371	}
372	ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
373	if m == nil {
374		ui.DrawInputLine(frame, 0, "> ", "", base, selection)
375		return frame
376	}
377	ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
378	start, visible := 1, frame.Height-1
379	if visible <= 0 || len(m.matches) == 0 {
380		return frame
381	}
382	if m.selected < m.scroll {
383		m.scroll = m.selected
384	}
385	if m.selected >= m.scroll+visible {
386		m.scroll = m.selected - visible + 1
387	}
388	codeWidth := 0
389	for _, match := range m.matches {
390		if cmd, ok := match.Result.Command(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {
391			codeWidth = utf8.RuneCountInString(cmd.Code)
392		}
393	}
394	activeCmd, activeOK := m.ArgumentCommand()
395	for y := range visible {
396		idx := m.scroll + y
397		if idx >= len(m.matches) {
398			break
399		}
400		match := m.matches[idx]
401		style := row
402		rowSelected := idx == m.selected && m.selectionMade
403		if rowSelected {
404			style = selection
405		}
406		ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
407		if cmd, ok := match.Result.Command(); ok {
408			m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, rowSelected)
409			continue
410		}
411		showFeedback := rowSelected || !m.selectionMade && idx == 0
412		m.renderSession(frame, y+start, style, selection, match, opts.Feedback, showFeedback)
413	}
414	return frame
415}
416
417func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc, selectionDesc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, preview, feedback string, selected bool) {
418	x, nextHighlight := 0, 0
419	for _, r := range cmd.Code {
420		cellStyle := style
421		cellStyle.Bold = true
422		if nextHighlight < len(positions) && positions[nextHighlight] == x {
423			cellStyle = selection
424			cellStyle.Bold = true
425			nextHighlight++
426		}
427		if x < frame.Width {
428			frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
429		}
430		x++
431	}
432	for x < codeWidth+1 && x < frame.Width {
433		frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
434		x++
435	}
436	description := cmd.Desc
437	if feedback != "" && selected {
438		description = feedback
439	} else if preview != "" && selected {
440		description = preview
441	} else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
442		description = guidance
443	}
444	descriptionStyle := mergePaletteDescStyle(style, desc)
445	if selected {
446		descriptionStyle = selectionDesc
447	}
448	ui.DrawText(frame, x, y, frame.Width, description, descriptionStyle)
449}
450
451func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
452	x, nextHighlight := 0, 0
453	for _, r := range match.Result.DisplayText() {
454		cellStyle := style
455		if nextHighlight < len(match.Positions) && match.Positions[nextHighlight] == x {
456			cellStyle = selection
457			cellStyle.Bold = true
458			nextHighlight++
459		}
460		if x < frame.Width {
461			frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
462		}
463		x++
464	}
465	if feedback == "" || !selected {
466		return
467	}
468	if x < frame.Width {
469		frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
470	}
471	x++
472	for _, r := range feedback {
473		if x < frame.Width {
474			frame.Set(x, y, renderer.Cell{Rune: r, Style: style})
475		}
476		x++
477	}
478}
479
480func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style {
481	out := line
482	out.Italic = desc.Italic
483	if desc.HasForegroundRGB {
484		out.HasForegroundRGB = true
485		out.ForegroundRGB = desc.ForegroundRGB
486		out.Foreground = -1
487	} else if desc.Foreground >= 0 {
488		out.HasForegroundRGB = false
489		out.Foreground = desc.Foreground
490	}
491	return out
492}

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