diff --git a/go.mod b/go.mod index a0464407..ea767244 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/lrstanley/bubblezone v1.0.0 github.com/maypok86/otter/v2 v2.2.1 github.com/muesli/termenv v0.16.0 + github.com/sahilm/fuzzy v0.1.1 github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index bb66882a..fdf6733b 100644 --- a/go.sum +++ b/go.sum @@ -71,10 +71,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dlvhdr/x/gh-checks v0.0.0-20251114174027-320f1169b8e8 h1:FwJR/7CzEgtllg0k9jLgHc0Be20cK1nFn7bvwKMW4Dk= -github.com/dlvhdr/x/gh-checks v0.0.0-20251114174027-320f1169b8e8/go.mod h1:rKC7AjoEOg95nM9iATbJf2FYb4KPLeg6nileMqFg3G8= -github.com/dlvhdr/x/gh-checks v0.2.0 h1:rq2tZckeqBbpqlzGfhF6558Tau7FOx57Y7fd9W6lKgk= -github.com/dlvhdr/x/gh-checks v0.2.0/go.mod h1:rKC7AjoEOg95nM9iATbJf2FYb4KPLeg6nileMqFg3G8= github.com/dlvhdr/x/gh-checks v0.3.0 h1:E6tPgs5Ox5cTAxU6BmY1DsjLTG6VRcRVwYXXPJhCPJA= github.com/dlvhdr/x/gh-checks v0.3.0/go.mod h1:rKC7AjoEOg95nM9iATbJf2FYb4KPLeg6nileMqFg3G8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -135,6 +131,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= @@ -187,6 +185,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ= diff --git a/internal/data/issueapi.go b/internal/data/issueapi.go index dd4bb1ee..cc7690c7 100644 --- a/internal/data/issueapi.go +++ b/internal/data/issueapi.go @@ -27,7 +27,7 @@ type IssueData struct { Assignees Assignees `graphql:"assignees(first: 3)"` Comments IssueComments `graphql:"comments(first: 15)"` Reactions IssueReactions `graphql:"reactions(first: 1)"` - Labels IssueLabels `graphql:"labels(first: 3)"` + Labels IssueLabels `graphql:"labels(first: 20)"` } type IssueComments struct { diff --git a/internal/data/labelapi.go b/internal/data/labelapi.go new file mode 100644 index 00000000..6988cdc9 --- /dev/null +++ b/internal/data/labelapi.go @@ -0,0 +1,77 @@ +package data + +import ( + "encoding/json" + "os/exec" + "strings" + "sync" +) + +var ( + repoLabelCache = make(map[string][]Label) + labelCacheMu sync.RWMutex + // execCommand is injectable for testing; defaults to exec.Command + execCommand = exec.Command +) + +func GetCachedRepoLabels(repoNameWithOwner string) ([]Label, bool) { + labelCacheMu.RLock() + defer labelCacheMu.RUnlock() + labels, ok := repoLabelCache[repoNameWithOwner] + return labels, ok +} + +func FetchRepoLabels(repoNameWithOwner string) ([]Label, error) { + // Check cache first + if cachedLabels, ok := GetCachedRepoLabels(repoNameWithOwner); ok { + return cachedLabels, nil + } + + cmd := execCommand("gh", "label", "list", "-R", repoNameWithOwner, "--json", "name,color", "--limit", "300") + output, err := cmd.Output() + if err != nil { + return nil, err + } + + var labels []Label + if err := json.Unmarshal(output, &labels); err != nil { + return nil, err + } + + filteredLabels := make([]Label, 0, len(labels)) + for _, label := range labels { + if strings.TrimSpace(label.Name) != "" { + filteredLabels = append(filteredLabels, label) + } + } + + labelCacheMu.Lock() + defer labelCacheMu.Unlock() + + if labels, ok := repoLabelCache[repoNameWithOwner]; ok { + return labels, nil + } + + repoLabelCache[repoNameWithOwner] = filteredLabels + return filteredLabels, nil +} + +func ClearLabelCache() { + labelCacheMu.Lock() + defer labelCacheMu.Unlock() + repoLabelCache = make(map[string][]Label) +} + +func ClearRepoLabelCache(repoNameWithOwner string) { + labelCacheMu.Lock() + defer labelCacheMu.Unlock() + delete(repoLabelCache, repoNameWithOwner) +} + +func GetLabelNames(labels []Label) []string { + names := make([]string, len(labels)) + for i, label := range labels { + names[i] = label.Name + } + return names +} diff --git a/internal/tui/components/autocomplete/autocomplete.go b/internal/tui/components/autocomplete/autocomplete.go new file mode 100644 index 00000000..75a7248b --- /dev/null +++ b/internal/tui/components/autocomplete/autocomplete.go @@ -0,0 +1,322 @@ +package autocomplete + +import ( + "strings" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + + "github.com/dlvhdr/gh-dash/v4/internal/tui/constants" + "github.com/dlvhdr/gh-dash/v4/internal/tui/context" + "github.com/sahilm/fuzzy" +) + +// suggestionList wraps a slice of strings to implement fuzzy.Source +type suggestionList struct { + items []string +} + +func (s suggestionList) String(i int) string { + return s.items[i] +} + +type FetchState int + +func (s suggestionList) Len() int { + return len(s.items) +} + +var ( + NextKey = key.NewBinding(key.WithKeys(tea.KeyDown.String(), tea.KeyCtrlN.String()), key.WithHelp("↓/Ctrl+n", "next")) + PrevKey = key.NewBinding(key.WithKeys(tea.KeyUp.String(), tea.KeyCtrlP.String()), key.WithHelp("↑/Ctrl+p", "previous")) + SelectKey = key.NewBinding(key.WithKeys(tea.KeyTab.String(), tea.KeyEnter.String(), tea.KeyCtrlY.String()), key.WithHelp("tab/enter/Ctrl+y", "select")) + RefreshSuggestionsKey = key.NewBinding(key.WithKeys(tea.KeyCtrlF.String()), key.WithHelp("Ctrl+f", "refresh suggestions")) + ToggleSuggestions = key.NewBinding(key.WithKeys(tea.KeyCtrlH.String()), key.WithHelp("Ctrl+h", "toggle suggestions")) +) + +var suggestionKeys = []key.Binding{ + NextKey, + PrevKey, + SelectKey, + RefreshSuggestionsKey, +} + +const ( + FetchStateIdle FetchState = iota + FetchStateLoading + FetchStateSuccess + FetchStateError +) + +// ClearFetchStatusMsg is sent to clear the fetch status after a delay +type ClearFetchStatusMsg struct{} + +// FetchSuggestionsRequestedMsg requests that the current view fetch suggestions from upstream. +// +// When Force is true the fetch should bypass any local cache and request fresh +// data from the gh CLI. +type FetchSuggestionsRequestedMsg struct { + Force bool +} + +// NewFetchSuggestionsRequestedCmd returns a tea.Cmd that emits a +// FetchSuggestionsRequestedMsg with the given force flag. +func NewFetchSuggestionsRequestedCmd(force bool) tea.Cmd { + return func() tea.Msg { return FetchSuggestionsRequestedMsg{Force: force} } +} + +type Model struct { + ctx *context.ProgramContext + suggestionHelp help.Model + suggestions []string + filtered []string + selected int + visible bool + maxVisible int + width int + fetchState FetchState + fetchError error + spinner spinner.Model + // whether the user explicitly hid the suggestions; when true + // Show() will not re-open the popup automatically until Unsuppress() + hiddenByUser bool +} + +func NewModel(ctx *context.ProgramContext) Model { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = lipgloss.NewStyle().Foreground(ctx.Theme.SecondaryText) + + h := help.New() + h.Styles = ctx.Styles.Help.BubbleStyles + return Model{ + ctx: ctx, + suggestionHelp: h, + visible: false, + selected: 0, + maxVisible: 4, + width: 30, + fetchState: FetchStateIdle, + spinner: sp, + } +} + +func (m *Model) SetSuggestions(suggestions []string) { + m.suggestions = suggestions +} + +func (m *Model) Show(currentItem string, excludeItems []string) { + excludeMap := make(map[string]bool) + for _, item := range excludeItems { + excludeMap[strings.ToLower(strings.TrimSpace(item))] = true + } + + // Filter excluded labels first + var filteredSuggestions []string + for _, suggestion := range m.suggestions { + if !excludeMap[strings.ToLower(strings.TrimSpace(suggestion))] { + filteredSuggestions = append(filteredSuggestions, suggestion) + } + } + + if currentItem == "" || len(filteredSuggestions) == 0 { + m.filtered = filteredSuggestions + if len(m.filtered) > m.maxVisible { + m.filtered = m.filtered[:m.maxVisible] + } + m.selected = 0 + // respect suppression: don't auto-show if suppressed + m.UpdateVisible() + return + } + + // Use fuzzy.FindFrom with suggestionList as Source + list := suggestionList{items: filteredSuggestions} + matches := fuzzy.FindFrom(currentItem, list) + + // Collect matched items up to maxResults + m.filtered = make([]string, 0, m.maxVisible) + for _, match := range matches { + if len(m.filtered) >= m.maxVisible { + break + } + m.filtered = append(m.filtered, match.Str) + } + + m.selected = 0 + // respect suppression: don't auto-show if suppressed + m.UpdateVisible() +} + +func (m *Model) Selected() string { + if m.selected >= 0 && m.selected < len(m.filtered) { + return m.filtered[m.selected] + } + return "" +} + +func (m *Model) Next() { + if len(m.filtered) > 0 { + m.selected = (m.selected + 1) % len(m.filtered) + } +} + +func (m *Model) Prev() { + if len(m.filtered) == 0 { + return + } + m.selected-- + if m.selected < 0 { + m.selected = len(m.filtered) - 1 + } +} + +func (m *Model) Hide() { + m.visible = false +} + +// Suppress hides the popup immediately and prevents it from being shown again +// automatically until `Unsuppress()` is called. The underlying filtered results +// are still updated while suppressed so navigation and selection keys will +// operate on up-to-date suggestions even though the popup is not visible. +func (m *Model) Suppress() { + m.hiddenByUser = true + m.visible = false +} + +// Unsuppress clears the user hide flag and allows auto-showing again. +func (m *Model) Unsuppress() { + m.hiddenByUser = false +} + +func (m *Model) IsVisible() bool { + return m.visible +} + +// HasSuggestions returns true if there are filtered suggestions available. +func (m *Model) HasSuggestions() bool { + return len(m.filtered) > 0 +} + +func (m *Model) SetWidth(width int) { + m.width = max(0, width) +} + +func (m *Model) View() string { + if !m.visible || len(m.filtered) == 0 { + return "" + } + + numVisible := min(len(m.filtered), m.maxVisible) + + var b strings.Builder + + popupStyle := m.ctx.Styles.Autocomplete.PopupStyle.Width(m.width) + maxLabelWidth := m.width - popupStyle.GetHorizontalPadding() + ellipsisWidth := lipgloss.Width(constants.Ellipsis) + + for i := 0; i < numVisible && i < len(m.filtered); i++ { + label := m.filtered[i] + if len(label) > maxLabelWidth { + label = ansi.Truncate(label, maxLabelWidth-ellipsisWidth, constants.Ellipsis) + } + + // Style based on selection + if i == m.selected { + // Selected row - use inverted colors + b.WriteString(m.ctx.Styles.Autocomplete.SelectedStyle.Render(constants.SelectionIcon + " " + label)) + } else { + // Non-selected row + b.WriteString(" " + label) + } + + if i < numVisible-1 { + b.WriteString("\n") + } + } + + var statusView string + switch m.fetchState { + case FetchStateLoading: + statusView = m.spinner.View() + m.ctx.Styles.Common.FaintTextStyle.Render("Fetching suggestions"+constants.Ellipsis) + case FetchStateSuccess: + statusView = m.ctx.Styles.Common.SuccessGlyph + m.ctx.Styles.Common.FaintTextStyle.Render(" Suggestions loaded") + case FetchStateError: + errMsg := m.ctx.Styles.Common.FailureGlyph + m.ctx.Styles.Common.FaintTextStyle.Render(" Failed to fetch suggestions") + if m.fetchError != nil { + errMsg = m.fetchError.Error() + } + statusView = m.ctx.Styles.Common.FailureGlyph + " " + errMsg + } + + return popupStyle.Render( + lipgloss.JoinVertical( + lipgloss.Left, + b.String(), + statusView, + lipgloss.NewStyle(). + Render(m.suggestionHelp.ShortHelpView(suggestionKeys)), + ), + ) +} + +func (m *Model) SetFetchLoading() tea.Cmd { + m.fetchState = FetchStateLoading + m.fetchError = nil + return m.spinner.Tick +} + +func (m *Model) SetFetchSuccess() tea.Cmd { + m.fetchState = FetchStateSuccess + m.fetchError = nil + return m.clearFetchStatusAfterDelay() +} + +func (m *Model) SetFetchError(err error) tea.Cmd { + m.fetchState = FetchStateError + m.fetchError = err + return m.clearFetchStatusAfterDelay() +} + +// clearFetchStatusAfterDelay returns a command that will send a ClearFetchStatusMsg after 2 seconds +func (m *Model) clearFetchStatusAfterDelay() tea.Cmd { + return tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + return ClearFetchStatusMsg{} + }) +} + +func (m *Model) UpdateVisible() { + if m.hiddenByUser { + m.visible = false + } else { + m.visible = len(m.filtered) > 0 + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case spinner.TickMsg: + if m.fetchState == FetchStateLoading { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + case ClearFetchStatusMsg: + // Only clear if we're in a success or error state (not loading or already idle) + if m.fetchState == FetchStateSuccess || m.fetchState == FetchStateError { + m.fetchState = FetchStateIdle + m.fetchError = nil + } + } + return m, nil +} + +func (m *Model) UpdateProgramContext(ctx *context.ProgramContext) { + m.ctx = ctx + m.suggestionHelp.Styles = ctx.Styles.Help.BubbleStyles +} diff --git a/internal/tui/components/inputbox/inputbox.go b/internal/tui/components/inputbox/inputbox.go index 3432e652..0da1363d 100644 --- a/internal/tui/components/inputbox/inputbox.go +++ b/internal/tui/components/inputbox/inputbox.go @@ -9,19 +9,35 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/dlvhdr/gh-dash/v4/internal/tui/components/autocomplete" "github.com/dlvhdr/gh-dash/v4/internal/tui/context" ) type Model struct { - ctx *context.ProgramContext - textArea textarea.Model - inputHelp help.Model - prompt string + ctx *context.ProgramContext + textArea textarea.Model + inputHelp help.Model + prompt string + autocomplete *autocomplete.Model + + // OnSuggestionSelected is called when a user selects an autocomplete suggestion. + // It receives the selected suggestion, current cursor position, and current value in inputbox. + // It should return the new value for the inputbox and new cursor position after insertion. + OnSuggestionSelected func(selected string, cursorPos int, currentValue string) (newValue string, newCursorPos int) + + // CurrentContext extracts the "current context" (e.g., partial label being typed) + // at the given cursor position, used for filtering autocomplete suggestions. + CurrentContext func(cursorPos int, currentValue string) string + + // SuggestionsToExclude parses the current value in the inputbox and returns all complete items, + // used to exclude already-entered items from autocomplete suggestions. + SuggestionsToExclude func(currentValue string) []string } var inputKeys = []key.Binding{ key.NewBinding(key.WithKeys(tea.KeyCtrlD.String()), key.WithHelp("Ctrl+d", "submit")), key.NewBinding(key.WithKeys(tea.KeyCtrlC.String(), tea.KeyEsc.String()), key.WithHelp("Ctrl+c/esc", "cancel")), + autocomplete.ToggleSuggestions, } func NewModel(ctx *context.ProgramContext) Model { @@ -50,8 +66,61 @@ func NewModel(ctx *context.ProgramContext) Model { } } +func (m *Model) SetAutocomplete(ac *autocomplete.Model) { + m.autocomplete = ac +} + func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + // Allow toggling suggestions at any time + if m.autocomplete != nil && key.Matches(msg, autocomplete.ToggleSuggestions) { + if m.autocomplete.IsVisible() { + m.autocomplete.Suppress() + return m, nil + } + + m.autocomplete.Unsuppress() + currentValue := m.textArea.Value() + cursorPos := m.GetCursorPosition() + var currentLabel string + var existingLabels []string + if m.CurrentContext != nil { + currentLabel = m.CurrentContext(cursorPos, currentValue) + } + if m.SuggestionsToExclude != nil { + existingLabels = m.SuggestionsToExclude(currentValue) + } + m.autocomplete.Show(currentLabel, existingLabels) + return m, nil + } + + // Allow navigation/selection even if the popup is hidden (as long as there are filtered results) + if m.autocomplete != nil && (m.autocomplete.IsVisible() || m.autocomplete.HasSuggestions()) { + switch { + case key.Matches(msg, autocomplete.PrevKey): + m.autocomplete.Prev() + return m, nil + case key.Matches(msg, autocomplete.NextKey): + m.autocomplete.Next() + return m, nil + case key.Matches(msg, autocomplete.SelectKey): + selected := m.autocomplete.Selected() + if selected != "" && m.OnSuggestionSelected != nil { + currentValue := m.textArea.Value() + cursorPos := m.GetCursorPosition() + newValue, newCursorPos := m.OnSuggestionSelected(selected, cursorPos, currentValue) + m.textArea.SetValue(newValue) + m.textArea.SetCursor(newCursorPos) + } + m.autocomplete.Hide() + return m, nil + } + } + } + m.textArea, cmd = m.textArea.Update(msg) return m, cmd } @@ -74,6 +143,30 @@ func (m Model) View() string { ) } +func (m Model) ViewWithAutocomplete() string { + autocompleteView := "" + if m.autocomplete != nil { + autocompleteView = m.autocomplete.View() + } + + return lipgloss.NewStyle(). + BorderTop(true). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(m.ctx.Theme.SecondaryBorder). + MarginTop(1). + Render( + lipgloss.JoinVertical( + lipgloss.Left, + fmt.Sprintf("%s\n", m.prompt), + m.textArea.View(), + autocompleteView, + lipgloss.NewStyle(). + MarginTop(1). + Render(m.inputHelp.ShortHelpView(inputKeys)), + ), + ) +} + func (m *Model) Value() string { return m.textArea.Value() } @@ -110,3 +203,14 @@ func (m *Model) UpdateProgramContext(ctx *context.ProgramContext) { m.ctx = ctx m.inputHelp.Styles = ctx.Styles.Help.BubbleStyles } + +// GetCursorPosition returns the cursor position within the current logical line +// in runes. This correctly handles multi-byte Unicode characters since the +// textarea internally uses rune-based positioning via [][]rune. +// +// Use this for single-line input contexts like comma-separated labels. +// For multi-line contexts (e.g., @mentions in comments), use GetAbsoluteCursorPosition. +func (m *Model) GetCursorPosition() int { + lineInfo := m.textArea.LineInfo() + return lineInfo.StartColumn + lineInfo.ColumnOffset +} diff --git a/internal/tui/components/issueview/issueview.go b/internal/tui/components/issueview/issueview.go index 510eb612..3946825d 100644 --- a/internal/tui/components/issueview/issueview.go +++ b/internal/tui/components/issueview/issueview.go @@ -5,12 +5,15 @@ import ( "regexp" "strings" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textarea" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/dlvhdr/gh-dash/v4/internal/data" "github.com/dlvhdr/gh-dash/v4/internal/tui/common" + "github.com/dlvhdr/gh-dash/v4/internal/tui/components/autocomplete" "github.com/dlvhdr/gh-dash/v4/internal/tui/components/inputbox" "github.com/dlvhdr/gh-dash/v4/internal/tui/components/issuerow" "github.com/dlvhdr/gh-dash/v4/internal/tui/context" @@ -23,6 +26,14 @@ var ( commentPrompt = "Leave a comment..." ) +type RepoLabelsFetchedMsg struct { + Labels []data.Label +} + +type RepoLabelsFetchFailedMsg struct { + Err error +} + type Model struct { ctx *context.ProgramContext issue *issuerow.Issue @@ -35,12 +46,22 @@ type Model struct { isAssigning bool isUnassigning bool - inputBox inputbox.Model + inputBox inputbox.Model + ac *autocomplete.Model + repoLabels []data.Label } func NewModel(ctx *context.ProgramContext) Model { inputBox := inputbox.NewModel(ctx) - inputBox.SetHeight(common.InputBoxHeight) + linesToAdjust := 5 + inputBox.SetHeight(common.InputBoxHeight - linesToAdjust) + + inputBox.OnSuggestionSelected = handleLabelSelection + inputBox.CurrentContext = labelAtCursor + inputBox.SuggestionsToExclude = allLabels + + ac := autocomplete.NewModel(ctx) + inputBox.SetAutocomplete(&ac) return Model{ issue: nil, @@ -50,7 +71,9 @@ func NewModel(ctx *context.ProgramContext) Model { isAssigning: false, isUnassigning: false, - inputBox: inputBox, + inputBox: inputBox, + ac: &ac, + repoLabels: nil, } } @@ -62,6 +85,39 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { ) switch msg := msg.(type) { + case RepoLabelsFetchedMsg: + clearCmd := m.ac.SetFetchSuccess() + m.repoLabels = msg.Labels + labelNames := data.GetLabelNames(msg.Labels) + m.ac.SetSuggestions(labelNames) + if m.isLabeling { + cursorPos := m.inputBox.GetCursorPosition() + currentLabel := labelAtCursor(cursorPos, m.inputBox.Value()) + existingLabels := allLabels(m.inputBox.Value()) + m.ac.Show(currentLabel, existingLabels) + } + return m, clearCmd + + case RepoLabelsFetchFailedMsg: + clearCmd := m.ac.SetFetchError(msg.Err) + return m, clearCmd + + case autocomplete.FetchSuggestionsRequestedMsg: + // Only fetch when we're in labeling mode (where labels are relevant) + if m.isLabeling { + // If this is a forced refresh (e.g., via Ctrl+F), clear the cached labels + // for this repo so FetchRepoLabels will actually call the gh CLI. + if msg.Force { + if m.issue != nil { + repoName := m.issue.Data.GetRepoNameWithOwner() + data.ClearRepoLabelCache(repoName) + } + } + cmd := m.fetchLabels() + return m, cmd + } + return m, nil + case tea.KeyMsg: if m.isCommenting { switch msg.Type { @@ -97,25 +153,45 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } else if m.isLabeling { switch msg.Type { case tea.KeyCtrlD: - labels := strings.Split(m.inputBox.Value(), ",") - for i := range labels { - labels[i] = strings.TrimSpace(labels[i]) - } + labels := allLabels(m.inputBox.Value()) if len(labels) > 0 { cmd = m.label(labels) } m.inputBox.Blur() m.isLabeling = false + m.ac.Hide() return m, cmd case tea.KeyEsc, tea.KeyCtrlC: m.inputBox.Blur() m.isLabeling = false + m.ac.Hide() return m, nil } + if key.Matches(msg, autocomplete.RefreshSuggestionsKey) { + if m.issue != nil { + repoName := m.issue.Data.GetRepoNameWithOwner() + data.ClearRepoLabelCache(repoName) + } + cmds = append(cmds, m.fetchLabels()) + } + + previousCursorPos := m.inputBox.GetCursorPosition() + previousValue := m.inputBox.Value() + previousLabel := labelAtCursor(previousCursorPos, previousValue) + m.inputBox, taCmd = m.inputBox.Update(msg) cmds = append(cmds, cmd, taCmd) + + currentCursorPos := m.inputBox.GetCursorPosition() + currentValue := m.inputBox.Value() + currentLabel := labelAtCursor(currentCursorPos, currentValue) + + if currentLabel != previousLabel { + existingLabels := allLabels(currentValue) + m.ac.Show(currentLabel, existingLabels) + } } else if m.isAssigning { switch msg.Type { case tea.KeyCtrlD: @@ -159,6 +235,13 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } } + switch msg.(type) { + case spinner.TickMsg, autocomplete.ClearFetchStatusMsg: + var acCmd tea.Cmd + *m.ac, acCmd = m.ac.Update(msg) + cmds = append(cmds, acCmd) + } + return m, tea.Batch(cmds...) } @@ -183,8 +266,10 @@ func (m Model) View() string { s.WriteString("\n\n") s.WriteString(m.renderActivity()) - if m.isCommenting || m.isAssigning || m.isUnassigning || m.isLabeling { + if m.isCommenting || m.isAssigning || m.isUnassigning { s.WriteString(m.inputBox.View()) + } else if m.isLabeling { + s.WriteString(m.inputBox.ViewWithAutocomplete()) } return lipgloss.NewStyle().Padding(0, m.ctx.Styles.Sidebar.ContentPadding).Render(s.String()) @@ -258,6 +343,7 @@ func (m *Model) getIndentedContentWidth() int { func (m *Model) SetWidth(width int) { m.width = width m.inputBox.SetWidth(width) + m.ac.SetWidth(width - 4) } func (m *Model) SetSectionId(id int) { @@ -348,14 +434,49 @@ func (m *Model) SetIsLabeling(isLabeling bool) tea.Cmd { for _, label := range m.issue.Data.Labels.Nodes { labels = append(labels, label.Name) } + labels = append(labels, "") m.inputBox.SetValue(strings.Join(labels, ", ")) + // Reset autocomplete + m.ac.Hide() + m.ac.SetSuggestions(nil) + + // Trigger label fetching for autocomplete if isLabeling { - return tea.Sequence(textarea.Blink, m.inputBox.Focus()) + repoName := m.issue.Data.GetRepoNameWithOwner() + if labels, ok := data.GetCachedRepoLabels(repoName); ok { + // Use cached labels + m.repoLabels = labels + m.ac.SetSuggestions(data.GetLabelNames(labels)) + cursorPos := m.inputBox.GetCursorPosition() + currentLabel := labelAtCursor(cursorPos, m.inputBox.Value()) + existingLabels := allLabels(m.inputBox.Value()) + m.ac.Show(currentLabel, existingLabels) + return tea.Sequence(textarea.Blink, m.inputBox.Focus()) + } else { + // Fetch labels asynchronously + return tea.Sequence(m.fetchLabels(), textarea.Blink, m.inputBox.Focus()) + } } return nil } +// fetchLabels returns a command to fetch repository labels +func (m *Model) fetchLabels() tea.Cmd { + spinnerTickCmd := m.ac.SetFetchLoading() + + fetchCmd := func() tea.Msg { + repoName := m.issue.Data.GetRepoNameWithOwner() + labels, err := data.FetchRepoLabels(repoName) + if err != nil { + return RepoLabelsFetchFailedMsg{Err: err} + } + return RepoLabelsFetchedMsg{Labels: labels} + } + + return tea.Batch(spinnerTickCmd, fetchCmd) +} + func (m *Model) userAssignedToIssue(login string) bool { for _, a := range m.issue.Data.Assignees.Nodes { if login == a.Login { @@ -398,4 +519,5 @@ func (m *Model) issueAssignees() []string { func (m *Model) UpdateProgramContext(ctx *context.ProgramContext) { m.ctx = ctx m.inputBox.UpdateProgramContext(ctx) + m.ac.UpdateProgramContext(ctx) } diff --git a/internal/tui/components/issueview/labels.go b/internal/tui/components/issueview/labels.go new file mode 100644 index 00000000..f0c4aec2 --- /dev/null +++ b/internal/tui/components/issueview/labels.go @@ -0,0 +1,131 @@ +package issueview + +import ( + "strings" +) + +// LabelInfo contains information about a label at a specific cursor position +// in a comma-separated list of labels. +// StartIdx and EndIdx are rune indices (not byte indices). +type LabelInfo struct { + Label string + StartIdx int + EndIdx int + IsFirst bool + IsLast bool +} + +// extractLabelAtCursor extracts information about the label at the given cursor position +// in a comma-separated list. It considers the entire word containing the cursor as the +// current label. The cursor position and returned indices are rune-based (not byte-based) +// to correctly handle multi-byte Unicode characters. +func extractLabelAtCursor(input string, cursorPos int) LabelInfo { + if input == "" { + return LabelInfo{ + Label: "", + StartIdx: 0, + EndIdx: 0, + IsFirst: true, + IsLast: true, + } + } + + runes := []rune(input) + + // Clamp cursor position to valid range + if cursorPos < 0 { + cursorPos = 0 + } + if cursorPos > len(runes) { + cursorPos = len(runes) + } + + // Find the comma before the cursor (or start of string) + startIdx := 0 + for i := cursorPos - 1; i >= 0; i-- { + if runes[i] == ',' { + startIdx = i + 1 + break + } + } + + // Find the comma after the cursor (or end of string) + endIdx := len(runes) + for i := cursorPos; i < len(runes); i++ { + if runes[i] == ',' { + endIdx = i + break + } + } + + // Extract and trim the label + label := strings.TrimSpace(string(runes[startIdx:endIdx])) + + // Determine if this is the first or last label + isFirst := startIdx == 0 + isLast := endIdx == len(runes) + + return LabelInfo{ + Label: label, + StartIdx: startIdx, + EndIdx: endIdx, + IsFirst: isFirst, + IsLast: isLast, + } +} + +// labelAtCursor returns the label text at the cursor position +func labelAtCursor(cursorPos int, currentValue string) string { + labelInfo := extractLabelAtCursor(currentValue, cursorPos) + return labelInfo.Label +} + +// allLabels splits the input by commas and returns trimmed labels +func allLabels(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ",") + labels := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + labels = append(labels, trimmed) + } + } + return labels +} + +// handleLabelSelection handles autocomplete selection for comma-separated labels. +// It enforces consistent formatting: +// - Single space after comma for non-first labels +// - Always adds ", " after the label for easy continuation +// All indices are rune-based to correctly handle multi-byte Unicode characters. +func handleLabelSelection(selected string, cursorPos int, currentValue string) (string, int) { + labelInfo := extractLabelAtCursor(currentValue, cursorPos) + runes := []rune(currentValue) + + // Build replacement with consistent spacing and trailing comma + var replacement string + if labelInfo.IsFirst { + replacement = selected + ", " + } else { + replacement = " " + selected + ", " + } + + // Determine what comes after the current label + // Skip existing comma and spaces if present to avoid duplication + remainingInput := string(runes[labelInfo.EndIdx:]) + // Remove the comma + remainingInput = strings.TrimPrefix(remainingInput, ",") + // Skip any spaces after the comma + remainingInput = strings.TrimLeft(remainingInput, " \t") + + // Build new input by replacing the label at cursor position + newValue := string(runes[:labelInfo.StartIdx]) + replacement + remainingInput + + // Position cursor after the ", " we added + newCursorPos := labelInfo.StartIdx + len([]rune(replacement)) + + return newValue, newCursorPos +} diff --git a/internal/tui/constants/constants.go b/internal/tui/constants/constants.go index 2d99fa00..7d2da793 100644 --- a/internal/tui/constants/constants.go +++ b/internal/tui/constants/constants.go @@ -53,6 +53,7 @@ const ( LabelsIcon = "󰌖" MergedIcon = "" OpenIcon = "" + SelectionIcon = "❯" // New contributors: users who created a PR for the repo for the first time NewContributorIcon = "󰎔" // \udb80\udf94 nf-md-new_box diff --git a/internal/tui/context/styles.go b/internal/tui/context/styles.go index 0584c2ae..fbbcdc32 100644 --- a/internal/tui/context/styles.go +++ b/internal/tui/context/styles.go @@ -80,6 +80,10 @@ type Styles struct { Root lipgloss.Style ViewsSeparator lipgloss.Style } + Autocomplete struct { + PopupStyle lipgloss.Style + SelectedStyle lipgloss.Style + } } const ( @@ -230,6 +234,15 @@ func InitStyles(theme theme.Theme) Styles { s.ViewSwitcher.InactiveView = lipgloss.NewStyle(). Background(theme.FaintBorder). Foreground(theme.FaintText) + s.Autocomplete.PopupStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.SecondaryBorder). + Foreground(theme.PrimaryText) + + s.Autocomplete.SelectedStyle = lipgloss.NewStyle(). + Background(theme.SelectedBackground). + Foreground(theme.PrimaryText). + Bold(true) return s }