main 5041353

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 (
68	defaultDmenuHistoryLimit = 500
69	dmenuListChromeHeight    = 6
70)
71
72// Init implements tea.Model.
73func (m DmenuModel) Init() tea.Cmd {
74	return tea.Batch(
75		textinput.Blink,
76		m.loadEntries,
77	)
78}
79
80// loadEntries loads history entries.
81func (m DmenuModel) loadEntries() tea.Msg {
82	ctx := context.Background()
83
84	entries, err := m.historyUC.GetRecent(ctx, defaultDmenuHistoryLimit, 0)
85	if err != nil {
86		return dmenuLoadedMsg{err: err}
87	}
88
89	items := make([]styles.HistoryItem, len(entries))
90	for i, e := range entries {
91		items[i] = styles.HistoryItem{
92			ID:          e.ID,
93			URL:         e.URL,
94			Title:       e.Title,
95			Domain:      getDomain(e.URL),
96			VisitCount:  int(e.VisitCount),
97			LastVisited: e.LastVisited,
98		}
99	}
100
101	return dmenuLoadedMsg{items: items}
102}
103
104// Update implements tea.Model.
105func (m DmenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
106	var cmds []tea.Cmd
107
108	switch msg := msg.(type) {
109	case tea.WindowSizeMsg:
110		m.width = msg.Width
111		m.height = msg.Height
112		m.help.Width = msg.Width
113		m.updateList()
114
115	case tea.KeyMsg:
116		switch {
117		case key.Matches(msg, m.keys.Cancel):
118			return m, tea.Quit
119
120		case key.Matches(msg, m.keys.Open):
121			// Select current item and quit
122			if item := m.list.SelectedItem(); item != nil {
123				if hi, ok := item.(styles.HistoryItem); ok {
124					m.selected = hi.URL
125				}
126			}
127			return m, tea.Quit
128
129		case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down):
130			// Pass to list
131			var cmd tea.Cmd
132			m.list, cmd = m.list.Update(msg)
133			cmds = append(cmds, cmd)
134
135		default:
136			// Update search input
137			var cmd tea.Cmd
138			m.search, cmd = m.search.Update(msg)
139			cmds = append(cmds, cmd)
140
141			// Filter list based on search
142			if m.search.Value() != m.searchQuery {
143				m.searchQuery = m.search.Value()
144				m.updateList()
145			}
146		}
147
148	case dmenuLoadedMsg:
149		if msg.err != nil {
150			m.err = msg.err
151		} else {
152			m.allItems = msg.items
153			m.updateList()
154		}
155	}
156
157	return m, tea.Batch(cmds...)
158}
159
160// updateList updates the list with filtered items.
161func (m *DmenuModel) updateList() {
162	items := m.allItems
163
164	// Apply search filter
165	if m.searchQuery != "" {
166		query := m.searchQuery
167		filtered := make([]styles.HistoryItem, 0)
168		for _, item := range items {
169			// Simple case-insensitive contains matching
170			if containsIgnoreCase(item.Title, query) || containsIgnoreCase(item.URL, query) {
171				filtered = append(filtered, item)
172			}
173		}
174		items = filtered
175	}
176
177	// Create list
178	listHeight := max(
179		// Account for search, help
180		m.height-dmenuListChromeHeight, 5)
181
182	m.list = styles.NewHistoryList(m.theme, items, m.width, listHeight)
183}
184
185// containsIgnoreCase checks if s contains substr (case-insensitive).
186func containsIgnoreCase(s, substr string) bool {
187	sLower := toLower(s)
188	substrLower := toLower(substr)
189
190	for i := 0; i <= len(sLower)-len(substrLower); i++ {
191		if sLower[i:i+len(substrLower)] == substrLower {
192			return true
193		}
194	}
195	return false
196}
197
198// toLower converts string to lowercase without importing strings.
199func toLower(s string) string {
200	b := make([]byte, len(s))
201	for i := 0; i < len(s); i++ {
202		c := s[i]
203		if c >= 'A' && c <= 'Z' {
204			c += 'a' - 'A'
205		}
206		b[i] = c
207	}
208	return string(b)
209}
210
211// View implements tea.Model.
212func (m DmenuModel) View() string {
213	t := m.theme
214
215	// Search bar
216	searchBar := t.InputFocused.Render(m.search.View())
217
218	// List
219	listView := m.list.View()
220
221	if m.err != nil {
222		listView = t.ErrorStyle.Render("Error: " + m.err.Error())
223	}
224
225	// Help
226	helpView := m.help.View(m.keys)
227
228	// Combine
229	return lipgloss.JoinVertical(
230		lipgloss.Left,
231		searchBar,
232		"",
233		listView,
234		"",
235		helpView,
236	)
237}
238
239// SelectedURL returns the URL selected by the user.
240func (m DmenuModel) SelectedURL() string {
241	return m.selected
242}
243
244// Ensure interface compliance.
245var _ 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