v0.30.0 eb3068f

history_sidebar_rendering.go RawHistoryFolder

1package component
2
3import (
4	"github.com/bnema/puregotk/v4/gtk"
5	"github.com/bnema/puregotk/v4/pango"
6
7	"github.com/bnema/dumber/internal/domain/entity"
8)
9
10// =============================================================================
11// List rendering
12// =============================================================================
13
14// rebuildList clears and repopulates the list box from current groups.
15// Must be called on the GTK main thread. Preserves scroll and selection.
16func (hs *HistorySidebar) rebuildList() {
17	hs.mu.Lock()
18	if hs.destroyed || hs.listBox == nil {
19		hs.mu.Unlock()
20		return
21	}
22	listBox := hs.listBox
23	rows := append([]historyDisplayRow(nil), hs.displayRows...)
24	query := hs.currentQuery
25	hasSearchResults := hs.searchResults != nil
26	searchDone := hs.searchDone
27	totalLoaded := hs.totalLoaded
28	isLoading := hs.isLoading
29	hs.clearRelativeTimeBindingsLocked()
30	hs.mu.Unlock()
31
32	// Remove all rows
33	listBox.RemoveAll()
34
35	if len(rows) == 0 {
36		if !hasSearchResults && totalLoaded == 0 {
37			// Browse has not loaded yet AND no search has completed.
38			hs.showLoadingOrEmpty(listBox, query, searchDone, isLoading)
39			return
40		}
41		// Search completed with 0 results, or browse loaded but empty (no history).
42		hs.showEmptyState(listBox, query)
43		hs.restoreScrollAndSelectionInListBox(listBox)
44		return
45	}
46
47	for _, row := range rows {
48		switch row.Kind {
49		case historyDisplayRowHeader:
50			hs.appendGroupHeader(listBox, row.Label)
51		case historyDisplayRowEntry:
52			hs.appendEntryRow(listBox, row.Entry)
53		}
54	}
55
56	listBox.Show()
57
58	// Restore previous scroll position and selection
59	hs.restoreScrollAndSelectionInListBox(listBox)
60
61	// If no selection was restored and this is the first load, select first entry
62	hs.ensureAtLeastOneSelectionInListBox(listBox)
63}
64
65func (hs *HistorySidebar) showLoadingOrEmpty(listBox *gtk.ListBox, query string, searchDone, isLoading bool) {
66	label := gtk.NewLabel(nil)
67	if label == nil {
68		return
69	}
70	label.AddCssClass("history-sidebar-loading")
71
72	switch {
73	case isLoading && query == "":
74		label.SetText("Loading history...")
75	case query != "" && !searchDone:
76		label.SetText("Searching...")
77	case query != "":
78		label.SetText(noResultsText(query))
79	default:
80		label.SetText("No browsing history")
81	}
82
83	label.SetWrap(false)
84	label.SetXalign(0.0)
85
86	row := gtk.NewListBoxRow()
87	if row == nil {
88		return
89	}
90	row.SetSelectable(false)
91	row.SetCanFocus(false)
92	row.SetActivatable(false)
93	row.SetChild(&label.Widget)
94	listBox.Append(&row.Widget)
95}
96
97func noResultsText(query string) string {
98	return "No results for \"" + query + "\""
99}
100
101func (hs *HistorySidebar) showEmptyState(listBox *gtk.ListBox, query string) {
102	label := gtk.NewLabel(nil)
103	if label == nil {
104		return
105	}
106	label.AddCssClass("history-sidebar-empty")
107
108	if query != "" {
109		label.SetText(noResultsText(query))
110	} else {
111		label.SetText("No browsing history")
112	}
113
114	label.SetWrap(false)
115	label.SetXalign(0.0)
116
117	row := gtk.NewListBoxRow()
118	if row == nil {
119		return
120	}
121	row.SetSelectable(false)
122	row.SetCanFocus(false)
123	row.SetActivatable(false)
124	row.SetChild(&label.Widget)
125	listBox.Append(&row.Widget)
126}
127
128// appendGroupHeader adds a non-selectable group header label to the list.
129func (hs *HistorySidebar) appendGroupHeader(listBox *gtk.ListBox, labelText string) {
130	label := gtk.NewLabel(&labelText)
131	if label == nil {
132		return
133	}
134	label.AddCssClass("history-sidebar-group-header")
135	label.SetXalign(0.0)
136	label.SetHexpand(true)
137
138	row := gtk.NewListBoxRow()
139	if row == nil {
140		return
141	}
142	row.SetSelectable(false)
143	row.SetCanFocus(false)
144	row.SetActivatable(false)
145	row.SetChild(&label.Widget)
146	listBox.Append(&row.Widget)
147}
148
149// appendEntryRow adds a selectable two-line entry row to the list.
150func (hs *HistorySidebar) appendEntryRow(listBox *gtk.ListBox, entry *entity.HistoryEntry) {
151	if entry == nil {
152		return
153	}
154	// Outer vertical box for two-line layout
155	rowBox := gtk.NewBox(gtk.OrientationVerticalValue, 1)
156	if rowBox == nil {
157		return
158	}
159	rowBox.SetHexpand(true)
160
161	// Title line (first line)
162	titleLabel := gtk.NewLabel(nil)
163	if titleLabel == nil {
164		return
165	}
166	titleLabel.AddCssClass("history-sidebar-row-title")
167	titleLabel.SetText(safeSidebarString(entry.Title, entry.URL))
168	titleLabel.SetXalign(0.0)
169	titleLabel.SetHexpand(true)
170	titleLabel.SetEllipsize(pango.EllipsizeEndValue)
171
172	// Subtitle line with URL and time
173	subBox := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
174	if subBox == nil {
175		return
176	}
177	subBox.SetHexpand(true)
178
179	urlLabel := gtk.NewLabel(nil)
180	if urlLabel == nil {
181		return
182	}
183	urlLabel.AddCssClass("history-sidebar-row-subtitle")
184	urlLabel.SetText(readableURL(entry.URL))
185	urlLabel.SetXalign(0.0)
186	urlLabel.SetHexpand(true)
187	urlLabel.SetEllipsize(pango.EllipsizeEndValue)
188
189	timeLabel := gtk.NewLabel(nil)
190	if timeLabel == nil {
191		return
192	}
193	timeLabel.AddCssClass("history-sidebar-row-time")
194	timeLabel.SetText(relativeTimeAt(entry.LastVisited, hs.currentTime()))
195	timeLabel.SetXalign(1.0)
196
197	subBox.Append(&urlLabel.Widget)
198	subBox.Append(&timeLabel.Widget)
199
200	rowBox.Append(&titleLabel.Widget)
201	rowBox.Append(&subBox.Widget)
202
203	// Create the list box row
204	row := gtk.NewListBoxRow()
205	if row == nil {
206		return
207	}
208	row.AddCssClass("history-sidebar-row")
209	row.SetSelectable(true)
210	row.SetActivatable(true)
211	row.SetCanFocus(true)
212	row.SetFocusOnClick(true)
213	row.SetChild(&rowBox.Widget)
214
215	listBox.Append(&row.Widget)
216	hs.bindRelativeTimeLabel(timeLabel, entry.LastVisited)
217}
218
219// ensureAtLeastOneSelection selects the first selectable row if nothing is selected.
220func (hs *HistorySidebar) ensureAtLeastOneSelectionInListBox(listBox *gtk.ListBox) {
221	if listBox == nil || listBox.GetSelectedRow() != nil {
222		return
223	}
224	for i := 0; ; i++ {
225		row := listBox.GetRowAtIndex(i)
226		if row == nil {
227			break
228		}
229		if row.GetSelectable() {
230			listBox.SelectRow(row)
231			return
232		}
233	}
234}

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