forked from Gitlink/gitlink-cli
306 lines
7.5 KiB
Go
306 lines
7.5 KiB
Go
package interactive
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/charmbracelet/bubbles/list"
|
||
"github.com/charmbracelet/bubbles/textinput"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
|
||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||
)
|
||
|
||
// paletteLevel tracks which level of the palette we are browsing.
|
||
type paletteLevel int
|
||
|
||
const (
|
||
levelGroup paletteLevel = iota // browsing command groups
|
||
levelCommand // browsing commands within a group
|
||
)
|
||
|
||
// PaletteResult holds the user's selection from the palette.
|
||
type PaletteResult struct {
|
||
Group string
|
||
Shortcut *common.Shortcut
|
||
}
|
||
|
||
// commandItem implements list.Item and list.DefaultItem.
|
||
type commandItem struct {
|
||
title string
|
||
description string
|
||
shortcut *common.Shortcut // nil for group items
|
||
group string // empty for group items
|
||
}
|
||
|
||
func (i commandItem) Title() string { return i.title }
|
||
func (i commandItem) Description() string { return i.description }
|
||
func (i commandItem) FilterValue() string { return i.title + " " + i.description }
|
||
|
||
// paletteModel is the command palette sub-component.
|
||
type paletteModel struct {
|
||
level paletteLevel
|
||
search textinput.Model
|
||
list list.Model
|
||
groups map[string][]*common.Shortcut
|
||
descs map[string]string
|
||
groupKeys []string
|
||
selected string // currently selected group name (when levelCommand)
|
||
result *PaletteResult
|
||
quitting bool
|
||
width int
|
||
height int
|
||
}
|
||
|
||
var paletteTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||
var selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
|
||
var paletteHelpStyle = lipgloss.NewStyle().Faint(true)
|
||
|
||
// newPaletteModel creates a new palette model.
|
||
func newPaletteModel(groups map[string][]*common.Shortcut, descs map[string]string, width, height int) paletteModel {
|
||
search := textinput.New()
|
||
search.Prompt = "> "
|
||
search.Placeholder = "Search..."
|
||
search.Focus()
|
||
search.Width = width - 4
|
||
|
||
// Sort group keys for deterministic ordering
|
||
keys := make([]string, 0, len(groups))
|
||
for k := range groups {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
|
||
// Build group items
|
||
items := make([]list.Item, 0, len(keys))
|
||
for _, k := range keys {
|
||
desc := descs[k]
|
||
items = append(items, commandItem{
|
||
title: k,
|
||
description: desc,
|
||
})
|
||
}
|
||
|
||
delegate := list.NewDefaultDelegate()
|
||
l := list.New(items, delegate, width, height-4)
|
||
l.SetShowTitle(false)
|
||
l.SetShowStatusBar(false)
|
||
l.SetShowHelp(false)
|
||
l.SetShowFilter(false)
|
||
l.SetFilteringEnabled(false)
|
||
|
||
return paletteModel{
|
||
level: levelGroup,
|
||
search: search,
|
||
list: l,
|
||
groups: groups,
|
||
descs: descs,
|
||
groupKeys: keys,
|
||
width: width,
|
||
height: height,
|
||
}
|
||
}
|
||
|
||
// switchToCommands switches the list to show commands for the given group.
|
||
func (m *paletteModel) switchToCommands(group string) {
|
||
m.selected = group
|
||
m.level = levelCommand
|
||
|
||
shortcuts := m.groups[group]
|
||
items := make([]list.Item, 0, len(shortcuts))
|
||
for _, s := range shortcuts {
|
||
items = append(items, commandItem{
|
||
title: "+" + s.Name,
|
||
description: s.Description,
|
||
shortcut: s,
|
||
group: group,
|
||
})
|
||
}
|
||
m.list.SetItems(items)
|
||
m.list.ResetSelected()
|
||
m.search.SetValue("")
|
||
}
|
||
|
||
// backToGroups returns to the group list.
|
||
func (m *paletteModel) backToGroups() {
|
||
m.level = levelGroup
|
||
m.selected = ""
|
||
|
||
items := make([]list.Item, 0, len(m.groupKeys))
|
||
for _, k := range m.groupKeys {
|
||
items = append(items, commandItem{
|
||
title: k,
|
||
description: m.descs[k],
|
||
})
|
||
}
|
||
m.list.SetItems(items)
|
||
m.list.ResetSelected()
|
||
m.search.SetValue("")
|
||
}
|
||
|
||
// filterItems filters the current list items by the given query.
|
||
func (m *paletteModel) filterItems(query string) {
|
||
var items []list.Item
|
||
|
||
if m.level == levelGroup {
|
||
for _, k := range m.groupKeys {
|
||
desc := m.descs[k]
|
||
if matchesQuery(k, desc, query) {
|
||
items = append(items, commandItem{
|
||
title: k,
|
||
description: desc,
|
||
})
|
||
}
|
||
}
|
||
} else {
|
||
shortcuts := m.groups[m.selected]
|
||
for _, s := range shortcuts {
|
||
name := "+" + s.Name
|
||
if matchesQuery(name, s.Description, query) {
|
||
items = append(items, commandItem{
|
||
title: name,
|
||
description: s.Description,
|
||
shortcut: s,
|
||
group: m.selected,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
if items == nil {
|
||
items = []list.Item{}
|
||
}
|
||
m.list.SetItems(items)
|
||
m.list.ResetSelected()
|
||
}
|
||
|
||
// matchesQuery checks if the query is a subsequence (fzy-style fuzzy match)
|
||
// of either the title or the description, case-insensitively. An empty query
|
||
// matches everything.
|
||
func matchesQuery(title, description, query string) bool {
|
||
return subsequenceMatch(query, title) || subsequenceMatch(query, description)
|
||
}
|
||
|
||
// subsequenceMatch returns true if every character of query appears in target
|
||
// in the same order (not necessarily contiguously), ignoring case. An empty
|
||
// query always matches.
|
||
func subsequenceMatch(query, target string) bool {
|
||
query = strings.ToLower(query)
|
||
target = strings.ToLower(target)
|
||
if query == "" {
|
||
return true
|
||
}
|
||
i := 0
|
||
for j := 0; j < len(target) && i < len(query); j++ {
|
||
if target[j] == query[i] {
|
||
i++
|
||
}
|
||
}
|
||
return i == len(query)
|
||
}
|
||
|
||
// Update handles messages for the palette.
|
||
func (m paletteModel) Update(msg tea.Msg) (paletteModel, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.KeyMsg:
|
||
switch msg.Type {
|
||
case tea.KeyEsc:
|
||
if m.level == levelCommand {
|
||
m.backToGroups()
|
||
return m, nil
|
||
}
|
||
// levelGroup: signal the outer REPL to return to stateInput, not quit
|
||
m.quitting = true
|
||
return m, nil
|
||
|
||
case tea.KeyEnter:
|
||
selected := m.list.SelectedItem()
|
||
if selected == nil {
|
||
return m, nil
|
||
}
|
||
item := selected.(commandItem)
|
||
if m.level == levelGroup {
|
||
m.switchToCommands(item.title)
|
||
return m, nil
|
||
}
|
||
// levelCommand: set result
|
||
m.result = &PaletteResult{
|
||
Group: m.selected,
|
||
Shortcut: item.shortcut,
|
||
}
|
||
return m, nil
|
||
|
||
case tea.KeyUp, tea.KeyDown:
|
||
var cmd tea.Cmd
|
||
m.list, cmd = m.list.Update(msg)
|
||
return m, cmd
|
||
|
||
default:
|
||
// Forward to search input
|
||
var cmd tea.Cmd
|
||
m.search, cmd = m.search.Update(msg)
|
||
m.filterItems(m.search.Value())
|
||
return m, cmd
|
||
}
|
||
|
||
case tea.WindowSizeMsg:
|
||
m.width = msg.Width
|
||
m.height = msg.Height
|
||
m.list.SetWidth(msg.Width)
|
||
m.list.SetHeight(msg.Height - 4)
|
||
m.search.Width = msg.Width - 4
|
||
}
|
||
|
||
return m, nil
|
||
}
|
||
|
||
// View renders the palette.
|
||
func (m paletteModel) View() string {
|
||
if m.quitting {
|
||
return ""
|
||
}
|
||
|
||
var title string
|
||
if m.level == levelGroup {
|
||
title = paletteTitleStyle.Render("Command Palette — Select a group")
|
||
} else {
|
||
title = paletteTitleStyle.Render(fmt.Sprintf("Command Palette — %s", m.selected))
|
||
}
|
||
|
||
searchView := m.search.View()
|
||
|
||
// Render list items manually for better control
|
||
var items strings.Builder
|
||
listItems := m.list.Items()
|
||
idx := m.list.Index()
|
||
|
||
for i, item := range listItems {
|
||
ci := item.(commandItem)
|
||
if i == idx {
|
||
items.WriteString(selectedStyle.Render(fmt.Sprintf(" ❯ %s", ci.title)))
|
||
if ci.description != "" {
|
||
items.WriteString(" ")
|
||
items.WriteString(paletteHelpStyle.Render(ci.description))
|
||
}
|
||
} else {
|
||
items.WriteString(fmt.Sprintf(" %s", ci.title))
|
||
if ci.description != "" {
|
||
items.WriteString(" ")
|
||
items.WriteString(paletteHelpStyle.Render(ci.description))
|
||
}
|
||
}
|
||
items.WriteString("\n")
|
||
}
|
||
|
||
if len(listItems) == 0 {
|
||
items.WriteString(paletteHelpStyle.Render(" No matches found"))
|
||
items.WriteString("\n")
|
||
}
|
||
|
||
help := paletteHelpStyle.Render("Enter select · Esc back · ↑↓ navigate · type to search")
|
||
|
||
return fmt.Sprintf("%s\n%s\n%s\n%s", title, searchView, items.String(), help)
|
||
}
|