main 5041353

favorites_sidebar_rows.go RawHistoryFolder

1package component
2
3import (
4	"fmt"
5
6	"github.com/bnema/puregotk/v4/gtk"
7	"github.com/bnema/puregotk/v4/pango"
8
9	"github.com/bnema/dumber/internal/domain/entity"
10)
11
12type favoriteSidebarDisplayRowKind int
13
14const (
15	favoriteSidebarDisplayRowFavorite favoriteSidebarDisplayRowKind = iota
16)
17
18type favoriteSidebarDisplayRow struct {
19	Kind       favoriteSidebarDisplayRowKind
20	FavoriteID entity.FavoriteID
21	TagID      entity.TagID
22	URL        string
23	Favorite   *entity.Favorite
24	Selectable bool
25}
26
27func buildFavoriteSidebarDisplayRows(favorites []*entity.Favorite) []favoriteSidebarDisplayRow {
28	rows := make([]favoriteSidebarDisplayRow, 0, len(favorites))
29	for _, fav := range favorites {
30		if fav == nil {
31			continue
32		}
33		rows = append(rows, favoriteSidebarDisplayRow{
34			Kind:       favoriteSidebarDisplayRowFavorite,
35			FavoriteID: fav.ID,
36			URL:        fav.URL,
37			Favorite:   fav,
38			Selectable: fav.URL != "",
39		})
40	}
41	return rows
42}
43
44func firstSelectableIndex(rows []favoriteSidebarDisplayRow) int {
45	for i := range rows {
46		if rows[i].Selectable {
47			return i
48		}
49	}
50	return -1
51}
52
53func lastSelectableIndex(rows []favoriteSidebarDisplayRow) int {
54	for i := len(rows) - 1; i >= 0; i-- {
55		if rows[i].Selectable {
56			return i
57		}
58	}
59	return -1
60}
61
62func nextSelectableIndex(rows []favoriteSidebarDisplayRow, current, direction int) int {
63	if direction != -1 && direction != 1 {
64		return -1
65	}
66	for i := current + direction; i >= 0 && i < len(rows); i += direction {
67		if rows[i].Selectable {
68			return i
69		}
70	}
71	return -1
72}
73
74func (fs *FavoritesSidebar) renderTags() {
75	tagBox, tags, selectedTagIDs, ok := fs.tagRenderState()
76	if !ok {
77		return
78	}
79	clearBoxChildren(tagBox)
80	callbacks := make([]any, 0, len(tags)+2)
81	controls := make([]*gtk.Button, 0, len(tags)+2)
82
83	fs.appendTagFilter(tagBox, "All", len(selectedTagIDs) == 0, fs.clearTagFilters, &callbacks, &controls)
84	for _, tag := range tags {
85		if tag == nil {
86			continue
87		}
88		t := tag
89		_, selected := selectedTagIDs[t.ID]
90		fs.appendTagFilter(tagBox, t.Name, selected, func() { fs.toggleTag(t.ID) }, &callbacks, &controls)
91	}
92	fs.appendTagFilter(tagBox, "+", false, fs.showCreateTagPrompt, &callbacks, &controls)
93	if len(controls) > 0 {
94		controls[len(controls)-1].AddCssClass("favorites-sidebar-tag-add")
95	}
96	fs.storeTagControls(callbacks, controls)
97}
98
99func (fs *FavoritesSidebar) tagRenderState() (*gtk.Box, []*entity.Tag, map[entity.TagID]struct{}, bool) {
100	if fs == nil || fs.tagBox == nil {
101		return nil, nil, nil, false
102	}
103	fs.mu.RLock()
104	defer fs.mu.RUnlock()
105	if fs.destroyed {
106		return nil, nil, nil, false
107	}
108	selected := make(map[entity.TagID]struct{}, len(fs.selectedTagIDs))
109	for id := range fs.selectedTagIDs {
110		selected[id] = struct{}{}
111	}
112	return fs.tagBox, append([]*entity.Tag(nil), fs.allTags...), selected, true
113}
114
115func (fs *FavoritesSidebar) appendTagFilter(
116	box *gtk.Box,
117	label string,
118	selected bool,
119	action func(),
120	callbacks *[]any,
121	controls *[]*gtk.Button,
122) {
123	button := gtk.NewButtonWithLabel(label)
124	if button == nil {
125		return
126	}
127	button.AddCssClass("favorites-sidebar-tag-filter")
128	if selected {
129		button.AddCssClass("favorites-sidebar-tag-filter-active")
130	}
131	callback := func(_ gtk.Button) { action() }
132	*callbacks = append(*callbacks, callback)
133	button.ConnectClicked(&callback)
134	box.Append(&button.Widget)
135	*controls = append(*controls, button)
136}
137
138func (fs *FavoritesSidebar) clearTagFilters() {
139	if fs == nil {
140		return
141	}
142	fs.mu.Lock()
143	if fs.destroyed {
144		fs.mu.Unlock()
145		return
146	}
147	fs.selectedTagIDs = make(map[entity.TagID]struct{})
148	fs.rebuildDisplayRowsLocked()
149	fs.mu.Unlock()
150	fs.renderTags()
151	fs.rebuildList()
152}
153
154func (fs *FavoritesSidebar) storeTagControls(callbacks []any, controls []*gtk.Button) {
155	fs.mu.Lock()
156	if !fs.destroyed {
157		fs.tagCallbacks = callbacks
158		fs.tagControls = controls
159	}
160	fs.mu.Unlock()
161}
162
163func (fs *FavoritesSidebar) toggleTag(tagID entity.TagID) {
164	if fs == nil {
165		return
166	}
167	fs.mu.Lock()
168	if fs.destroyed {
169		fs.mu.Unlock()
170		return
171	}
172	if fs.selectedTagIDs == nil {
173		fs.selectedTagIDs = make(map[entity.TagID]struct{})
174	}
175	if _, ok := fs.selectedTagIDs[tagID]; ok {
176		delete(fs.selectedTagIDs, tagID)
177	} else {
178		fs.selectedTagIDs[tagID] = struct{}{}
179	}
180	fs.rebuildDisplayRowsLocked()
181	fs.mu.Unlock()
182	fs.renderTags()
183	fs.rebuildList()
184}
185
186func (fs *FavoritesSidebar) rebuildList() {
187	if fs == nil {
188		return
189	}
190	fs.mu.RLock()
191	if fs.destroyed || fs.listBox == nil {
192		fs.mu.RUnlock()
193		return
194	}
195	listBox := fs.listBox
196	rows := append([]favoriteSidebarDisplayRow(nil), fs.displayRows...)
197	notice := fs.notice
198	query := fs.currentQuery
199	fs.mu.RUnlock()
200
201	listBox.RemoveAll()
202	if len(rows) == 0 {
203		for _, text := range favoriteSidebarNoticeRows(notice, query, false) {
204			fs.appendNoticeRow(listBox, text)
205		}
206		return
207	}
208	for _, row := range rows {
209		fs.appendFavoriteRow(listBox, row)
210	}
211	for _, text := range favoriteSidebarNoticeRows(notice, query, true) {
212		fs.appendNoticeRow(listBox, text)
213	}
214	fs.ensureAtLeastOneSelectionInListBox(listBox)
215}
216
217func favoriteSidebarNoticeRows(notice, query string, hasRows bool) []string {
218	if notice != "" {
219		return []string{notice}
220	}
221	if hasRows {
222		return nil
223	}
224	if query != "" {
225		return []string{noResultsText(query)}
226	}
227	return []string{"No favorites"}
228}
229
230func (fs *FavoritesSidebar) appendNoticeRow(listBox *gtk.ListBox, text string) {
231	label := gtk.NewLabel(&text)
232	if label == nil {
233		return
234	}
235	label.AddCssClass("sidebar-empty")
236	label.AddCssClass("favorites-sidebar-empty")
237	label.SetXalign(0.0)
238	row := gtk.NewListBoxRow()
239	if row == nil {
240		return
241	}
242	row.SetSelectable(false)
243	row.SetCanFocus(false)
244	row.SetActivatable(false)
245	row.SetChild(&label.Widget)
246	listBox.Append(&row.Widget)
247}
248
249func (fs *FavoritesSidebar) appendFavoriteRow(listBox *gtk.ListBox, displayRow favoriteSidebarDisplayRow) {
250	fav := displayRow.Favorite
251	if fav == nil {
252		return
253	}
254	rowBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
255	if rowBox == nil {
256		return
257	}
258	rowBox.SetHexpand(true)
259	appendFavoriteRowTitle(rowBox, fav)
260	appendFavoriteRowSubtitle(rowBox, fav)
261	appendFavoriteRowTags(rowBox, fav.Tags)
262
263	row := gtk.NewListBoxRow()
264	if row == nil {
265		return
266	}
267	row.AddCssClass("sidebar-row")
268	row.AddCssClass("favorites-sidebar-row")
269	row.SetSelectable(displayRow.Selectable)
270	row.SetActivatable(displayRow.Selectable)
271	row.SetCanFocus(displayRow.Selectable)
272	row.SetFocusOnClick(displayRow.Selectable)
273	row.SetChild(&rowBox.Widget)
274	listBox.Append(&row.Widget)
275}
276
277func appendFavoriteRowTitle(rowBox *gtk.Box, fav *entity.Favorite) {
278	title := gtk.NewLabel(nil)
279	if title == nil {
280		return
281	}
282	title.AddCssClass("sidebar-row-title")
283	title.AddCssClass("favorites-sidebar-row-title")
284	title.SetText(safeSidebarString(fav.Title, fav.URL))
285	title.SetXalign(0.0)
286	title.SetHexpand(true)
287	title.SetEllipsize(pango.EllipsizeEndValue)
288	rowBox.Append(&title.Widget)
289}
290
291func appendFavoriteRowSubtitle(rowBox *gtk.Box, fav *entity.Favorite) {
292	sub := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
293	if sub == nil {
294		return
295	}
296	sub.SetHexpand(true)
297	url := gtk.NewLabel(nil)
298	if url != nil {
299		url.AddCssClass("sidebar-row-subtitle")
300		url.AddCssClass("favorites-sidebar-row-subtitle")
301		url.SetText(readableURL(fav.URL))
302		url.SetXalign(0.0)
303		url.SetHexpand(true)
304		url.SetEllipsize(pango.EllipsizeEndValue)
305		sub.Append(&url.Widget)
306	}
307	if fav.ShortcutKey != nil {
308		badge := fmt.Sprintf("Shortcut %d", *fav.ShortcutKey)
309		badgeLabel := gtk.NewLabel(&badge)
310		if badgeLabel != nil {
311			badgeLabel.AddCssClass("favorites-sidebar-shortcut-badge")
312			sub.Append(&badgeLabel.Widget)
313		}
314	}
315	rowBox.Append(&sub.Widget)
316}
317
318func appendFavoriteRowTags(rowBox *gtk.Box, favoriteTags []entity.Tag) {
319	if len(favoriteTags) == 0 {
320		return
321	}
322	tags := gtk.NewBox(gtk.OrientationHorizontalValue, 3)
323	if tags == nil {
324		return
325	}
326	tags.AddCssClass("favorites-sidebar-row-tags")
327	for _, tag := range favoriteTags {
328		name := tag.Name
329		label := gtk.NewLabel(&name)
330		if label != nil {
331			label.AddCssClass("favorites-sidebar-tag-chip")
332			tags.Append(&label.Widget)
333		}
334	}
335	rowBox.Append(&tags.Widget)
336}
337
338func (fs *FavoritesSidebar) ensureAtLeastOneSelectionInListBox(listBox *gtk.ListBox) {
339	if listBox == nil || listBox.GetSelectedRow() != nil {
340		return
341	}
342	fs.mu.RLock()
343	index := firstSelectableIndex(fs.displayRows)
344	fs.mu.RUnlock()
345	if row := listBox.GetRowAtIndex(index); row != nil {
346		listBox.SelectRow(row)
347	}
348}
349
350func (fs *FavoritesSidebar) rowURLAt(index int) string {
351	if index < 0 || index >= len(fs.displayRows) || !fs.displayRows[index].Selectable {
352		return ""
353	}
354	return fs.displayRows[index].URL
355}

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