main 5041353

history_sidebar_reactivity_test.go RawHistoryFolder

1package component
2
3import (
4	"sync"
5	"testing"
6	"time"
7
8	"github.com/bnema/dumber/internal/domain/entity"
9	"github.com/bnema/puregotk/v4/glib"
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/require"
12)
13
14type sidebarReactivityHarness struct {
15	hs      *HistorySidebar
16	idle    []glib.SourceFunc
17	timers  map[uint]glib.SourceFunc
18	removed []uint
19	nextID  uint
20	idleMu  sync.Mutex
21	timerMu sync.Mutex
22}
23
24func newSidebarReactivityHarness() *sidebarReactivityHarness {
25	h := &sidebarReactivityHarness{timers: make(map[uint]glib.SourceFunc), nextID: 10}
26	hs := newTestSidebarSearchHarness()
27	hs.now = func() time.Time { return time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) }
28	hs.idleScheduler = func(cb glib.SourceFunc) {
29		h.idleMu.Lock()
30		defer h.idleMu.Unlock()
31		h.idle = append(h.idle, cb)
32	}
33	hs.timeoutAdd = func(_ uint, cb glib.SourceFunc) uint {
34		h.timerMu.Lock()
35		defer h.timerMu.Unlock()
36		h.nextID++
37		h.timers[h.nextID] = cb
38		return h.nextID
39	}
40	hs.sourceRemove = func(id uint) {
41		h.timerMu.Lock()
42		defer h.timerMu.Unlock()
43		h.removed = append(h.removed, id)
44		delete(h.timers, id)
45	}
46	h.hs = hs
47	return h
48}
49
50func (h *sidebarReactivityHarness) runIdle() int {
51	h.idleMu.Lock()
52	callbacks := append([]glib.SourceFunc(nil), h.idle...)
53	h.idle = nil
54	h.idleMu.Unlock()
55	for _, cb := range callbacks {
56		cb(0)
57	}
58	return len(callbacks)
59}
60
61func (h *sidebarReactivityHarness) singleTimer(t *testing.T) (uint, glib.SourceFunc) {
62	t.Helper()
63	h.timerMu.Lock()
64	defer h.timerMu.Unlock()
65	require.Len(t, h.timers, 1)
66	for id, cb := range h.timers {
67		return id, cb
68	}
69	return 0, nil
70}
71
72func TestHistorySidebar_RequestReloadIfVisible_HiddenNoop(t *testing.T) {
73	h := newSidebarReactivityHarness()
74	h.hs.RequestReloadIfVisible("hidden")
75	require.Zero(t, h.runIdle())
76	assert.Empty(t, h.timers)
77	assert.Zero(t, h.hs.reloadDebounceTimer)
78}
79
80func TestHistorySidebar_RequestReloadIfVisible_CoalescesVisible(t *testing.T) {
81	h := newSidebarReactivityHarness()
82	h.hs.visible = true
83	h.hs.RequestReloadIfVisible("a")
84	h.hs.RequestReloadIfVisible("b")
85	require.Equal(t, 2, h.runIdle())
86	assert.Len(t, h.timers, 1)
87	assert.Len(t, h.removed, 1, "first debounce source should be removed")
88	assert.NotZero(t, h.hs.reloadDebounceTimer)
89}
90
91func TestHistorySidebar_ReloadDebounceCallbackReloadsOnceAndClearsTimer(t *testing.T) {
92	h := newSidebarReactivityHarness()
93	h.hs.visible = true
94	h.hs.RequestReloadIfVisible("history-change")
95	require.Equal(t, 1, h.runIdle())
96	timerID, cb := h.singleTimer(t)
97	require.Equal(t, timerID, h.hs.reloadDebounceTimer)
98
99	keep := cb(0)
100
101	assert.False(t, keep, "debounce timeout should be one-shot")
102	assert.Zero(t, h.hs.reloadDebounceTimer)
103	assert.True(t, h.hs.loadStarted, "debounce should call Reload and start a history load")
104}
105
106func TestHistorySidebar_ReloadDebounceCallbackSkipsHiddenSidebar(t *testing.T) {
107	h := newSidebarReactivityHarness()
108	h.hs.visible = true
109	h.hs.RequestReloadIfVisible("history-change")
110	require.Equal(t, 1, h.runIdle())
111	_, cb := h.singleTimer(t)
112	h.hs.visible = false
113
114	keep := cb(0)
115
116	assert.False(t, keep, "debounce timeout should be one-shot")
117	assert.Zero(t, h.hs.reloadDebounceTimer)
118	assert.False(t, h.hs.loadStarted, "hidden sidebar should not reload when debounce fires")
119}
120
121func TestHistorySidebar_ReloadDebounceCallbackIgnoresSupersededTimer(t *testing.T) {
122	h := newSidebarReactivityHarness()
123	h.hs.visible = true
124	h.hs.RequestReloadIfVisible("first")
125	require.Equal(t, 1, h.runIdle())
126	firstID, staleCB := h.singleTimer(t)
127
128	h.hs.RequestReloadIfVisible("second")
129	require.Equal(t, 1, h.runIdle())
130	currentID, currentCB := h.singleTimer(t)
131	require.NotEqual(t, firstID, currentID)
132	require.Equal(t, currentID, h.hs.reloadDebounceTimer)
133
134	keep := staleCB(0)
135
136	assert.False(t, keep, "stale debounce timeout should be one-shot")
137	assert.Equal(t, currentID, h.hs.reloadDebounceTimer)
138	assert.False(t, h.hs.loadStarted, "superseded debounce should not reload")
139
140	keep = currentCB(0)
141	assert.False(t, keep)
142	assert.Zero(t, h.hs.reloadDebounceTimer)
143	assert.True(t, h.hs.loadStarted, "current debounce should still reload")
144}
145
146func TestHistorySidebar_DestroyCancelsReloadTickerAndClearsBindings(t *testing.T) {
147	h := newSidebarReactivityHarness()
148	h.hs.visible = true
149	h.hs.reloadDebounceTimer = 51
150	h.hs.relativeTimeTicker = 52
151	h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime()}}
152
153	h.hs.Destroy()
154
155	h.hs.mu.RLock()
156	destroyed := h.hs.destroyed
157	reloadTimer := h.hs.reloadDebounceTimer
158	ticker := h.hs.relativeTimeTicker
159	bindings := h.hs.relativeTimeLabelBinds
160	h.hs.mu.RUnlock()
161	assert.True(t, destroyed)
162	assert.Zero(t, reloadTimer)
163	assert.Zero(t, ticker)
164	assert.Empty(t, bindings)
165	assert.Contains(t, h.removed, uint(51))
166	assert.Contains(t, h.removed, uint(52))
167}
168
169func TestHistorySidebar_HideCancelsReloadTickerAndClearsBindingsWithoutDestroying(t *testing.T) {
170	h := newSidebarReactivityHarness()
171	h.hs.visible = true
172	h.hs.reloadDebounceTimer = 61
173	h.hs.relativeTimeTicker = 62
174	h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime()}}
175
176	h.hs.Hide()
177
178	h.hs.mu.RLock()
179	destroyed := h.hs.destroyed
180	visible := h.hs.visible
181	reloadTimer := h.hs.reloadDebounceTimer
182	ticker := h.hs.relativeTimeTicker
183	bindings := h.hs.relativeTimeLabelBinds
184	h.hs.mu.RUnlock()
185	assert.False(t, destroyed)
186	assert.False(t, visible)
187	assert.Zero(t, reloadTimer)
188	assert.Zero(t, ticker)
189	assert.Empty(t, bindings)
190	assert.Contains(t, h.removed, uint(61))
191	assert.Contains(t, h.removed, uint(62))
192}
193
194func TestHistorySidebar_RelativeTimeBindingsLifecycleAndTicker(t *testing.T) {
195	h := newSidebarReactivityHarness()
196	h.hs.visible = true
197	h.hs.bindRelativeTimeLabel(nil, h.hs.currentTime())
198	assert.Empty(t, h.hs.relativeTimeLabelBinds, "nil labels are skipped")
199	h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime().Add(-time.Minute)}}
200	h.hs.mu.Lock()
201	h.hs.clearRelativeTimeBindingsLocked()
202	h.hs.mu.Unlock()
203	assert.Empty(t, h.hs.relativeTimeLabelBinds)
204
205	h.hs.startRelativeTimeTicker()
206	assert.NotZero(t, h.hs.relativeTimeTicker)
207	assert.Len(t, h.timers, 1)
208	assert.Equal(t, 1, h.runIdle(), "initial label refresh is scheduled through idle path")
209
210	ticker := h.hs.relativeTimeTicker
211	h.hs.visible = false
212	h.hs.relativeTimeTicker = 0
213	h.hs.removeSource(ticker)
214	assert.Contains(t, h.removed, ticker)
215}
216
217func TestHistorySidebar_RelativeTimeTickerIgnoresSupersededTimer(t *testing.T) {
218	h := newSidebarReactivityHarness()
219	h.hs.visible = true
220
221	h.hs.startRelativeTimeTicker()
222	firstID, staleCB := h.singleTimer(t)
223	require.Equal(t, 1, h.runIdle(), "clear initial refresh")
224
225	h.hs.startRelativeTimeTicker()
226	currentID, currentCB := h.singleTimer(t)
227	require.NotEqual(t, firstID, currentID)
228	require.Equal(t, currentID, h.hs.relativeTimeTicker)
229	require.Equal(t, 1, h.runIdle(), "clear replacement initial refresh")
230
231	keep := staleCB(0)
232
233	assert.False(t, keep, "stale ticker should stop")
234	assert.Equal(t, currentID, h.hs.relativeTimeTicker)
235	assert.Zero(t, h.runIdle(), "stale ticker should not schedule refresh")
236
237	keep = currentCB(0)
238	assert.True(t, keep, "current ticker should keep running while visible")
239	assert.Equal(t, 1, h.runIdle(), "current ticker schedules refresh")
240}
241
242func TestHistorySidebar_RelativeTimeDayBoundaryRequestsReloadNoRepoCall(t *testing.T) {
243	h := newSidebarReactivityHarness()
244	day1 := time.Date(2026, 6, 24, 23, 59, 0, 0, time.UTC)
245	day2 := day1.Add(2 * time.Minute)
246	now := day1
247	h.hs.now = func() time.Time { return now }
248	h.hs.visible = true
249	h.hs.relativeTimeDayKey = h.hs.currentDayKey()
250	h.hs.relativeTimeDayKeySet = true
251	h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: day1.Add(-time.Hour)}}
252	now = day2
253	h.hs.updateRelativeTimeLabelsOnMain()
254	assert.False(t, h.hs.loadStarted, "day boundary should not reload synchronously")
255	assert.Equal(t, 1, h.runIdle(), "day boundary schedules debounced reload via RequestReloadIfVisible")
256	assert.False(t, h.hs.loadStarted, "day boundary should wait for debounce before loading")
257	assert.Equal(t, h.hs.currentDayKey(), h.hs.relativeTimeDayKey)
258	assert.Len(t, h.timers, 1)
259}
260
261func TestRelativeTimeAtDeterministic(t *testing.T) {
262	now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
263	assert.Equal(t, "1m ago", relativeTimeAt(now.Add(-time.Minute), now))
264	assert.Equal(t, "2h ago", relativeTimeAt(now.Add(-2*time.Hour), now))
265}
266
267func TestHistorySidebar_RelativeRefreshDoesNotTouchHistoryRepo(t *testing.T) {
268	h := newSidebarReactivityHarness()
269	h.hs.historyUC = nil
270	h.hs.visible = true
271	h.hs.relativeTimeLabelBinds = []relativeTimeLabelBinding{{lastVisited: h.hs.currentTime().Add(-time.Minute)}}
272	h.hs.updateRelativeTimeLabelsOnMain()
273	assert.Empty(t, h.timers, "label-only refresh does not schedule reload/repo work without day boundary")
274}
275
276func TestHistorySidebar_ReloadStillPreservesDisplayRowsSemantics(t *testing.T) {
277	hs := newTestSidebarSearchHarness()
278	entry := &entity.HistoryEntry{URL: "https://example.com", LastVisited: time.Now()}
279	hs.setDisplayGroupsLocked(groupHistoryByDay([]*entity.HistoryEntry{entry}))
280	require.Len(t, hs.displayRows, 2)
281	assert.Equal(t, entry.URL, hs.entryURLAtIndex(1))
282}

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