v0.23.1 6c872d5

dmenu.go RawHistoryFolder

1package model
2
3import (
4	"context"
5
6	"github.com/charmbracelet/bubbles/help"
7	"github.com/charmbracelet/bubbles/key"
8	"github.com/charmbracelet/bubbles/list"
9	"github.com/charmbracelet/bubbles/textinput"
10	tea "github.com/charmbracelet/bubbletea"
11	"github.com/charmbracelet/lipgloss"
12
13	"github.com/bnema/dumber/internal/application/usecase"
14	"github.com/bnema/dumber/internal/cli/styles"
15)
16
17// DmenuModel is the Bubble Tea model for interactive dmenu.
18type DmenuModel struct {
19	// UI components
20	list   list.Model
21	search textinput.Model
22	help   help.Model
23	keys   styles.DmenuKeyMap
24
25	// State
26	allItems    []styles.HistoryItem
27	selected    string // Selected URL
28	searchQuery string
29	width       int
30	height      int
31	err         error
32
33	// Dependencies
34	historyUC *usecase.SearchHistoryUseCase
35	theme     *styles.Theme
36}
37
38// NewDmenuModel creates a new dmenu model.
39func NewDmenuModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) DmenuModel {
40	// Create search input
41	search := styles.NewSearchInput(theme)
42	search.Focus()
43
44	// Create help
45	helpModel := styles.NewStyledHelp(theme)
46
47	// Create keybindings
48	keys := styles.DefaultDmenuKeyMap()
49
50	return DmenuModel{
51		search:    search,
52		help:      helpModel,
53		keys:      keys,
54		historyUC: historyUC,
55		theme:     theme,
56		width:     80,
57		height:    24,
58	}
59}
60
61// dmenuLoadedMsg is sent when entries are loaded.
62type dmenuLoadedMsg struct {
63	items []styles.HistoryItem
64	err   error
65}
66
67const defaultDmenuHistoryLimit = 500
68
69// Init implements tea.Model.
70func (m DmenuModel) Init() tea.Cmd {
71	return tea.Batch(
72		textinput.Blink,
73		m.loadEntries,
74	)
75}
76
77// loadEntries loads history entries.
78func (m DmenuModel) loadEntries() tea.Msg {
79	ctx := context.Background()
80
81	entries, err := m.historyUC.GetRecent(ctx, defaultDmenuHistoryLimit, 0)
82	if err != nil {
83		return dmenuLoadedMsg{err: err}
84	}
85
86	items := make([]styles.HistoryItem, len(entries))
87	for i, e := range entries {
88		items[i] = styles.HistoryItem{
89			ID:          e.ID,
90			URL:         e.URL,
91			Title:       e.Title,
92			Domain:      getDomain(e.URL),
93			VisitCount:  int(e.VisitCount),
94			LastVisited: e.LastVisited,
95		}
96	}
97
98	return dmenuLoadedMsg{items: items}
99}
100
101// Update implements tea.Model.
102func (m DmenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103	var cmds []tea.Cmd
104
105	switch msg := msg.(type) {
106	case tea.WindowSizeMsg:
107		m.width = msg.Width
108		m.height = msg.Height
109		m.help.Width = msg.Width
110		m.updateList()
111
112	case tea.KeyMsg:
113		switch {
114		case key.Matches(msg, m.keys.Cancel):
115			return m, tea.Quit
116
117		case key.Matches(msg, m.keys.Open):
118			// Select current item and quit
119			if item := m.list.SelectedItem(); item != nil {
120				if hi, ok := item.(styles.HistoryItem); ok {
121					m.selected = hi.URL
122				}
123			}
124			return m, tea.Quit
125
126		case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down):
127			// Pass to list
128			var cmd tea.Cmd
129			m.list, cmd = m.list.Update(msg)
130			cmds = append(cmds, cmd)
131
132		default:
133			// Update search input
134			var cmd tea.Cmd
135			m.search, cmd = m.search.Update(msg)
136			cmds = append(cmds, cmd)
137
138			// Filter list based on search
139			if m.search.Value() != m.searchQuery {
140				m.searchQuery = m.search.Value()
141				m.updateList()
142			}
143		}
144
145	case dmenuLoadedMsg:
146		if msg.err != nil {
147			m.err = msg.err
148		} else {
149			m.allItems = msg.items
150			m.updateList()
151		}
152	}
153
154	return m, tea.Batch(cmds...)
155}
156
157// updateList updates the list with filtered items.
158func (m *DmenuModel) updateList() {
159	items := m.allItems
160
161	// Apply search filter
162	if m.searchQuery != "" {
163		query := m.searchQuery
164		filtered := make([]styles.HistoryItem, 0)
165		for _, item := range items {
166			// Simple case-insensitive contains matching
167			if containsIgnoreCase(item.Title, query) || containsIgnoreCase(item.URL, query) {
168				filtered = append(filtered, item)
169			}
170		}
171		items = filtered
172	}
173
174	// Create list
175	listHeight := m.height - 6 // Account for search, help
176	if listHeight < 5 {
177		listHeight = 5
178	}
179
180	m.list = styles.NewHistoryList(m.theme, items, m.width, listHeight)
181}
182
183// containsIgnoreCase checks if s contains substr (case-insensitive).
184func containsIgnoreCase(s, substr string) bool {
185	sLower := toLower(s)
186	substrLower := toLower(substr)
187
188	for i := 0; i <= len(sLower)-len(substrLower); i++ {
189		if sLower[i:i+len(substrLower)] == substrLower {
190			return true
191		}
192	}
193	return false
194}
195
196// toLower converts string to lowercase without importing strings.
197func toLower(s string) string {
198	b := make([]byte, len(s))
199	for i := 0; i < len(s); i++ {
200		c := s[i]
201		if c >= 'A' && c <= 'Z' {
202			c += 'a' - 'A'
203		}
204		b[i] = c
205	}
206	return string(b)
207}
208
209// View implements tea.Model.
210func (m DmenuModel) View() string {
211	t := m.theme
212
213	// Search bar
214	searchBar := t.InputFocused.Render(m.search.View())
215
216	// List
217	listView := m.list.View()
218
219	if m.err != nil {
220		listView = t.ErrorStyle.Render("Error: " + m.err.Error())
221	}
222
223	// Help
224	helpView := m.help.View(m.keys)
225
226	// Combine
227	return lipgloss.JoinVertical(
228		lipgloss.Left,
229		searchBar,
230		"",
231		listView,
232		"",
233		helpView,
234	)
235}
236
237// SelectedURL returns the URL selected by the user.
238func (m DmenuModel) SelectedURL() string {
239	return m.selected
240}
241
242// Ensure interface compliance.
243var _ tea.Model = (*DmenuModel)(nil)

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 8

Tags 42

↑↓ moveenter openesc close