detached 01f225a

refactor(palette): use concrete result values

(cherry picked from commit 847c0a0c6eafd3acdb0dc62ff2db952147d92e79)
commit
01f225a51bf442d108f35e719743bef3f3def316
author
brice · · 2026-07-15 07:08 UTC
committer
brice · · 2026-07-15 07:47 UTC
parent
b8602e4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +136 -120
M internal/usecase/palette/action.go +12 -5
@@ -22,7 +22,7 @@ func ParseAction(results []Result, input string) (Action, bool) {
2222 return Action{}, false
2323 }
2424 for _, result := range results {
25- cmd, ok := result.CommandInfo()
25+ cmd, ok := result.Command()
2626 if !ok || !strings.EqualFold(fields[0], cmd.Code) {
2727 continue
2828 }
@@ -45,15 +45,22 @@ func ParseAction(results []Result, input string) (Action, bool) {
4545 // ArgumentCommand returns the exact argument-taking command whose token is
4646 // being typed, including the whitespace-before-argument state.
4747 func ArgumentCommand(results []Result, input string) (command.Command, bool) {
48+ _, cmd, ok := argumentResult(results, input)
49+ return cmd, ok
50+}
51+
52+// argumentResult finds the exact argument-taking command and its source row in
53+// one pass so callers can reuse the immutable result without another scan.
54+func argumentResult(results []Result, input string) (Result, command.Command, bool) {
4855 fields := strings.Fields(input)
4956 if len(fields) == 0 {
50- return command.Command{}, false
57+ return Result{}, command.Command{}, false
5158 }
5259 for _, result := range results {
53- cmd, ok := result.CommandInfo()
60+ cmd, ok := result.Command()
5461 if ok && cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {
55- return cmd, true
62+ return result, cmd, true
5663 }
5764 }
58- return command.Command{}, false
65+ return Result{}, command.Command{}, false
5966 }
@@ -22,7 +22,7 @@ func ParseAction(results []Result, input string) (Action, bool) {
22 return Action{}, false22 return Action{}, false
23 }23 }
24 for _, result := range results {24 for _, result := range results {
25- cmd, ok := result.CommandInfo()25+ cmd, ok := result.Command()
26 if !ok || !strings.EqualFold(fields[0], cmd.Code) {26 if !ok || !strings.EqualFold(fields[0], cmd.Code) {
27 continue27 continue
28 }28 }
@@ -45,15 +45,22 @@ func ParseAction(results []Result, input string) (Action, bool) {
45 // ArgumentCommand returns the exact argument-taking command whose token is45 // ArgumentCommand returns the exact argument-taking command whose token is
46 // being typed, including the whitespace-before-argument state.46 // being typed, including the whitespace-before-argument state.
47 func ArgumentCommand(results []Result, input string) (command.Command, bool) {47 func ArgumentCommand(results []Result, input string) (command.Command, bool) {
48+ _, cmd, ok := argumentResult(results, input)
49+ return cmd, ok
50+}
51+
52+// argumentResult finds the exact argument-taking command and its source row in
53+// one pass so callers can reuse the immutable result without another scan.
54+func argumentResult(results []Result, input string) (Result, command.Command, bool) {
48 fields := strings.Fields(input)55 fields := strings.Fields(input)
49 if len(fields) == 0 {56 if len(fields) == 0 {
50- return command.Command{}, false57+ return Result{}, command.Command{}, false
51 }58 }
52 for _, result := range results {59 for _, result := range results {
53- cmd, ok := result.CommandInfo()60+ cmd, ok := result.Command()
54 if ok && cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {61 if ok && cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {
55- return cmd, true62+ return result, cmd, true
56 }63 }
57 }64 }
58- return command.Command{}, false65+ return Result{}, command.Command{}, false
59 }66 }
M internal/usecase/palette/action_test.go +12 -0
@@ -2,6 +2,7 @@ package palette
22
33 import (
44 "testing"
5+ "time"
56
67 "github.com/bnema/vev/internal/usecase/command"
78 )
@@ -48,6 +49,17 @@ func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {
4849 }
4950 }
5051
52+func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) {
53+ jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
54+ results := []Result{NewStoppedSessionResult("JRS", time.Time{}), NewCommandResult(jrs)}
55+
56+ result, got, ok := argumentResult(results, "jrs 1")
57+
58+ if !ok || result.Kind() != ResultKindCommand || got.Code != jrs.Code {
59+ t.Fatalf("argumentResult() = %#v, %#v, %v; want matching JRS row and command", result, got, ok)
60+ }
61+}
62+
5163 func sameStrings(a, b []string) bool {
5264 if len(a) != len(b) {
5365 return false
@@ -2,6 +2,7 @@ package palette
2 2
3 import (3 import (
4 "testing"4 "testing"
5+ "time"
5 6
6 "github.com/bnema/vev/internal/usecase/command"7 "github.com/bnema/vev/internal/usecase/command"
7 )8 )
@@ -48,6 +49,17 @@ func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {
48 }49 }
49 }50 }
50 51
52+func TestArgumentResultReturnsTheMatchingRowAndCommand(t *testing.T) {
53+ jrs := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
54+ results := []Result{NewStoppedSessionResult("JRS", time.Time{}), NewCommandResult(jrs)}
55+
56+ result, got, ok := argumentResult(results, "jrs 1")
57+
58+ if !ok || result.Kind() != ResultKindCommand || got.Code != jrs.Code {
59+ t.Fatalf("argumentResult() = %#v, %#v, %v; want matching JRS row and command", result, got, ok)
60+ }
61+}
62+
51 func sameStrings(a, b []string) bool {63 func sameStrings(a, b []string) bool {
52 if len(a) != len(b) {64 if len(a) != len(b) {
53 return false65 return false
M internal/usecase/palette/fuzzy.go +3 -12
@@ -17,19 +17,13 @@ type Match struct {
1717 search string
1818 }
1919
20-// Fuzzy searches typed immutable palette results.
20+// Fuzzy searches immutable palette results.
2121 func Fuzzy(results []Result, query string) []Match {
22- return fuzzyResults(results, query)
23-}
24-
25-func fuzzyResults(results []Result, query string) []Match {
2622 needle := strings.ToLower(query)
2723 if query == "" {
2824 out := make([]Match, 0, len(results))
2925 for i, result := range results {
30- if result != nil {
31- out = append(out, newMatch(result, i))
32- }
26+ out = append(out, newMatch(result, i))
3327 }
3428 return out
3529 }
@@ -37,9 +31,6 @@ func fuzzyResults(results []Result, query string) []Match {
3731 needleRunes := []rune(needle)
3832 var out []Match
3933 for i, result := range results {
40- if result == nil {
41- continue
42- }
4334 if match, ok := score(result, needle, needleRunes, i); ok {
4435 out = append(out, match)
4536 }
@@ -92,7 +83,7 @@ func score(result Result, needle string, needleRunes []rune, order int) (Match,
9283 match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0]
9384 return match, true
9485 }
95- if cmd, ok := result.CommandInfo(); ok {
86+ if cmd, ok := result.Command(); ok {
9687 if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {
9788 match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0]
9889 return match, true
@@ -17,19 +17,13 @@ type Match struct {
17 search string17 search string
18 }18 }
19 19
20-// Fuzzy searches typed immutable palette results.20+// Fuzzy searches immutable palette results.
21 func Fuzzy(results []Result, query string) []Match {21 func Fuzzy(results []Result, query string) []Match {
22- return fuzzyResults(results, query)
23-}
24-
25-func fuzzyResults(results []Result, query string) []Match {
26 needle := strings.ToLower(query)22 needle := strings.ToLower(query)
27 if query == "" {23 if query == "" {
28 out := make([]Match, 0, len(results))24 out := make([]Match, 0, len(results))
29 for i, result := range results {25 for i, result := range results {
30- if result != nil {26+ out = append(out, newMatch(result, i))
31- out = append(out, newMatch(result, i))
32- }
33 }27 }
34 return out28 return out
35 }29 }
@@ -37,9 +31,6 @@ func fuzzyResults(results []Result, query string) []Match {
37 needleRunes := []rune(needle)31 needleRunes := []rune(needle)
38 var out []Match32 var out []Match
39 for i, result := range results {33 for i, result := range results {
40- if result == nil {
41- continue
42- }
43 if match, ok := score(result, needle, needleRunes, i); ok {34 if match, ok := score(result, needle, needleRunes, i); ok {
44 out = append(out, match)35 out = append(out, match)
45 }36 }
@@ -92,7 +83,7 @@ func score(result Result, needle string, needleRunes []rune, order int) (Match,
92 match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0]83 match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0]
93 return match, true84 return match, true
94 }85 }
95- if cmd, ok := result.CommandInfo(); ok {86+ if cmd, ok := result.Command(); ok {
96 if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {87 if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {
97 match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0]88 match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0]
98 return match, true89 return match, true
M internal/usecase/palette/fuzzy_test.go +8 -25
@@ -16,7 +16,7 @@ func cmd(code, name, desc string) command.Command {
1616 func codes(matches []Match) []string {
1717 out := make([]string, len(matches))
1818 for i, match := range matches {
19- cmd, ok := match.Result.CommandInfo()
19+ cmd, ok := match.Result.Command()
2020 if !ok {
2121 continue
2222 }
@@ -102,32 +102,15 @@ func TestFuzzyMixedResultsPrioritizeCommandShortcodeAndRankSessions(t *testing.T
102102 require.Empty(t, matches[4].Positions)
103103 }
104104
105-func TestFuzzyCachesNormalizedSearchTextPerMatch(t *testing.T) {
106- calls := 0
107- results := []Result{
108- countingResult{text: "aBravo", calls: &calls},
109- countingResult{text: "aAlpha", calls: &calls},
110- }
111-
112- matches := Fuzzy(results, "a")
113-
114- require.Len(t, matches, 2)
115- require.Equal(t, 2, calls, "sorting must use the normalized text cached in Match")
116-}
117-
118-type countingResult struct {
119- text string
120- calls *int
121-}
105+func TestFuzzySortsEquivalentSessionMatchesByNormalizedText(t *testing.T) {
106+ created := time.Time{}
107+ matches := Fuzzy([]Result{
108+ NewStoppedSessionResult("aBravo", created),
109+ NewStoppedSessionResult("aAlpha", created),
110+ }, "a")
122111
123-func (r countingResult) Kind() ResultKind { return ResultKindStoppedSession }
124-func (r countingResult) DisplayText() string { return r.text }
125-func (r countingResult) SearchText() string {
126- (*r.calls)++
127- return r.text
112+ require.Equal(t, []string{"aAlpha", "aBravo"}, matchSearchText(matches))
128113 }
129-func (r countingResult) CommandInfo() (command.Command, bool) { return command.Command{}, false }
130-func (r countingResult) Session() (SessionResult, bool) { return SessionResult{}, false }
131114
132115 func TestFuzzyMixedResultsUsesKindThenNormalizedTextThenStableOrder(t *testing.T) {
133116 created := time.Time{}
@@ -16,7 +16,7 @@ func cmd(code, name, desc string) command.Command {
16 func codes(matches []Match) []string {16 func codes(matches []Match) []string {
17 out := make([]string, len(matches))17 out := make([]string, len(matches))
18 for i, match := range matches {18 for i, match := range matches {
19- cmd, ok := match.Result.CommandInfo()19+ cmd, ok := match.Result.Command()
20 if !ok {20 if !ok {
21 continue21 continue
22 }22 }
@@ -102,32 +102,15 @@ func TestFuzzyMixedResultsPrioritizeCommandShortcodeAndRankSessions(t *testing.T
102 require.Empty(t, matches[4].Positions)102 require.Empty(t, matches[4].Positions)
103 }103 }
104 104
105-func TestFuzzyCachesNormalizedSearchTextPerMatch(t *testing.T) {105+func TestFuzzySortsEquivalentSessionMatchesByNormalizedText(t *testing.T) {
106- calls := 0106+ created := time.Time{}
107- results := []Result{107+ matches := Fuzzy([]Result{
108- countingResult{text: "aBravo", calls: &calls},108+ NewStoppedSessionResult("aBravo", created),
109- countingResult{text: "aAlpha", calls: &calls},109+ NewStoppedSessionResult("aAlpha", created),
110- }110+ }, "a")
111-
112- matches := Fuzzy(results, "a")
113-
114- require.Len(t, matches, 2)
115- require.Equal(t, 2, calls, "sorting must use the normalized text cached in Match")
116-}
117-
118-type countingResult struct {
119- text string
120- calls *int
121-}
122 111
123-func (r countingResult) Kind() ResultKind { return ResultKindStoppedSession }112+ require.Equal(t, []string{"aAlpha", "aBravo"}, matchSearchText(matches))
124-func (r countingResult) DisplayText() string { return r.text }
125-func (r countingResult) SearchText() string {
126- (*r.calls)++
127- return r.text
128 }113 }
129-func (r countingResult) CommandInfo() (command.Command, bool) { return command.Command{}, false }
130-func (r countingResult) Session() (SessionResult, bool) { return SessionResult{}, false }
131 114
132 func TestFuzzyMixedResultsUsesKindThenNormalizedTextThenStableOrder(t *testing.T) {115 func TestFuzzyMixedResultsUsesKindThenNormalizedTextThenStableOrder(t *testing.T) {
133 created := time.Time{}116 created := time.Time{}
M internal/usecase/palette/model.go +27 -29
@@ -27,16 +27,21 @@ type RenderOptions struct {
2727 }
2828
2929 type Model struct {
30- results []Result
31- input ui.TextInput
32- matches []Match
33- selected int
34- scroll int
30+ results []Result
31+ argumentResults []Result
32+ input ui.TextInput
33+ matches []Match
34+ selected int
35+ scroll int
3536 }
3637
3738 // New accepts typed immutable palette results.
3839 func New(results []Result) *Model {
39- m := &Model{results: append([]Result(nil), results...)}
40+ copied := append([]Result(nil), results...)
41+ m := &Model{
42+ results: copied,
43+ argumentResults: requiredArgumentResults(copied),
44+ }
4045 m.refresh()
4146 return m
4247 }
@@ -88,7 +93,7 @@ func (m *Model) Down() {
8893 // Selected returns the typed immutable target, not an executable command.
8994 func (m *Model) Selected() (Result, bool) {
9095 if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
91- return nil, false
96+ return Result{}, false
9297 }
9398 return m.matches[m.selected].Result, true
9499 }
@@ -100,7 +105,7 @@ func (m *Model) CompleteSelected() bool {
100105 if !ok {
101106 return false
102107 }
103- cmd, ok := selected.CommandInfo()
108+ cmd, ok := selected.Command()
104109 if !ok {
105110 return false
106111 }
@@ -166,10 +171,10 @@ func (m *Model) Matches() []Match {
166171
167172 func (m *Model) refresh() {
168173 query := m.input.Value()
169- m.matches = fuzzyResults(m.results, query)
174+ m.matches = Fuzzy(m.results, query)
170175 // Once an argument-taking static token is exact, retain its row while its
171176 // arguments make ordinary fuzzy matching inapplicable.
172- if result, ok := m.argumentResult(query); ok {
177+ if result, _, ok := argumentResult(m.results, query); ok {
173178 m.prependMatch(result)
174179 } else if len(m.matches) == 0 {
175180 // Arguments are not part of static command matching. When they would
@@ -177,34 +182,27 @@ func (m *Model) refresh() {
177182 // the partial first token so Tab can complete them.
178183 token, _, hasSeparator := completionParts(query)
179184 if hasSeparator {
180- for _, match := range fuzzyResults(m.results, token) {
181- if cmd, ok := match.Result.CommandInfo(); ok && cmd.Arguments == command.ArgumentsRequired {
182- m.matches = append(m.matches, match)
183- }
184- }
185+ m.matches = Fuzzy(m.argumentResults, token)
185186 }
186187 }
187188 m.clamp()
188189 }
189190
190-func (m *Model) argumentResult(input string) (Result, bool) {
191- cmd, ok := ArgumentCommand(m.results, input)
192- if !ok {
193- return nil, false
194- }
195- for _, result := range m.results {
196- candidate, ok := result.CommandInfo()
197- if ok && candidate.Code == cmd.Code {
198- return result, true
191+func requiredArgumentResults(results []Result) []Result {
192+ commands := make([]Result, 0, len(results))
193+ for _, result := range results {
194+ cmd, ok := result.Command()
195+ if ok && cmd.Arguments == command.ArgumentsRequired {
196+ commands = append(commands, result)
199197 }
200198 }
201- return nil, false
199+ return commands
202200 }
203201
204202 func (m *Model) prependMatch(result Result) {
205- cmd, _ := result.CommandInfo()
203+ cmd, _ := result.Command()
206204 for i, match := range m.matches {
207- candidate, ok := match.Result.CommandInfo()
205+ candidate, ok := match.Result.Command()
208206 if !ok || candidate.Code != cmd.Code {
209207 continue
210208 }
@@ -260,7 +258,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
260258 }
261259 codeWidth := 0
262260 for _, match := range m.matches {
263- if cmd, ok := match.Result.CommandInfo(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {
261+ if cmd, ok := match.Result.Command(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {
264262 codeWidth = utf8.RuneCountInString(cmd.Code)
265263 }
266264 }
@@ -276,7 +274,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
276274 style = selection
277275 }
278276 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
279- if cmd, ok := match.Result.CommandInfo(); ok {
277+ if cmd, ok := match.Result.Command(); ok {
280278 m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)
281279 continue
282280 }
@@ -27,16 +27,21 @@ type RenderOptions struct {
27 }27 }
28 28
29 type Model struct {29 type Model struct {
30- results []Result30+ results []Result
31- input ui.TextInput31+ argumentResults []Result
32- matches []Match32+ input ui.TextInput
33- selected int33+ matches []Match
34- scroll int34+ selected int
35+ scroll int
35 }36 }
36 37
37 // New accepts typed immutable palette results.38 // New accepts typed immutable palette results.
38 func New(results []Result) *Model {39 func New(results []Result) *Model {
39- m := &Model{results: append([]Result(nil), results...)}40+ copied := append([]Result(nil), results...)
41+ m := &Model{
42+ results: copied,
43+ argumentResults: requiredArgumentResults(copied),
44+ }
40 m.refresh()45 m.refresh()
41 return m46 return m
42 }47 }
@@ -88,7 +93,7 @@ func (m *Model) Down() {
88 // Selected returns the typed immutable target, not an executable command.93 // Selected returns the typed immutable target, not an executable command.
89 func (m *Model) Selected() (Result, bool) {94 func (m *Model) Selected() (Result, bool) {
90 if m == nil || m.selected < 0 || m.selected >= len(m.matches) {95 if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
91- return nil, false96+ return Result{}, false
92 }97 }
93 return m.matches[m.selected].Result, true98 return m.matches[m.selected].Result, true
94 }99 }
@@ -100,7 +105,7 @@ func (m *Model) CompleteSelected() bool {
100 if !ok {105 if !ok {
101 return false106 return false
102 }107 }
103- cmd, ok := selected.CommandInfo()108+ cmd, ok := selected.Command()
104 if !ok {109 if !ok {
105 return false110 return false
106 }111 }
@@ -166,10 +171,10 @@ func (m *Model) Matches() []Match {
166 171
167 func (m *Model) refresh() {172 func (m *Model) refresh() {
168 query := m.input.Value()173 query := m.input.Value()
169- m.matches = fuzzyResults(m.results, query)174+ m.matches = Fuzzy(m.results, query)
170 // Once an argument-taking static token is exact, retain its row while its175 // Once an argument-taking static token is exact, retain its row while its
171 // arguments make ordinary fuzzy matching inapplicable.176 // arguments make ordinary fuzzy matching inapplicable.
172- if result, ok := m.argumentResult(query); ok {177+ if result, _, ok := argumentResult(m.results, query); ok {
173 m.prependMatch(result)178 m.prependMatch(result)
174 } else if len(m.matches) == 0 {179 } else if len(m.matches) == 0 {
175 // Arguments are not part of static command matching. When they would180 // Arguments are not part of static command matching. When they would
@@ -177,34 +182,27 @@ func (m *Model) refresh() {
177 // the partial first token so Tab can complete them.182 // the partial first token so Tab can complete them.
178 token, _, hasSeparator := completionParts(query)183 token, _, hasSeparator := completionParts(query)
179 if hasSeparator {184 if hasSeparator {
180- for _, match := range fuzzyResults(m.results, token) {185+ m.matches = Fuzzy(m.argumentResults, token)
181- if cmd, ok := match.Result.CommandInfo(); ok && cmd.Arguments == command.ArgumentsRequired {
182- m.matches = append(m.matches, match)
183- }
184- }
185 }186 }
186 }187 }
187 m.clamp()188 m.clamp()
188 }189 }
189 190
190-func (m *Model) argumentResult(input string) (Result, bool) {191+func requiredArgumentResults(results []Result) []Result {
191- cmd, ok := ArgumentCommand(m.results, input)192+ commands := make([]Result, 0, len(results))
192- if !ok {193+ for _, result := range results {
193- return nil, false194+ cmd, ok := result.Command()
194- }195+ if ok && cmd.Arguments == command.ArgumentsRequired {
195- for _, result := range m.results {196+ commands = append(commands, result)
196- candidate, ok := result.CommandInfo()
197- if ok && candidate.Code == cmd.Code {
198- return result, true
199 }197 }
200 }198 }
201- return nil, false199+ return commands
202 }200 }
203 201
204 func (m *Model) prependMatch(result Result) {202 func (m *Model) prependMatch(result Result) {
205- cmd, _ := result.CommandInfo()203+ cmd, _ := result.Command()
206 for i, match := range m.matches {204 for i, match := range m.matches {
207- candidate, ok := match.Result.CommandInfo()205+ candidate, ok := match.Result.Command()
208 if !ok || candidate.Code != cmd.Code {206 if !ok || candidate.Code != cmd.Code {
209 continue207 continue
210 }208 }
@@ -260,7 +258,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
260 }258 }
261 codeWidth := 0259 codeWidth := 0
262 for _, match := range m.matches {260 for _, match := range m.matches {
263- if cmd, ok := match.Result.CommandInfo(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {261+ if cmd, ok := match.Result.Command(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {
264 codeWidth = utf8.RuneCountInString(cmd.Code)262 codeWidth = utf8.RuneCountInString(cmd.Code)
265 }263 }
266 }264 }
@@ -276,7 +274,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
276 style = selection274 style = selection
277 }275 }
278 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})276 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
279- if cmd, ok := match.Result.CommandInfo(); ok {277+ if cmd, ok := match.Result.Command(); ok {
280 m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)278 m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)
281 continue279 continue
282 }280 }
M internal/usecase/palette/model_test.go +3 -4
@@ -182,7 +182,7 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
182182
183183 func selectedCommandCode(t *testing.T, result Result) string {
184184 t.Helper()
185- cmd, ok := result.CommandInfo()
185+ cmd, ok := result.Command()
186186 require.True(t, ok, "selected result is a command")
187187 return cmd.Code
188188 }
@@ -353,9 +353,8 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
353353
354354 selected, ok := m.Selected()
355355 require.True(t, ok)
356- session, ok := selected.Session()
357- require.True(t, ok)
358- id, active := session.SessionID()
356+ require.Equal(t, ResultKindActiveSession, selected.Kind())
357+ id, active := selected.SessionID()
359358 require.True(t, active)
360359 require.Equal(t, domain.SessionID("work-id"), id)
361360 require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
@@ -182,7 +182,7 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
182 182
183 func selectedCommandCode(t *testing.T, result Result) string {183 func selectedCommandCode(t *testing.T, result Result) string {
184 t.Helper()184 t.Helper()
185- cmd, ok := result.CommandInfo()185+ cmd, ok := result.Command()
186 require.True(t, ok, "selected result is a command")186 require.True(t, ok, "selected result is a command")
187 return cmd.Code187 return cmd.Code
188 }188 }
@@ -353,9 +353,8 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
353 353
354 selected, ok := m.Selected()354 selected, ok := m.Selected()
355 require.True(t, ok)355 require.True(t, ok)
356- session, ok := selected.Session()356+ require.Equal(t, ResultKindActiveSession, selected.Kind())
357- require.True(t, ok)357+ id, active := selected.SessionID()
358- id, active := session.SessionID()
359 require.True(t, active)358 require.True(t, active)
360 require.Equal(t, domain.SessionID("work-id"), id)359 require.Equal(t, domain.SessionID("work-id"), id)
361 require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")360 require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
M internal/usecase/palette/result.go +56 -41
@@ -16,65 +16,80 @@ const (
1616 ResultKindStoppedSession
1717 )
1818
19-// Result is a searchable palette target. Command and session accessors make
20-// the target's capabilities explicit without callers needing type assertions.
21-type Result interface {
22- Kind() ResultKind
23- DisplayText() string
24- SearchText() string
25- CommandInfo() (command.Command, bool)
26- Session() (SessionResult, bool)
27-}
28-
29-// CommandResult is a static command palette target.
30-type CommandResult struct {
19+// Result is an immutable palette target. Its kind is the sole discriminator
20+// for its private command or session payload.
21+type Result struct {
22+ kind ResultKind
3123 command command.Command
24+ session sessionPayload
3225 }
3326
34-func NewCommandResult(cmd command.Command) CommandResult { return CommandResult{command: cmd} }
35-func (r CommandResult) Kind() ResultKind { return ResultKindCommand }
36-func (r CommandResult) DisplayText() string { return r.command.Code }
37-func (r CommandResult) SearchText() string { return r.command.Code }
38-func (r CommandResult) Command() command.Command { return r.command }
39-func (r CommandResult) CommandInfo() (command.Command, bool) { return r.command, true }
40-func (r CommandResult) Session() (SessionResult, bool) { return SessionResult{}, false }
41-
42-// SessionResult is an immutable named-session target. An active session has a
43-// SessionID; a stopped session intentionally does not.
44-type SessionResult struct {
27+type sessionPayload struct {
4528 name string
4629 createdAt time.Time
4730 sessionID domain.SessionID
48- active bool
4931 }
5032
51-func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) SessionResult {
52- return SessionResult{name: name, createdAt: createdAt, sessionID: sessionID, active: true}
33+// NewCommandResult creates a static command palette target.
34+func NewCommandResult(cmd command.Command) Result {
35+ return Result{kind: ResultKindCommand, command: cmd}
5336 }
5437
55-func NewStoppedSessionResult(name string, createdAt time.Time) SessionResult {
56- return SessionResult{name: name, createdAt: createdAt}
38+// NewActiveSessionResult creates an immutable active named-session target.
39+func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) Result {
40+ return Result{
41+ kind: ResultKindActiveSession,
42+ session: sessionPayload{name: name, createdAt: createdAt, sessionID: sessionID},
43+ }
5744 }
5845
59-func (r SessionResult) Kind() ResultKind {
60- if r.active {
61- return ResultKindActiveSession
46+// NewStoppedSessionResult creates an immutable stopped named-session target.
47+func NewStoppedSessionResult(name string, createdAt time.Time) Result {
48+ return Result{
49+ kind: ResultKindStoppedSession,
50+ session: sessionPayload{name: name, createdAt: createdAt},
6251 }
63- return ResultKindStoppedSession
6452 }
65-func (r SessionResult) Name() string { return r.name }
66-func (r SessionResult) CreatedAt() time.Time { return r.createdAt }
67-func (r SessionResult) SessionID() (domain.SessionID, bool) {
68- return r.sessionID, r.active
53+
54+func (r Result) Kind() ResultKind { return r.kind }
55+
56+func (r Result) DisplayText() string {
57+ if r.kind == ResultKindCommand {
58+ return r.command.Code
59+ }
60+ return sessionDisplayPrefix + r.session.name
61+}
62+
63+func (r Result) SearchText() string {
64+ if r.kind == ResultKindCommand {
65+ return r.command.Code
66+ }
67+ return r.session.name
68+}
69+
70+// Command returns the command payload only for command results.
71+func (r Result) Command() (command.Command, bool) {
72+ return r.command, r.kind == ResultKindCommand
73+}
74+
75+// SessionName returns the session name only for session results.
76+func (r Result) SessionName() (string, bool) {
77+ return r.session.name, r.kind != ResultKindCommand
78+}
79+
80+// SessionCreatedAt returns the session creation time only for session results.
81+func (r Result) SessionCreatedAt() (time.Time, bool) {
82+ return r.session.createdAt, r.kind != ResultKindCommand
83+}
84+
85+// SessionID returns the lifecycle identity only for active session results.
86+func (r Result) SessionID() (domain.SessionID, bool) {
87+ return r.session.sessionID, r.kind == ResultKindActiveSession
6988 }
70-func (r SessionResult) DisplayText() string { return sessionDisplayPrefix + r.name }
71-func (r SessionResult) SearchText() string { return r.name }
72-func (r SessionResult) CommandInfo() (command.Command, bool) { return command.Command{}, false }
73-func (r SessionResult) Session() (SessionResult, bool) { return r, true }
7489
7590 const sessionDisplayPrefix = "Switch to session "
7691
77-// CommandResults converts static commands to typed palette targets.
92+// CommandResults converts static commands to immutable palette targets.
7893 func CommandResults(commands []command.Command) []Result {
7994 results := make([]Result, len(commands))
8095 for i, cmd := range commands {
@@ -16,65 +16,80 @@ const (
16 ResultKindStoppedSession16 ResultKindStoppedSession
17 )17 )
18 18
19-// Result is a searchable palette target. Command and session accessors make19+// Result is an immutable palette target. Its kind is the sole discriminator
20-// the target's capabilities explicit without callers needing type assertions.20+// for its private command or session payload.
21-type Result interface {21+type Result struct {
22- Kind() ResultKind22+ kind ResultKind
23- DisplayText() string
24- SearchText() string
25- CommandInfo() (command.Command, bool)
26- Session() (SessionResult, bool)
27-}
28-
29-// CommandResult is a static command palette target.
30-type CommandResult struct {
31 command command.Command23 command command.Command
24+ session sessionPayload
32 }25 }
33 26
34-func NewCommandResult(cmd command.Command) CommandResult { return CommandResult{command: cmd} }27+type sessionPayload struct {
35-func (r CommandResult) Kind() ResultKind { return ResultKindCommand }
36-func (r CommandResult) DisplayText() string { return r.command.Code }
37-func (r CommandResult) SearchText() string { return r.command.Code }
38-func (r CommandResult) Command() command.Command { return r.command }
39-func (r CommandResult) CommandInfo() (command.Command, bool) { return r.command, true }
40-func (r CommandResult) Session() (SessionResult, bool) { return SessionResult{}, false }
41-
42-// SessionResult is an immutable named-session target. An active session has a
43-// SessionID; a stopped session intentionally does not.
44-type SessionResult struct {
45 name string28 name string
46 createdAt time.Time29 createdAt time.Time
47 sessionID domain.SessionID30 sessionID domain.SessionID
48- active bool
49 }31 }
50 32
51-func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) SessionResult {33+// NewCommandResult creates a static command palette target.
52- return SessionResult{name: name, createdAt: createdAt, sessionID: sessionID, active: true}34+func NewCommandResult(cmd command.Command) Result {
35+ return Result{kind: ResultKindCommand, command: cmd}
53 }36 }
54 37
55-func NewStoppedSessionResult(name string, createdAt time.Time) SessionResult {38+// NewActiveSessionResult creates an immutable active named-session target.
56- return SessionResult{name: name, createdAt: createdAt}39+func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) Result {
40+ return Result{
41+ kind: ResultKindActiveSession,
42+ session: sessionPayload{name: name, createdAt: createdAt, sessionID: sessionID},
43+ }
57 }44 }
58 45
59-func (r SessionResult) Kind() ResultKind {46+// NewStoppedSessionResult creates an immutable stopped named-session target.
60- if r.active {47+func NewStoppedSessionResult(name string, createdAt time.Time) Result {
61- return ResultKindActiveSession48+ return Result{
49+ kind: ResultKindStoppedSession,
50+ session: sessionPayload{name: name, createdAt: createdAt},
62 }51 }
63- return ResultKindStoppedSession
64 }52 }
65-func (r SessionResult) Name() string { return r.name }53+
66-func (r SessionResult) CreatedAt() time.Time { return r.createdAt }54+func (r Result) Kind() ResultKind { return r.kind }
67-func (r SessionResult) SessionID() (domain.SessionID, bool) {55+
68- return r.sessionID, r.active56+func (r Result) DisplayText() string {
57+ if r.kind == ResultKindCommand {
58+ return r.command.Code
59+ }
60+ return sessionDisplayPrefix + r.session.name
61+}
62+
63+func (r Result) SearchText() string {
64+ if r.kind == ResultKindCommand {
65+ return r.command.Code
66+ }
67+ return r.session.name
68+}
69+
70+// Command returns the command payload only for command results.
71+func (r Result) Command() (command.Command, bool) {
72+ return r.command, r.kind == ResultKindCommand
73+}
74+
75+// SessionName returns the session name only for session results.
76+func (r Result) SessionName() (string, bool) {
77+ return r.session.name, r.kind != ResultKindCommand
78+}
79+
80+// SessionCreatedAt returns the session creation time only for session results.
81+func (r Result) SessionCreatedAt() (time.Time, bool) {
82+ return r.session.createdAt, r.kind != ResultKindCommand
83+}
84+
85+// SessionID returns the lifecycle identity only for active session results.
86+func (r Result) SessionID() (domain.SessionID, bool) {
87+ return r.session.sessionID, r.kind == ResultKindActiveSession
69 }88 }
70-func (r SessionResult) DisplayText() string { return sessionDisplayPrefix + r.name }
71-func (r SessionResult) SearchText() string { return r.name }
72-func (r SessionResult) CommandInfo() (command.Command, bool) { return command.Command{}, false }
73-func (r SessionResult) Session() (SessionResult, bool) { return r, true }
74 89
75 const sessionDisplayPrefix = "Switch to session "90 const sessionDisplayPrefix = "Switch to session "
76 91
77-// CommandResults converts static commands to typed palette targets.92+// CommandResults converts static commands to immutable palette targets.
78 func CommandResults(commands []command.Command) []Result {93 func CommandResults(commands []command.Command) []Result {
79 results := make([]Result, len(commands))94 results := make([]Result, len(commands))
80 for i, cmd := range commands {95 for i, cmd := range commands {
M internal/usecase/palette/result_test.go +15 -4
@@ -18,10 +18,17 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
1818 require.Equal(t, ResultKindCommand, commandResult.Kind())
1919 require.Equal(t, "NT", commandResult.DisplayText())
2020 require.Equal(t, "NT", commandResult.SearchText())
21+ commandInfo, ok := commandResult.Command()
22+ require.True(t, ok)
23+ require.Equal(t, "NT", commandInfo.Code)
2124
2225 require.Equal(t, ResultKindActiveSession, active.Kind())
23- require.Equal(t, "work", active.Name())
24- require.Equal(t, created, active.CreatedAt())
26+ name, ok := active.SessionName()
27+ require.True(t, ok)
28+ require.Equal(t, "work", name)
29+ createdAt, ok := active.SessionCreatedAt()
30+ require.True(t, ok)
31+ require.Equal(t, created, createdAt)
2532 require.Equal(t, "Switch to session work", active.DisplayText())
2633 require.Equal(t, "work", active.SearchText())
2734 id, ok := active.SessionID()
@@ -29,8 +36,12 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
2936 require.Equal(t, domain.SessionID("session-work"), id)
3037
3138 require.Equal(t, ResultKindStoppedSession, stopped.Kind())
32- require.Equal(t, "archive", stopped.Name())
33- require.Equal(t, created, stopped.CreatedAt())
39+ name, ok = stopped.SessionName()
40+ require.True(t, ok)
41+ require.Equal(t, "archive", name)
42+ createdAt, ok = stopped.SessionCreatedAt()
43+ require.True(t, ok)
44+ require.Equal(t, created, createdAt)
3445 _, ok = stopped.SessionID()
3546 require.False(t, ok)
3647 }
@@ -18,10 +18,17 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
18 require.Equal(t, ResultKindCommand, commandResult.Kind())18 require.Equal(t, ResultKindCommand, commandResult.Kind())
19 require.Equal(t, "NT", commandResult.DisplayText())19 require.Equal(t, "NT", commandResult.DisplayText())
20 require.Equal(t, "NT", commandResult.SearchText())20 require.Equal(t, "NT", commandResult.SearchText())
21+ commandInfo, ok := commandResult.Command()
22+ require.True(t, ok)
23+ require.Equal(t, "NT", commandInfo.Code)
21 24
22 require.Equal(t, ResultKindActiveSession, active.Kind())25 require.Equal(t, ResultKindActiveSession, active.Kind())
23- require.Equal(t, "work", active.Name())26+ name, ok := active.SessionName()
24- require.Equal(t, created, active.CreatedAt())27+ require.True(t, ok)
28+ require.Equal(t, "work", name)
29+ createdAt, ok := active.SessionCreatedAt()
30+ require.True(t, ok)
31+ require.Equal(t, created, createdAt)
25 require.Equal(t, "Switch to session work", active.DisplayText())32 require.Equal(t, "Switch to session work", active.DisplayText())
26 require.Equal(t, "work", active.SearchText())33 require.Equal(t, "work", active.SearchText())
27 id, ok := active.SessionID()34 id, ok := active.SessionID()
@@ -29,8 +36,12 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
29 require.Equal(t, domain.SessionID("session-work"), id)36 require.Equal(t, domain.SessionID("session-work"), id)
30 37
31 require.Equal(t, ResultKindStoppedSession, stopped.Kind())38 require.Equal(t, ResultKindStoppedSession, stopped.Kind())
32- require.Equal(t, "archive", stopped.Name())39+ name, ok = stopped.SessionName()
33- require.Equal(t, created, stopped.CreatedAt())40+ require.True(t, ok)
41+ require.Equal(t, "archive", name)
42+ createdAt, ok = stopped.SessionCreatedAt()
43+ require.True(t, ok)
44+ require.Equal(t, created, createdAt)
34 _, ok = stopped.SessionID()45 _, ok = stopped.SessionID()
35 require.False(t, ok)46 require.False(t, ok)
36 }47 }

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