detached 0600fe7

test(ui): clean up deferred toaster callbacks

commit
0600fe7b718b63b6aba757ab7222381891df81b2
author
brice · · 2026-08-12 08:58 UTC
parent
2548d05
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +35 -1
M internal/ui/app_browser_launch_test.go +23 -1
@@ -35,7 +35,9 @@ import (
3535 "github.com/bnema/dumber/internal/ui/window"
3636 "github.com/bnema/puregotk/v4/gdk"
3737 "github.com/bnema/puregotk/v4/gio"
38+ "github.com/bnema/puregotk/v4/glib"
3839 "github.com/bnema/puregotk/v4/gtk"
40+ "github.com/stretchr/testify/assert"
3941 "github.com/stretchr/testify/mock"
4042 "github.com/stretchr/testify/require"
4143 )
@@ -979,7 +981,9 @@ func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBox
979981 label.EXPECT().SetCanTarget(false).Once()
980982 label.EXPECT().SetCanFocus(false).Once()
981983
982- return component.NewToaster(factory), box, label
984+ toaster := component.NewToaster(factory)
985+ t.Cleanup(toaster.CancelAutoDismiss)
986+ return toaster, box, label
983987 }
984988
985989 func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
@@ -997,6 +1001,24 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
9971001 app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})
9981002 }
9991003
1004+func TestTestShellToasterCleanupCancelsAutoDismiss(t *testing.T) {
1005+ toaster, box, label := newTestShellToaster(t)
1006+ box.EXPECT().SetVisible(true).Once()
1007+ label.EXPECT().SetText("will not outlive this test").Once()
1008+ toaster.Show(context.Background(), "will not outlive this test", component.ToastInfo)
1009+ toaster.CancelAutoDismiss()
1010+
1011+ mainContext := glib.MainContextDefault()
1012+ deadline := time.Now().Add(component.ToastBriefDurationMs + 100*time.Millisecond)
1013+ for time.Now().Before(deadline) {
1014+ for mainContext.Pending() {
1015+ mainContext.Iteration(false)
1016+ }
1017+ time.Sleep(10 * time.Millisecond)
1018+ }
1019+ assert.True(t, toaster.IsVisible(), "canceling must prevent the deferred hide callback")
1020+}
1021+
10001022 func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {
10011023 ctx := context.Background()
10021024 toaster, box, label := newTestShellToaster(t)
@@ -35,7 +35,9 @@ import (
35 "github.com/bnema/dumber/internal/ui/window"35 "github.com/bnema/dumber/internal/ui/window"
36 "github.com/bnema/puregotk/v4/gdk"36 "github.com/bnema/puregotk/v4/gdk"
37 "github.com/bnema/puregotk/v4/gio"37 "github.com/bnema/puregotk/v4/gio"
38+ "github.com/bnema/puregotk/v4/glib"
38 "github.com/bnema/puregotk/v4/gtk"39 "github.com/bnema/puregotk/v4/gtk"
40+ "github.com/stretchr/testify/assert"
39 "github.com/stretchr/testify/mock"41 "github.com/stretchr/testify/mock"
40 "github.com/stretchr/testify/require"42 "github.com/stretchr/testify/require"
41 )43 )
@@ -979,7 +981,9 @@ func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBox
979 label.EXPECT().SetCanTarget(false).Once()981 label.EXPECT().SetCanTarget(false).Once()
980 label.EXPECT().SetCanFocus(false).Once()982 label.EXPECT().SetCanFocus(false).Once()
981 983
982- return component.NewToaster(factory), box, label984+ toaster := component.NewToaster(factory)
985+ t.Cleanup(toaster.CancelAutoDismiss)
986+ return toaster, box, label
983 }987 }
984 988
985 func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {989 func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
@@ -997,6 +1001,24 @@ func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
997 app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})1001 app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})
998 }1002 }
999 1003
1004+func TestTestShellToasterCleanupCancelsAutoDismiss(t *testing.T) {
1005+ toaster, box, label := newTestShellToaster(t)
1006+ box.EXPECT().SetVisible(true).Once()
1007+ label.EXPECT().SetText("will not outlive this test").Once()
1008+ toaster.Show(context.Background(), "will not outlive this test", component.ToastInfo)
1009+ toaster.CancelAutoDismiss()
1010+
1011+ mainContext := glib.MainContextDefault()
1012+ deadline := time.Now().Add(component.ToastBriefDurationMs + 100*time.Millisecond)
1013+ for time.Now().Before(deadline) {
1014+ for mainContext.Pending() {
1015+ mainContext.Iteration(false)
1016+ }
1017+ time.Sleep(10 * time.Millisecond)
1018+ }
1019+ assert.True(t, toaster.IsVisible(), "canceling must prevent the deferred hide callback")
1020+}
1021+
1000 func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {1022 func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {
1001 ctx := context.Background()1023 ctx := context.Background()
1002 toaster, box, label := newTestShellToaster(t)1024 toaster, box, label := newTestShellToaster(t)
M internal/ui/component/toaster.go +12 -0
@@ -276,6 +276,18 @@ func (t *Toaster) Hide() {
276276 t.hide()
277277 }
278278
279+// CancelAutoDismiss removes a pending auto-dismiss source without changing the
280+// toast's visibility. It is useful when the owner is being torn down.
281+func (t *Toaster) CancelAutoDismiss() {
282+ t.mu.Lock()
283+ defer t.mu.Unlock()
284+
285+ if t.dismissTimer != 0 {
286+ glib.SourceRemove(t.dismissTimer)
287+ t.dismissTimer = 0
288+ }
289+}
290+
279291 // hide performs the actual hide operation (must be called with lock held).
280292 func (t *Toaster) hide() {
281293 if !t.visible {
@@ -276,6 +276,18 @@ func (t *Toaster) Hide() {
276 t.hide()276 t.hide()
277 }277 }
278 278
279+// CancelAutoDismiss removes a pending auto-dismiss source without changing the
280+// toast's visibility. It is useful when the owner is being torn down.
281+func (t *Toaster) CancelAutoDismiss() {
282+ t.mu.Lock()
283+ defer t.mu.Unlock()
284+
285+ if t.dismissTimer != 0 {
286+ glib.SourceRemove(t.dismissTimer)
287+ t.dismissTimer = 0
288+ }
289+}
290+
279 // hide performs the actual hide operation (must be called with lock held).291 // hide performs the actual hide operation (must be called with lock held).
280 func (t *Toaster) hide() {292 func (t *Toaster) hide() {
281 if !t.visible {293 if !t.visible {

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