detached 87e495c

feat(lifecycle): coalesce idle callbacks and bump puregotk dependencies

Add mainloop.Coalescer to deduplicate bursts of same-key IdleAdd calls,
preventing purego callback slot exhaustion under rapid UI events.

Refactor omnibox and link_status to use coalescer instead of allocating
fresh glib.SourceFunc closures per event. Lift timer callbacks to struct
fields so they are allocated once.

Bump puregotk to fb13c24 (refcounted callback lifecycle) and
puregotk-webkit to v0.0.13. Remove local replace directives in favor
of GitHub module refs.

Add CI steps for verify-purego and CGO-disabled build.
commit
87e495cd62bc68e74b2482d17acb960b72eb22e4
author
brice · · 2026-02-12 18:21 UTC
parent
96b6ff4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +234 -49
M .github/workflows/ci.yml +4 -0
@@ -109,3 +109,7 @@ jobs:
109109 GDK_BACKEND: wayland
110110 WAYLAND_DISPLAY: wayland-0
111111 run: go test -v ./...
112+ - name: Verify purego-only constraints
113+ run: make verify-purego
114+ - name: Verify CGO-disabled build
115+ run: CGO_ENABLED=0 go build -o /tmp/dumber ./cmd/dumber && rm -f /tmp/dumber
@@ -109,3 +109,7 @@ jobs:
109 GDK_BACKEND: wayland109 GDK_BACKEND: wayland
110 WAYLAND_DISPLAY: wayland-0110 WAYLAND_DISPLAY: wayland-0
111 run: go test -v ./...111 run: go test -v ./...
112+ - name: Verify purego-only constraints
113+ run: make verify-purego
114+ - name: Verify CGO-disabled build
115+ run: CGO_ENABLED=0 go build -o /tmp/dumber ./cmd/dumber && rm -f /tmp/dumber
M Makefile +9 -1
@@ -1,6 +1,6 @@
11 # Makefile for dumber (Clean Architecture - puregotk)
22
3-.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean
3+.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego
44
55 # Load local overrides from .env.local if present (Makefile syntax)
66 ifneq (,$(wildcard .env.local))
@@ -84,6 +84,12 @@ test-cover: ## Run tests with coverage
8484 go tool cover -html=coverage.out -o coverage.html
8585 @echo "Coverage report: coverage.html"
8686
87+stress-omnibox-callbacks: ## Run placeholder omnibox callback stress harness
88+ GOFLAGS=-mod=mod CGO_ENABLED=0 go run ./scripts/stress_omnibox_callbacks.go
89+
90+verify-purego: ## Ensure callback path stays cgo/export free
91+ bash ./scripts/verify_purego_only.sh
92+
8793 # Linting
8894 lint: ## Run golangci-lint
8995 @echo "Running golangci-lint..."
@@ -135,6 +141,8 @@ check: ## Check that all tools and dependencies are working
135141 @$(MAKE) build-quick
136142 @echo "\nRunning tests..."
137143 @$(MAKE) test
144+ @echo "\nVerifying purego-only constraints..."
145+ @$(MAKE) verify-purego
138146 @echo "\nAll checks passed!"
139147
140148 # Documentation
@@ -1,6 +1,6 @@
1 # Makefile for dumber (Clean Architecture - puregotk)1 # Makefile for dumber (Clean Architecture - puregotk)
2 2
3-.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean3+.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego
4 4
5 # Load local overrides from .env.local if present (Makefile syntax)5 # Load local overrides from .env.local if present (Makefile syntax)
6 ifneq (,$(wildcard .env.local))6 ifneq (,$(wildcard .env.local))
@@ -84,6 +84,12 @@ test-cover: ## Run tests with coverage
84 go tool cover -html=coverage.out -o coverage.html84 go tool cover -html=coverage.out -o coverage.html
85 @echo "Coverage report: coverage.html"85 @echo "Coverage report: coverage.html"
86 86
87+stress-omnibox-callbacks: ## Run placeholder omnibox callback stress harness
88+ GOFLAGS=-mod=mod CGO_ENABLED=0 go run ./scripts/stress_omnibox_callbacks.go
89+
90+verify-purego: ## Ensure callback path stays cgo/export free
91+ bash ./scripts/verify_purego_only.sh
92+
87 # Linting93 # Linting
88 lint: ## Run golangci-lint94 lint: ## Run golangci-lint
89 @echo "Running golangci-lint..."95 @echo "Running golangci-lint..."
@@ -135,6 +141,8 @@ check: ## Check that all tools and dependencies are working
135 @$(MAKE) build-quick141 @$(MAKE) build-quick
136 @echo "\nRunning tests..."142 @echo "\nRunning tests..."
137 @$(MAKE) test143 @$(MAKE) test
144+ @echo "\nVerifying purego-only constraints..."
145+ @$(MAKE) verify-purego
138 @echo "\nAll checks passed!"146 @echo "\nAll checks passed!"
139 147
140 # Documentation148 # Documentation
M go.mod +3 -3
@@ -3,14 +3,14 @@ module github.com/bnema/dumber
33 go 1.25.3
44
55 require (
6- github.com/bnema/puregotk-webkit v0.0.12
6+ github.com/bnema/puregotk-webkit v0.0.13
77 github.com/charmbracelet/bubbles v1.0.0
88 github.com/charmbracelet/bubbletea v1.3.10
99 github.com/charmbracelet/lipgloss v1.1.0
1010 github.com/fsnotify/fsnotify v1.9.0
1111 github.com/godbus/dbus/v5 v5.2.2
1212 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42
13- github.com/jwijenbergh/puregotk v0.0.0-20260115100645-c78e1521129b
13+ github.com/jwijenbergh/puregotk v0.0.0-20260212181341-fb13c245f9f0
1414 github.com/ncruces/go-sqlite3 v0.30.5
1515 github.com/pelletier/go-toml/v2 v2.2.4
1616 github.com/pressly/goose/v3 v3.26.0
@@ -23,7 +23,7 @@ require (
2323 gopkg.in/yaml.v3 v3.0.1
2424 )
2525
26-replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92
26+replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0
2727
2828 require (
2929 github.com/atotto/clipboard v0.1.4 // indirect
@@ -3,14 +3,14 @@ module github.com/bnema/dumber
3 go 1.25.33 go 1.25.3
4 4
5 require (5 require (
6- github.com/bnema/puregotk-webkit v0.0.126+ github.com/bnema/puregotk-webkit v0.0.13
7 github.com/charmbracelet/bubbles v1.0.07 github.com/charmbracelet/bubbles v1.0.0
8 github.com/charmbracelet/bubbletea v1.3.108 github.com/charmbracelet/bubbletea v1.3.10
9 github.com/charmbracelet/lipgloss v1.1.09 github.com/charmbracelet/lipgloss v1.1.0
10 github.com/fsnotify/fsnotify v1.9.010 github.com/fsnotify/fsnotify v1.9.0
11 github.com/godbus/dbus/v5 v5.2.211 github.com/godbus/dbus/v5 v5.2.2
12 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba4212 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42
13- github.com/jwijenbergh/puregotk v0.0.0-20260115100645-c78e1521129b13+ github.com/jwijenbergh/puregotk v0.0.0-20260212181341-fb13c245f9f0
14 github.com/ncruces/go-sqlite3 v0.30.514 github.com/ncruces/go-sqlite3 v0.30.5
15 github.com/pelletier/go-toml/v2 v2.2.415 github.com/pelletier/go-toml/v2 v2.2.4
16 github.com/pressly/goose/v3 v3.26.016 github.com/pressly/goose/v3 v3.26.0
@@ -23,7 +23,7 @@ require (
23 gopkg.in/yaml.v3 v3.0.123 gopkg.in/yaml.v3 v3.0.1
24 )24 )
25 25
26-replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed9226+replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0
27 27
28 require (28 require (
29 github.com/atotto/clipboard v0.1.4 // indirect29 github.com/atotto/clipboard v0.1.4 // indirect
M go.sum +4 -4
@@ -4,10 +4,10 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
44 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
55 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
66 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
7-github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92 h1:rprl5N0eR8wMIv27i8Zz0UeYqewhTPWYr6QNP4n76hs=
8-github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc=
9-github.com/bnema/puregotk-webkit v0.0.12 h1:cvE+EZr0TiKJj1EeXbtOkNMU4RSgGXXclKl8OcSX63Y=
10-github.com/bnema/puregotk-webkit v0.0.12/go.mod h1:kITL8qc4l1BGKl03TCrnMqVc2xPJ2Rf6+ihH+N6kdJo=
7+github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0 h1:oV1Tlh5COw+K3ua6PnezSuVeFi/CxQ05DhhrzKYt+4Q=
8+github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc=
9+github.com/bnema/puregotk-webkit v0.0.13 h1:0j99i2a7GPhCQiIaV2LQ4n2p3Tw0q//V/CccDDU4M3A=
10+github.com/bnema/puregotk-webkit v0.0.13/go.mod h1:wc+zzGdAy51YgyG1N3iwkQIusgCY4lFHGJUQgi4svJ0=
1111 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
1212 github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
1313 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -4,10 +4,10 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
4 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=4 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
5 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=5 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
6 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=6 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
7-github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92 h1:rprl5N0eR8wMIv27i8Zz0UeYqewhTPWYr6QNP4n76hs=7+github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0 h1:oV1Tlh5COw+K3ua6PnezSuVeFi/CxQ05DhhrzKYt+4Q=
8-github.com/bnema/puregotk v0.0.0-20260208062357-b1825488ed92/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc=8+github.com/bnema/puregotk v0.0.0-20260212181341-fb13c245f9f0/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc=
9-github.com/bnema/puregotk-webkit v0.0.12 h1:cvE+EZr0TiKJj1EeXbtOkNMU4RSgGXXclKl8OcSX63Y=9+github.com/bnema/puregotk-webkit v0.0.13 h1:0j99i2a7GPhCQiIaV2LQ4n2p3Tw0q//V/CccDDU4M3A=
10-github.com/bnema/puregotk-webkit v0.0.12/go.mod h1:kITL8qc4l1BGKl03TCrnMqVc2xPJ2Rf6+ihH+N6kdJo=10+github.com/bnema/puregotk-webkit v0.0.13/go.mod h1:wc+zzGdAy51YgyG1N3iwkQIusgCY4lFHGJUQgi4svJ0=
11 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=11 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
12 github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=12 github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
13 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=13 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
M internal/ui/component/link_status.go +58 -22
@@ -4,6 +4,7 @@ package component
44 import (
55 "sync"
66
7+ "github.com/bnema/dumber/internal/ui/mainloop"
78 "github.com/jwijenbergh/puregotk/v4/glib"
89 "github.com/jwijenbergh/puregotk/v4/gtk"
910
@@ -30,6 +31,9 @@ type LinkStatusOverlay struct {
3031 showTimer uint // GLib timer for delayed show
3132 autoHideTimer uint // GLib timer for auto-hide after timeout
3233 visible bool // Whether overlay is currently visible (has .visible class)
34+ showTimerCb glib.SourceFunc
35+ autoHideCb glib.SourceFunc
36+ coalescer *mainloop.Coalescer
3337 mu sync.Mutex
3438 }
3539
@@ -60,12 +64,44 @@ func NewLinkStatusOverlay(factory layout.WidgetFactory) *LinkStatusOverlay {
6064 label.SetMaxWidthChars(linkStatusMaxChars)
6165 container.Append(label)
6266
63- return &LinkStatusOverlay{
67+ overlay := &LinkStatusOverlay{
6468 factory: factory,
6569 container: container,
6670 label: label,
6771 visible: false,
6872 }
73+ overlay.coalescer = mainloop.NewCoalescer(func(fn func()) {
74+ var cb glib.SourceFunc = func(uintptr) bool {
75+ fn()
76+ return false
77+ }
78+ glib.IdleAdd(&cb, 0)
79+ })
80+ overlay.showTimerCb = func(_ uintptr) bool {
81+ overlay.mu.Lock()
82+ defer overlay.mu.Unlock()
83+
84+ if overlay.pendingURI != "" {
85+ overlay.label.SetText(overlay.pendingURI)
86+ if !overlay.visible {
87+ overlay.visible = true
88+ overlay.container.AddCssClass("visible")
89+ }
90+ overlay.resetAutoHideTimerLocked()
91+ }
92+ overlay.showTimer = 0
93+ return false
94+ }
95+ overlay.autoHideCb = func(_ uintptr) bool {
96+ overlay.mu.Lock()
97+ defer overlay.mu.Unlock()
98+
99+ overlay.autoHideTimer = 0
100+ overlay.hide()
101+ return false
102+ }
103+
104+ return overlay
69105 }
70106
71107 // Show displays the link status overlay with the given URI.
@@ -88,25 +124,19 @@ func (l *LinkStatusOverlay) Show(uri string) {
88124
89125 l.pendingURI = uri
90126
91- // Start delay timer before showing
92- cb := glib.SourceFunc(func(_ uintptr) bool {
127+ // Coalesce rapid hover events before scheduling delay timer.
128+ l.coalescer.Post("link-status-show-delay", func() {
93129 l.mu.Lock()
94130 defer l.mu.Unlock()
95131
96- if l.pendingURI != "" {
97- l.label.SetText(l.pendingURI)
98- if !l.visible {
99- l.visible = true
100- l.container.AddCssClass("visible")
101- }
102- // Start or reset auto-hide timer
103- l.resetAutoHideTimerLocked()
132+ if l.pendingURI == "" {
133+ return
104134 }
105- l.showTimer = 0
106- return false // Don't repeat
135+ if l.showTimer != 0 {
136+ glib.SourceRemove(l.showTimer)
137+ }
138+ l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &l.showTimerCb, 0)
107139 })
108-
109- l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &cb, 0)
110140 }
111141
112142 // Hide manually hides the link status overlay.
@@ -144,17 +174,19 @@ func (l *LinkStatusOverlay) resetAutoHideTimerLocked() {
144174 l.autoHideTimer = 0
145175 }
146176
147- // Start new auto-hide timer
148- cb := glib.SourceFunc(func(_ uintptr) bool {
177+ // Coalesce timer resets while pointer moves quickly across links.
178+ l.coalescer.Post("link-status-auto-hide", func() {
149179 l.mu.Lock()
150180 defer l.mu.Unlock()
151181
152- l.autoHideTimer = 0
153- l.hide()
154- return false // Don't repeat
182+ if l.pendingURI == "" {
183+ return
184+ }
185+ if l.autoHideTimer != 0 {
186+ glib.SourceRemove(l.autoHideTimer)
187+ }
188+ l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &l.autoHideCb, 0)
155189 })
156-
157- l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &cb, 0)
158190 }
159191
160192 // Widget returns the underlying widget for embedding in overlays.
@@ -185,6 +217,10 @@ func (l *LinkStatusOverlay) Cleanup() {
185217 glib.SourceRemove(l.autoHideTimer)
186218 l.autoHideTimer = 0
187219 }
220+ if l.coalescer != nil {
221+ l.coalescer.Destroy()
222+ l.coalescer = nil
223+ }
188224 l.pendingURI = ""
189225 l.visible = false
190226 }
@@ -4,6 +4,7 @@ package component
4 import (4 import (
5 "sync"5 "sync"
6 6
7+ "github.com/bnema/dumber/internal/ui/mainloop"
7 "github.com/jwijenbergh/puregotk/v4/glib"8 "github.com/jwijenbergh/puregotk/v4/glib"
8 "github.com/jwijenbergh/puregotk/v4/gtk"9 "github.com/jwijenbergh/puregotk/v4/gtk"
9 10
@@ -30,6 +31,9 @@ type LinkStatusOverlay struct {
30 showTimer uint // GLib timer for delayed show31 showTimer uint // GLib timer for delayed show
31 autoHideTimer uint // GLib timer for auto-hide after timeout32 autoHideTimer uint // GLib timer for auto-hide after timeout
32 visible bool // Whether overlay is currently visible (has .visible class)33 visible bool // Whether overlay is currently visible (has .visible class)
34+ showTimerCb glib.SourceFunc
35+ autoHideCb glib.SourceFunc
36+ coalescer *mainloop.Coalescer
33 mu sync.Mutex37 mu sync.Mutex
34 }38 }
35 39
@@ -60,12 +64,44 @@ func NewLinkStatusOverlay(factory layout.WidgetFactory) *LinkStatusOverlay {
60 label.SetMaxWidthChars(linkStatusMaxChars)64 label.SetMaxWidthChars(linkStatusMaxChars)
61 container.Append(label)65 container.Append(label)
62 66
63- return &LinkStatusOverlay{67+ overlay := &LinkStatusOverlay{
64 factory: factory,68 factory: factory,
65 container: container,69 container: container,
66 label: label,70 label: label,
67 visible: false,71 visible: false,
68 }72 }
73+ overlay.coalescer = mainloop.NewCoalescer(func(fn func()) {
74+ var cb glib.SourceFunc = func(uintptr) bool {
75+ fn()
76+ return false
77+ }
78+ glib.IdleAdd(&cb, 0)
79+ })
80+ overlay.showTimerCb = func(_ uintptr) bool {
81+ overlay.mu.Lock()
82+ defer overlay.mu.Unlock()
83+
84+ if overlay.pendingURI != "" {
85+ overlay.label.SetText(overlay.pendingURI)
86+ if !overlay.visible {
87+ overlay.visible = true
88+ overlay.container.AddCssClass("visible")
89+ }
90+ overlay.resetAutoHideTimerLocked()
91+ }
92+ overlay.showTimer = 0
93+ return false
94+ }
95+ overlay.autoHideCb = func(_ uintptr) bool {
96+ overlay.mu.Lock()
97+ defer overlay.mu.Unlock()
98+
99+ overlay.autoHideTimer = 0
100+ overlay.hide()
101+ return false
102+ }
103+
104+ return overlay
69 }105 }
70 106
71 // Show displays the link status overlay with the given URI.107 // Show displays the link status overlay with the given URI.
@@ -88,25 +124,19 @@ func (l *LinkStatusOverlay) Show(uri string) {
88 124
89 l.pendingURI = uri125 l.pendingURI = uri
90 126
91- // Start delay timer before showing127+ // Coalesce rapid hover events before scheduling delay timer.
92- cb := glib.SourceFunc(func(_ uintptr) bool {128+ l.coalescer.Post("link-status-show-delay", func() {
93 l.mu.Lock()129 l.mu.Lock()
94 defer l.mu.Unlock()130 defer l.mu.Unlock()
95 131
96- if l.pendingURI != "" {132+ if l.pendingURI == "" {
97- l.label.SetText(l.pendingURI)133+ return
98- if !l.visible {
99- l.visible = true
100- l.container.AddCssClass("visible")
101- }
102- // Start or reset auto-hide timer
103- l.resetAutoHideTimerLocked()
104 }134 }
105- l.showTimer = 0135+ if l.showTimer != 0 {
106- return false // Don't repeat136+ glib.SourceRemove(l.showTimer)
137+ }
138+ l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &l.showTimerCb, 0)
107 })139 })
108-
109- l.showTimer = glib.TimeoutAdd(linkStatusShowDelayMs, &cb, 0)
110 }140 }
111 141
112 // Hide manually hides the link status overlay.142 // Hide manually hides the link status overlay.
@@ -144,17 +174,19 @@ func (l *LinkStatusOverlay) resetAutoHideTimerLocked() {
144 l.autoHideTimer = 0174 l.autoHideTimer = 0
145 }175 }
146 176
147- // Start new auto-hide timer177+ // Coalesce timer resets while pointer moves quickly across links.
148- cb := glib.SourceFunc(func(_ uintptr) bool {178+ l.coalescer.Post("link-status-auto-hide", func() {
149 l.mu.Lock()179 l.mu.Lock()
150 defer l.mu.Unlock()180 defer l.mu.Unlock()
151 181
152- l.autoHideTimer = 0182+ if l.pendingURI == "" {
153- l.hide()183+ return
154- return false // Don't repeat184+ }
185+ if l.autoHideTimer != 0 {
186+ glib.SourceRemove(l.autoHideTimer)
187+ }
188+ l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &l.autoHideCb, 0)
155 })189 })
156-
157- l.autoHideTimer = glib.TimeoutAdd(linkStatusAutoHideMs, &cb, 0)
158 }190 }
159 191
160 // Widget returns the underlying widget for embedding in overlays.192 // Widget returns the underlying widget for embedding in overlays.
@@ -185,6 +217,10 @@ func (l *LinkStatusOverlay) Cleanup() {
185 glib.SourceRemove(l.autoHideTimer)217 glib.SourceRemove(l.autoHideTimer)
186 l.autoHideTimer = 0218 l.autoHideTimer = 0
187 }219 }
220+ if l.coalescer != nil {
221+ l.coalescer.Destroy()
222+ l.coalescer = nil
223+ }
188 l.pendingURI = ""224 l.pendingURI = ""
189 l.visible = false225 l.visible = false
190 }226 }
M internal/ui/component/omnibox.go +23 -19
@@ -17,6 +17,7 @@ import (
1717 "github.com/bnema/dumber/internal/ui/adapter"
1818 "github.com/bnema/dumber/internal/ui/input"
1919 "github.com/bnema/dumber/internal/ui/layout"
20+ "github.com/bnema/dumber/internal/ui/mainloop"
2021 "github.com/jwijenbergh/puregotk/v4/gdk"
2122 "github.com/jwijenbergh/puregotk/v4/glib"
2223 "github.com/jwijenbergh/puregotk/v4/graphene"
@@ -138,6 +139,7 @@ type Omnibox struct {
138139 // Click outside handler
139140 clickOutsideController *gtk.GestureClick
140141 clickOutsideCb func(gtk.GestureClick, int, float64, float64)
142+ idleCoalescer *mainloop.Coalescer
141143 }
142144
143145 // OmniboxConfig holds configuration for creating an Omnibox.
@@ -182,6 +184,13 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
182184 ctx: ctx,
183185 uiScale: uiScale,
184186 }
187+ o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {
188+ var cb glib.SourceFunc = func(uintptr) bool {
189+ fn()
190+ return false
191+ }
192+ glib.IdleAdd(&cb, 0)
193+ })
185194
186195 if err := o.createWidgets(); err != nil {
187196 log.Error().Err(err).Msg("failed to create omnibox widgets")
@@ -817,12 +826,7 @@ func (o *Omnibox) onEntryChanged() {
817826 o.debounceTimer.Stop()
818827 }
819828 o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() {
820- // Schedule on GTK main thread — performSearch reads GTK widget state
821- var cb glib.SourceFunc = func(uintptr) bool {
822- o.performSearch()
823- return false
824- }
825- glib.IdleAdd(&cb, 0)
829+ o.idleCoalescer.Post("omnibox-search", o.performSearch)
826830 })
827831 o.debounceMu.Unlock()
828832 }
@@ -879,9 +883,9 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
879883 capturedToken := o.ghostToken
880884 o.mu.Unlock()
881885
882- var cb glib.SourceFunc = func(uintptr) bool {
886+ o.idleCoalescer.Post("omnibox-ghost-set", func() {
883887 if o.entry == nil {
884- return false
888+ return
885889 }
886890
887891 o.mu.RLock()
@@ -889,10 +893,10 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
889893 currentToken := o.ghostToken
890894 o.mu.RUnlock()
891895 if currentInput != originalInput {
892- return false
896+ return
893897 }
894898 if capturedToken != currentToken {
895- return false
899+ return
896900 }
897901 o.mu.Lock()
898902 o.isAcceptingGhost = true
@@ -905,9 +909,7 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
905909 o.mu.Lock()
906910 o.isAcceptingGhost = false
907911 o.mu.Unlock()
908- return false
909- }
910- glib.IdleAdd(&cb, 0)
912+ })
911913 }
912914
913915 // clearGhostText hides the ghost completion text.
@@ -927,15 +929,15 @@ func (o *Omnibox) clearGhostText() {
927929 return
928930 }
929931
930- var cb glib.SourceFunc = func(uintptr) bool {
932+ o.idleCoalescer.Post("omnibox-ghost-clear", func() {
931933 if o.entry == nil {
932- return false
934+ return
933935 }
934936 o.mu.RLock()
935937 currentToken := o.ghostToken
936938 o.mu.RUnlock()
937939 if capturedToken != currentToken {
938- return false
940+ return
939941 }
940942 o.mu.Lock()
941943 o.isAcceptingGhost = true
@@ -950,9 +952,7 @@ func (o *Omnibox) clearGhostText() {
950952 o.mu.Lock()
951953 o.isAcceptingGhost = false
952954 o.mu.Unlock()
953- return false
954- }
955- glib.IdleAdd(&cb, 0)
955+ })
956956 }
957957
958958 // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.
@@ -2337,6 +2337,10 @@ func (o *Omnibox) Destroy() {
23372337 o.retainedCallbacks = nil
23382338 o.clickOutsideController = nil
23392339 o.clickOutsideCb = nil
2340+ if o.idleCoalescer != nil {
2341+ o.idleCoalescer.Destroy()
2342+ o.idleCoalescer = nil
2343+ }
23402344
23412345 if o.outerBox != nil {
23422346 o.outerBox.Unparent()
@@ -17,6 +17,7 @@ import (
17 "github.com/bnema/dumber/internal/ui/adapter"17 "github.com/bnema/dumber/internal/ui/adapter"
18 "github.com/bnema/dumber/internal/ui/input"18 "github.com/bnema/dumber/internal/ui/input"
19 "github.com/bnema/dumber/internal/ui/layout"19 "github.com/bnema/dumber/internal/ui/layout"
20+ "github.com/bnema/dumber/internal/ui/mainloop"
20 "github.com/jwijenbergh/puregotk/v4/gdk"21 "github.com/jwijenbergh/puregotk/v4/gdk"
21 "github.com/jwijenbergh/puregotk/v4/glib"22 "github.com/jwijenbergh/puregotk/v4/glib"
22 "github.com/jwijenbergh/puregotk/v4/graphene"23 "github.com/jwijenbergh/puregotk/v4/graphene"
@@ -138,6 +139,7 @@ type Omnibox struct {
138 // Click outside handler139 // Click outside handler
139 clickOutsideController *gtk.GestureClick140 clickOutsideController *gtk.GestureClick
140 clickOutsideCb func(gtk.GestureClick, int, float64, float64)141 clickOutsideCb func(gtk.GestureClick, int, float64, float64)
142+ idleCoalescer *mainloop.Coalescer
141 }143 }
142 144
143 // OmniboxConfig holds configuration for creating an Omnibox.145 // OmniboxConfig holds configuration for creating an Omnibox.
@@ -182,6 +184,13 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
182 ctx: ctx,184 ctx: ctx,
183 uiScale: uiScale,185 uiScale: uiScale,
184 }186 }
187+ o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {
188+ var cb glib.SourceFunc = func(uintptr) bool {
189+ fn()
190+ return false
191+ }
192+ glib.IdleAdd(&cb, 0)
193+ })
185 194
186 if err := o.createWidgets(); err != nil {195 if err := o.createWidgets(); err != nil {
187 log.Error().Err(err).Msg("failed to create omnibox widgets")196 log.Error().Err(err).Msg("failed to create omnibox widgets")
@@ -817,12 +826,7 @@ func (o *Omnibox) onEntryChanged() {
817 o.debounceTimer.Stop()826 o.debounceTimer.Stop()
818 }827 }
819 o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() {828 o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() {
820- // Schedule on GTK main thread — performSearch reads GTK widget state829+ o.idleCoalescer.Post("omnibox-search", o.performSearch)
821- var cb glib.SourceFunc = func(uintptr) bool {
822- o.performSearch()
823- return false
824- }
825- glib.IdleAdd(&cb, 0)
826 })830 })
827 o.debounceMu.Unlock()831 o.debounceMu.Unlock()
828 }832 }
@@ -879,9 +883,9 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
879 capturedToken := o.ghostToken883 capturedToken := o.ghostToken
880 o.mu.Unlock()884 o.mu.Unlock()
881 885
882- var cb glib.SourceFunc = func(uintptr) bool {886+ o.idleCoalescer.Post("omnibox-ghost-set", func() {
883 if o.entry == nil {887 if o.entry == nil {
884- return false888+ return
885 }889 }
886 890
887 o.mu.RLock()891 o.mu.RLock()
@@ -889,10 +893,10 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
889 currentToken := o.ghostToken893 currentToken := o.ghostToken
890 o.mu.RUnlock()894 o.mu.RUnlock()
891 if currentInput != originalInput {895 if currentInput != originalInput {
892- return false896+ return
893 }897 }
894 if capturedToken != currentToken {898 if capturedToken != currentToken {
895- return false899+ return
896 }900 }
897 o.mu.Lock()901 o.mu.Lock()
898 o.isAcceptingGhost = true902 o.isAcceptingGhost = true
@@ -905,9 +909,7 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
905 o.mu.Lock()909 o.mu.Lock()
906 o.isAcceptingGhost = false910 o.isAcceptingGhost = false
907 o.mu.Unlock()911 o.mu.Unlock()
908- return false912+ })
909- }
910- glib.IdleAdd(&cb, 0)
911 }913 }
912 914
913 // clearGhostText hides the ghost completion text.915 // clearGhostText hides the ghost completion text.
@@ -927,15 +929,15 @@ func (o *Omnibox) clearGhostText() {
927 return929 return
928 }930 }
929 931
930- var cb glib.SourceFunc = func(uintptr) bool {932+ o.idleCoalescer.Post("omnibox-ghost-clear", func() {
931 if o.entry == nil {933 if o.entry == nil {
932- return false934+ return
933 }935 }
934 o.mu.RLock()936 o.mu.RLock()
935 currentToken := o.ghostToken937 currentToken := o.ghostToken
936 o.mu.RUnlock()938 o.mu.RUnlock()
937 if capturedToken != currentToken {939 if capturedToken != currentToken {
938- return false940+ return
939 }941 }
940 o.mu.Lock()942 o.mu.Lock()
941 o.isAcceptingGhost = true943 o.isAcceptingGhost = true
@@ -950,9 +952,7 @@ func (o *Omnibox) clearGhostText() {
950 o.mu.Lock()952 o.mu.Lock()
951 o.isAcceptingGhost = false953 o.isAcceptingGhost = false
952 o.mu.Unlock()954 o.mu.Unlock()
953- return false955+ })
954- }
955- glib.IdleAdd(&cb, 0)
956 }956 }
957 957
958 // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.958 // clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.
@@ -2337,6 +2337,10 @@ func (o *Omnibox) Destroy() {
2337 o.retainedCallbacks = nil2337 o.retainedCallbacks = nil
2338 o.clickOutsideController = nil2338 o.clickOutsideController = nil
2339 o.clickOutsideCb = nil2339 o.clickOutsideCb = nil
2340+ if o.idleCoalescer != nil {
2341+ o.idleCoalescer.Destroy()
2342+ o.idleCoalescer = nil
2343+ }
2340 2344
2341 if o.outerBox != nil {2345 if o.outerBox != nil {
2342 o.outerBox.Unparent()2346 o.outerBox.Unparent()
A internal/ui/component/omnibox_callback_pressure_test.go +7 -0
@@ -0,0 +1,7 @@
1+package component
2+
3+import "testing"
4+
5+func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) {
6+ t.Skip("TODO: wire GTK stress harness and assert no abrupt crash markers")
7+}
@@ -0,0 +1,7 @@
1+package component
2+
3+import "testing"
4+
5+func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) {
6+ t.Skip("TODO: wire GTK stress harness and assert no abrupt crash markers")
7+}
A internal/ui/mainloop/coalescer.go +70 -0
@@ -0,0 +1,70 @@
1+package mainloop
2+
3+import "sync"
4+
5+// Coalescer merges bursts of same-key main-loop tasks.
6+type Coalescer struct {
7+ mu sync.Mutex
8+ pending map[string]bool
9+ callbacks map[string]func()
10+ post func(func())
11+ destroyed bool
12+}
13+
14+func NewCoalescer(post func(func())) *Coalescer {
15+ if post == nil {
16+ panic("mainloop.NewCoalescer: post function cannot be nil")
17+ }
18+
19+ return &Coalescer{
20+ pending: make(map[string]bool),
21+ callbacks: make(map[string]func()),
22+ post: post,
23+ }
24+}
25+
26+func (c *Coalescer) Post(key string, fn func()) {
27+ if fn == nil || key == "" {
28+ return
29+ }
30+
31+ c.mu.Lock()
32+ if c.destroyed {
33+ c.mu.Unlock()
34+ return
35+ }
36+ c.callbacks[key] = fn
37+ if c.pending[key] {
38+ c.mu.Unlock()
39+ return
40+ }
41+ c.pending[key] = true
42+ post := c.post
43+ c.mu.Unlock()
44+
45+ post(func() {
46+ c.mu.Lock()
47+ if c.destroyed {
48+ delete(c.pending, key)
49+ delete(c.callbacks, key)
50+ c.mu.Unlock()
51+ return
52+ }
53+ fn := c.callbacks[key]
54+ delete(c.pending, key)
55+ delete(c.callbacks, key)
56+ c.mu.Unlock()
57+
58+ if fn != nil {
59+ fn()
60+ }
61+ })
62+}
63+
64+func (c *Coalescer) Destroy() {
65+ c.mu.Lock()
66+ c.destroyed = true
67+ c.pending = map[string]bool{}
68+ c.callbacks = map[string]func(){}
69+ c.mu.Unlock()
70+}
@@ -0,0 +1,70 @@
1+package mainloop
2+
3+import "sync"
4+
5+// Coalescer merges bursts of same-key main-loop tasks.
6+type Coalescer struct {
7+ mu sync.Mutex
8+ pending map[string]bool
9+ callbacks map[string]func()
10+ post func(func())
11+ destroyed bool
12+}
13+
14+func NewCoalescer(post func(func())) *Coalescer {
15+ if post == nil {
16+ panic("mainloop.NewCoalescer: post function cannot be nil")
17+ }
18+
19+ return &Coalescer{
20+ pending: make(map[string]bool),
21+ callbacks: make(map[string]func()),
22+ post: post,
23+ }
24+}
25+
26+func (c *Coalescer) Post(key string, fn func()) {
27+ if fn == nil || key == "" {
28+ return
29+ }
30+
31+ c.mu.Lock()
32+ if c.destroyed {
33+ c.mu.Unlock()
34+ return
35+ }
36+ c.callbacks[key] = fn
37+ if c.pending[key] {
38+ c.mu.Unlock()
39+ return
40+ }
41+ c.pending[key] = true
42+ post := c.post
43+ c.mu.Unlock()
44+
45+ post(func() {
46+ c.mu.Lock()
47+ if c.destroyed {
48+ delete(c.pending, key)
49+ delete(c.callbacks, key)
50+ c.mu.Unlock()
51+ return
52+ }
53+ fn := c.callbacks[key]
54+ delete(c.pending, key)
55+ delete(c.callbacks, key)
56+ c.mu.Unlock()
57+
58+ if fn != nil {
59+ fn()
60+ }
61+ })
62+}
63+
64+func (c *Coalescer) Destroy() {
65+ c.mu.Lock()
66+ c.destroyed = true
67+ c.pending = map[string]bool{}
68+ c.callbacks = map[string]func(){}
69+ c.mu.Unlock()
70+}
A internal/ui/mainloop/coalescer_test.go +56 -0
@@ -0,0 +1,56 @@
1+package mainloop
2+
3+import "testing"
4+
5+func TestCoalescerMergesBurstIntoSingleIdle(t *testing.T) {
6+ queue := make([]func(), 0, 8)
7+ c := NewCoalescer(func(fn func()) { queue = append(queue, fn) })
8+
9+ value := 0
10+ for i := 1; i <= 5; i++ {
11+ v := i
12+ c.Post("omnibox-search", func() { value = v })
13+ }
14+
15+ if len(queue) != 1 {
16+ t.Fatalf("expected 1 scheduled callback, got %d", len(queue))
17+ }
18+ queue[0]()
19+
20+ if value != 5 {
21+ t.Fatalf("expected latest callback to run, got %d", value)
22+ }
23+}
24+
25+func TestCoalescerDropsWorkAfterDestroy(t *testing.T) {
26+ queue := make([]func(), 0, 4)
27+ c := NewCoalescer(func(fn func()) { queue = append(queue, fn) })
28+
29+ ran := false
30+ c.Post("ghost-clear", func() { ran = true })
31+ c.Destroy()
32+
33+ if len(queue) != 1 {
34+ t.Fatalf("expected one queued callback before destroy, got %d", len(queue))
35+ }
36+ queue[0]()
37+
38+ if ran {
39+ t.Fatalf("expected queued work to be dropped after destroy")
40+ }
41+
42+ c.Post("ghost-clear", func() { ran = true })
43+ if len(queue) != 1 {
44+ t.Fatalf("expected no new callback after destroy, got %d", len(queue))
45+ }
46+}
47+
48+func TestNewCoalescerPanicsOnNilPost(t *testing.T) {
49+ defer func() {
50+ if r := recover(); r == nil {
51+ t.Fatalf("expected NewCoalescer to panic when post is nil")
52+ }
53+ }()
54+
55+ _ = NewCoalescer(nil)
56+}
@@ -0,0 +1,56 @@
1+package mainloop
2+
3+import "testing"
4+
5+func TestCoalescerMergesBurstIntoSingleIdle(t *testing.T) {
6+ queue := make([]func(), 0, 8)
7+ c := NewCoalescer(func(fn func()) { queue = append(queue, fn) })
8+
9+ value := 0
10+ for i := 1; i <= 5; i++ {
11+ v := i
12+ c.Post("omnibox-search", func() { value = v })
13+ }
14+
15+ if len(queue) != 1 {
16+ t.Fatalf("expected 1 scheduled callback, got %d", len(queue))
17+ }
18+ queue[0]()
19+
20+ if value != 5 {
21+ t.Fatalf("expected latest callback to run, got %d", value)
22+ }
23+}
24+
25+func TestCoalescerDropsWorkAfterDestroy(t *testing.T) {
26+ queue := make([]func(), 0, 4)
27+ c := NewCoalescer(func(fn func()) { queue = append(queue, fn) })
28+
29+ ran := false
30+ c.Post("ghost-clear", func() { ran = true })
31+ c.Destroy()
32+
33+ if len(queue) != 1 {
34+ t.Fatalf("expected one queued callback before destroy, got %d", len(queue))
35+ }
36+ queue[0]()
37+
38+ if ran {
39+ t.Fatalf("expected queued work to be dropped after destroy")
40+ }
41+
42+ c.Post("ghost-clear", func() { ran = true })
43+ if len(queue) != 1 {
44+ t.Fatalf("expected no new callback after destroy, got %d", len(queue))
45+ }
46+}
47+
48+func TestNewCoalescerPanicsOnNilPost(t *testing.T) {
49+ defer func() {
50+ if r := recover(); r == nil {
51+ t.Fatalf("expected NewCoalescer to panic when post is nil")
52+ }
53+ }()
54+
55+ _ = NewCoalescer(nil)
56+}

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