main ece7682

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}
130
131// paletteInputPrefix leads the query line on row 0 of the rendered frame.
132const paletteInputPrefix = "> "
133
134// Caret is the query caret column on row 0 of a Render frame of size inner
135// (see ui.InputCaret).
136func (m *Model) Caret(inner domain.Size) (int, bool) {
137	return ui.InputCaret(inner, paletteInputPrefix, m.Query())
138}
139func (m *Model) Up() {
140	if m == nil || len(m.matches) == 0 {
141		return
142	}
143	if !m.selectionMade {
144		m.selected = len(m.matches) - 1
145		m.selectionMade = true
146		m.clamp()
147		return
148	}
149	if m.selected > 0 {
150		m.selected--
151		m.clamp()
152	}
153}
154func (m *Model) Down() {
155	if m == nil || len(m.matches) == 0 {
156		return
157	}
158	if !m.selectionMade {
159		m.selected = 0
160		m.selectionMade = true
161		m.clamp()
162		return
163	}
164	if m.selected+1 < len(m.matches) {
165		m.selected++
166		m.clamp()
167	}
168}
169
170// Selected returns the typed immutable target, not an executable command.
171func (m *Model) Selected() (Result, bool) {
172	if m == nil || !m.selectionMade || m.selected < 0 || m.selected >= len(m.matches) {
173		return Result{}, false
174	}
175	return m.matches[m.selected].Result, true
176}
177
178// CompleteSelected only completes static command results. Session targets can
179// never be parsed, completed, or otherwise treated as commands.
180func (m *Model) CompleteSelected() bool {
181	selected, ok := m.Selected()
182	if !ok {
183		return false
184	}
185	cmd, ok := selected.Command()
186	if !ok {
187		return false
188	}
189
190	query := m.Query()
191	completed := cmd.Code
192	if cmd.Arguments != command.ArgumentsNone {
193		query = strings.TrimLeftFunc(query, unicode.IsSpace)
194		token, args, hasSeparator := completionParts(query)
195		if token == cmd.Code && hasSeparator && args != "" {
196			return false
197		}
198		completed += " "
199		if args != "" {
200			completed += args
201		}
202	}
203	if completed == query {
204		return false
205	}
206
207	m.input.SetValue(completed)
208	m.selected, m.scroll, m.selectionMade = 0, 0, false
209	m.refresh()
210	return true
211}
212
213// completionParts splits the first token at Unicode whitespace and returns
214// the argument beginning at its first non-whitespace byte.
215func completionParts(query string) (token, args string, hasSeparator bool) {
216	for i, r := range query {
217		if !unicode.IsSpace(r) {
218			continue
219		}
220		for j, argumentRune := range query[i:] {
221			if !unicode.IsSpace(argumentRune) {
222				return query[:i], query[i+j:], true
223			}
224		}
225		return query[:i], "", true
226	}
227	return query, "", false
228}
229
230// ArgumentCommand returns the exact argument-taking static command being entered.
231func (m *Model) ArgumentCommand() (command.Command, bool) {
232	if m == nil {
233		return command.Command{}, false
234	}
235	return ArgumentCommand(m.results, m.Query())
236}
237
238func (m *Model) Matches() []Match {
239	if m == nil {
240		return nil
241	}
242	out := make([]Match, len(m.matches))
243	for i, match := range m.matches {
244		out[i] = match
245		out[i].Positions = append([]int(nil), match.Positions...)
246	}
247	return out
248}
249
250func (m *Model) refresh() {
251	query := m.input.Value()
252	wasDestinationMode := m.destinationMode
253	if destinations, ok := m.cnsDestinationMatches(query); ok {
254		m.destinationMode = true
255		if !wasDestinationMode {
256			m.selectionMade = false
257		}
258		m.matches = destinations
259		m.clamp()
260		return
261	}
262	m.destinationMode = false
263	m.selectionMade = true
264	m.matches = Fuzzy(m.results, query)
265	// Keep an exact command row selected over fuzzy session matches.
266	if result, ok := ExactCommandResult(m.results, query); ok {
267		m.prependMatch(result)
268		// Once an argument-taking static token is exact, retain its row while its
269		// arguments make ordinary fuzzy matching inapplicable.
270	} else if result, _, ok := argumentResult(m.results, query); ok {
271		m.prependMatch(result)
272	} else if len(m.matches) == 0 {
273		// Arguments are not part of static command matching. When they would
274		// otherwise clear the list, keep argument-capable candidates matched by
275		// the partial first token so Tab can complete them.
276		token, _, hasSeparator := completionParts(query)
277		if hasSeparator {
278			m.matches = Fuzzy(m.argumentResults, token)
279		}
280	}
281	m.clamp()
282}
283
284func splitResults(results []Result) (ordinary, destinations []Result) {
285	ordinary = make([]Result, 0, len(results))
286	for _, result := range results {
287		if result.Kind() == ResultKindCreateSessionDestination {
288			destinations = append(destinations, result)
289			continue
290		}
291		ordinary = append(ordinary, result)
292	}
293	return ordinary, destinations
294}
295
296func (m *Model) cnsDestinationMatches(query string) ([]Match, bool) {
297	if len(m.createDestinations) < 2 {
298		return nil, false
299	}
300	fields := strings.Fields(query)
301	if len(fields) == 0 || len(fields) > 2 {
302		return nil, false
303	}
304	cmd, ok := ArgumentCommand(m.results, query)
305	if !ok || cmd.Slug != "new-session" {
306		return nil, false
307	}
308	name := ""
309	if len(fields) == 2 {
310		name = fields[1]
311		if domain.ValidateSessionName(name) != nil {
312			return nil, false
313		}
314	}
315	matches := make([]Match, len(m.createDestinations))
316	for i, destination := range m.createDestinations {
317		matches[i] = newMatch(destination.withCreateSessionName(name), 0)
318	}
319	return matches, true
320}
321
322func requiredArgumentResults(results []Result) []Result {
323	commands := make([]Result, 0, len(results))
324	for _, result := range results {
325		cmd, ok := result.Command()
326		if ok && cmd.Arguments != command.ArgumentsNone {
327			commands = append(commands, result)
328		}
329	}
330	return commands
331}
332
333func (m *Model) prependMatch(result Result) {
334	cmd, _ := result.Command()
335	for i, match := range m.matches {
336		candidate, ok := match.Result.Command()
337		if !ok || candidate.Code != cmd.Code {
338			continue
339		}
340		if i > 0 {
341			copy(m.matches[1:i+1], m.matches[:i])
342			m.matches[0] = match
343		}
344		return
345	}
346	m.matches = append([]Match{newMatch(result, 0)}, m.matches...)
347}
348func (m *Model) clamp() {
349	if len(m.matches) == 0 {
350		m.selected, m.scroll = -1, 0
351		return
352	}
353	if m.selected < 0 {
354		m.selected = 0
355	}
356	if m.selected >= len(m.matches) {
357		m.selected = len(m.matches) - 1
358	}
359	if m.scroll < 0 {
360		m.scroll = 0
361	}
362	if m.scroll > m.selected {
363		m.scroll = m.selected
364	}
365}
366
367func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
368	styles := opts.Styles
369	if styles == (RenderStyles{}) {
370		styles = DefaultRenderStyles()
371	}
372	frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
373	if frame.Width == 0 || frame.Height == 0 {
374		return frame
375	}
376	base, row, selection, desc := styles.Base, styles.Row, styles.Selection, styles.Description
377	selectionDesc := styles.SelectionDescription
378	if selectionDesc == (renderer.Style{}) {
379		selectionDesc = mergePaletteDescStyle(selection, desc)
380	}
381	ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
382	if m == nil {
383		ui.DrawInputLine(frame, 0, paletteInputPrefix, "", base)
384		return frame
385	}
386	ui.DrawInputLine(frame, 0, paletteInputPrefix, m.Query(), base)
387	start, visible := 1, frame.Height-1
388	if visible <= 0 || len(m.matches) == 0 {
389		return frame
390	}
391	if m.selected < m.scroll {
392		m.scroll = m.selected
393	}
394	if m.selected >= m.scroll+visible {
395		m.scroll = m.selected - visible + 1
396	}
397	codeWidth := 0
398	for _, match := range m.matches {
399		if cmd, ok := match.Result.Command(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {
400			codeWidth = utf8.RuneCountInString(cmd.Code)
401		}
402	}
403	activeCmd, activeOK := m.ArgumentCommand()
404	for y := range visible {
405		idx := m.scroll + y
406		if idx >= len(m.matches) {
407			break
408		}
409		match := m.matches[idx]
410		style := row
411		rowSelected := idx == m.selected && m.selectionMade
412		if rowSelected {
413			style = selection
414		}
415		ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
416		if cmd, ok := match.Result.Command(); ok {
417			m.renderCommand(frame, y+start, style, selection, desc, selectionDesc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Preview, opts.Feedback, rowSelected)
418			continue
419		}
420		showFeedback := rowSelected || !m.selectionMade && idx == 0
421		m.renderSession(frame, y+start, style, selection, match, opts.Feedback, showFeedback)
422	}
423	return frame
424}
425
426func (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) {
427	x, nextHighlight := 0, 0
428	for _, r := range cmd.Code {
429		cellStyle := style
430		cellStyle.Bold = true
431		if nextHighlight < len(positions) && positions[nextHighlight] == x {
432			cellStyle = selection
433			cellStyle.Bold = true
434			nextHighlight++
435		}
436		if x < frame.Width {
437			frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
438		}
439		x++
440	}
441	for x < codeWidth+1 && x < frame.Width {
442		frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
443		x++
444	}
445	description := cmd.Desc
446	if feedback != "" && selected {
447		description = feedback
448	} else if preview != "" && selected {
449		description = preview
450	} else if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
451		description = guidance
452	}
453	descriptionStyle := mergePaletteDescStyle(style, desc)
454	if selected {
455		descriptionStyle = selectionDesc
456	}
457	ui.DrawText(frame, x, y, frame.Width, description, descriptionStyle)
458}
459
460func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
461	x, nextHighlight := 0, 0
462	for _, r := range match.Result.DisplayText() {
463		cellStyle := style
464		if nextHighlight < len(match.Positions) && match.Positions[nextHighlight] == x {
465			cellStyle = selection
466			cellStyle.Bold = true
467			nextHighlight++
468		}
469		if x < frame.Width {
470			frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
471		}
472		x++
473	}
474	if feedback == "" || !selected {
475		return
476	}
477	if x < frame.Width {
478		frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
479	}
480	x++
481	for _, r := range feedback {
482		if x < frame.Width {
483			frame.Set(x, y, renderer.Cell{Rune: r, Style: style})
484		}
485		x++
486	}
487}
488
489func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style {
490	out := line
491	out.Italic = desc.Italic
492	if desc.HasForegroundRGB {
493		out.HasForegroundRGB = true
494		out.ForegroundRGB = desc.ForegroundRGB
495		out.Foreground = -1
496	} else if desc.Foreground >= 0 {
497		out.HasForegroundRGB = false
498		out.Foreground = desc.Foreground
499	}
500	return out
501}

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