detached 041cf3d

Merge pull request #169 from bnema/feat/purego-callback-lifecycle

feat(lifecycle): coalesce idle callbacks and bump puregotk dependencies
commit
041cf3d0bdac9db310b6aaa4b31e14d977917f04
author
bnema · · 2026-02-12 18:53 UTC
committer
GitHub · · 2026-02-12 18:53 UTC
merge of
96b6ff4 + d4df575 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
14 files · +321 -50
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 -buildvcs=false -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 -buildvcs=false -o /tmp/dumber ./cmd/dumber && rm -f /tmp/dumber
M .gitignore +2 -0
@@ -79,6 +79,8 @@ notes/
7979 docs/plans/
8080 scripts/
8181 !scripts/generate-types.js
82+!scripts/verify_purego_only.sh
83+!scripts/stress_omnibox_callbacks.go
8284 specs/
8385 templates/
8486 .serena/
@@ -79,6 +79,8 @@ notes/
79 docs/plans/79 docs/plans/
80 scripts/80 scripts/
81 !scripts/generate-types.js81 !scripts/generate-types.js
82+!scripts/verify_purego_only.sh
83+!scripts/stress_omnibox_callbacks.go
82 specs/84 specs/
83 templates/85 templates/
84 .serena/86 .serena/
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 generated file: collapsed
M internal/domain/download/filename.go +20 -0
@@ -46,6 +46,22 @@ func SanitizeFilenameWithExtension(name, mimeType string) string {
4646 return clean
4747 }
4848
49+// preferredExtensions maps MIME types whose stdlib extension list is
50+// platform-dependent (alphabetical order) to the canonical extension.
51+// Without this, mime.ExtensionsByType("text/html") may return ".ehtml"
52+// instead of ".html" depending on the system MIME database.
53+var preferredExtensions = map[string]string{
54+ "text/html": ".html",
55+ "text/plain": ".txt",
56+ "text/xml": ".xml",
57+ "application/xhtml+xml": ".xhtml",
58+ "image/jpeg": ".jpg",
59+ "image/svg+xml": ".svg",
60+ "audio/mpeg": ".mp3",
61+ "video/mp4": ".mp4",
62+ "application/octet-stream": ".bin",
63+}
64+
4965 // GetExtensionFromMimeType returns a file extension for a given MIME type.
5066 // Returns empty string if MIME type is unknown or empty.
5167 // Handles MIME types with parameters (e.g., "application/pdf; charset=binary").
@@ -61,6 +77,10 @@ func GetExtensionFromMimeType(mimeType string) string {
6177 return ""
6278 }
6379
80+ if ext, ok := preferredExtensions[mediaType]; ok {
81+ return ext
82+ }
83+
6484 exts, err := mime.ExtensionsByType(mediaType)
6585 if err != nil || len(exts) == 0 {
6686 return ""
@@ -46,6 +46,22 @@ func SanitizeFilenameWithExtension(name, mimeType string) string {
46 return clean46 return clean
47 }47 }
48 48
49+// preferredExtensions maps MIME types whose stdlib extension list is
50+// platform-dependent (alphabetical order) to the canonical extension.
51+// Without this, mime.ExtensionsByType("text/html") may return ".ehtml"
52+// instead of ".html" depending on the system MIME database.
53+var preferredExtensions = map[string]string{
54+ "text/html": ".html",
55+ "text/plain": ".txt",
56+ "text/xml": ".xml",
57+ "application/xhtml+xml": ".xhtml",
58+ "image/jpeg": ".jpg",
59+ "image/svg+xml": ".svg",
60+ "audio/mpeg": ".mp3",
61+ "video/mp4": ".mp4",
62+ "application/octet-stream": ".bin",
63+}
64+
49 // GetExtensionFromMimeType returns a file extension for a given MIME type.65 // GetExtensionFromMimeType returns a file extension for a given MIME type.
50 // Returns empty string if MIME type is unknown or empty.66 // Returns empty string if MIME type is unknown or empty.
51 // Handles MIME types with parameters (e.g., "application/pdf; charset=binary").67 // Handles MIME types with parameters (e.g., "application/pdf; charset=binary").
@@ -61,6 +77,10 @@ func GetExtensionFromMimeType(mimeType string) string {
61 return ""77 return ""
62 }78 }
63 79
80+ if ext, ok := preferredExtensions[mediaType]; ok {
81+ return ext
82+ }
83+
64 exts, err := mime.ExtensionsByType(mediaType)84 exts, err := mime.ExtensionsByType(mediaType)
65 if err != nil || len(exts) == 0 {85 if err != nil || len(exts) == 0 {
66 return ""86 return ""
M internal/domain/download/filename_test.go +1 -1
@@ -147,7 +147,7 @@ func TestGetExtensionFromMimeType(t *testing.T) {
147147 {
148148 name: "text html with charset",
149149 input: "text/html; charset=utf-8",
150- expected: ".htm",
150+ expected: ".html",
151151 },
152152 {
153153 name: "mime type with multiple params",
@@ -147,7 +147,7 @@ func TestGetExtensionFromMimeType(t *testing.T) {
147 {147 {
148 name: "text html with charset",148 name: "text html with charset",
149 input: "text/html; charset=utf-8",149 input: "text/html; charset=utf-8",
150- expected: ".htm",150+ expected: ".html",
151 },151 },
152 {152 {
153 name: "mime type with multiple params",153 name: "mime type with multiple params",
M internal/ui/component/link_status.go +57 -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,9 @@ func (l *LinkStatusOverlay) Cleanup() {
185217 glib.SourceRemove(l.autoHideTimer)
186218 l.autoHideTimer = 0
187219 }
220+ if l.coalescer != nil {
221+ l.coalescer.Destroy()
222+ }
188223 l.pendingURI = ""
189224 l.visible = false
190225 }
@@ -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,9 @@ 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+ }
188 l.pendingURI = ""223 l.pendingURI = ""
189 l.visible = false224 l.visible = false
190 }225 }
M internal/ui/component/omnibox.go +22 -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,9 @@ 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+ }
23402343
23412344 if o.outerBox != nil {
23422345 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,9 @@ 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+ }
2340 2343
2341 if o.outerBox != nil {2344 if o.outerBox != nil {
2342 o.outerBox.Unparent()2345 o.outerBox.Unparent()
A internal/ui/component/omnibox_callback_pressure_test.go +13 -0
@@ -0,0 +1,13 @@
1+package component
2+
3+import (
4+ "os"
5+ "testing"
6+)
7+
8+func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) {
9+ if os.Getenv("OMNIBOX_STRESS") != "1" {
10+ t.Skip("skipping: set OMNIBOX_STRESS=1 to run with GTK stress harness")
11+ }
12+ // TODO: wire GTK stress harness and assert no abrupt crash markers
13+}
@@ -0,0 +1,13 @@
1+package component
2+
3+import (
4+ "os"
5+ "testing"
6+)
7+
8+func TestOmniboxCallbackPressure_NoCrashSignal(t *testing.T) {
9+ if os.Getenv("OMNIBOX_STRESS") != "1" {
10+ t.Skip("skipping: set OMNIBOX_STRESS=1 to run with GTK stress harness")
11+ }
12+ // TODO: wire GTK stress harness and assert no abrupt crash markers
13+}
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+}
A scripts/stress_omnibox_callbacks.go +18 -0
@@ -0,0 +1,18 @@
1+package main
2+
3+import (
4+ "flag"
5+ "fmt"
6+ "os"
7+)
8+
9+const defaultIterations = 5000
10+
11+func main() {
12+ iterations := flag.Int("iterations", defaultIterations, "number of synthetic omnibox updates")
13+ flag.Parse()
14+
15+ fmt.Printf("stress harness placeholder: iterations=%d\n", *iterations)
16+ fmt.Println("TODO: attach to a live dumber session and drive omnibox updates")
17+ os.Exit(0)
18+}
@@ -0,0 +1,18 @@
1+package main
2+
3+import (
4+ "flag"
5+ "fmt"
6+ "os"
7+)
8+
9+const defaultIterations = 5000
10+
11+func main() {
12+ iterations := flag.Int("iterations", defaultIterations, "number of synthetic omnibox updates")
13+ flag.Parse()
14+
15+ fmt.Printf("stress harness placeholder: iterations=%d\n", *iterations)
16+ fmt.Println("TODO: attach to a live dumber session and drive omnibox updates")
17+ os.Exit(0)
18+}
A scripts/verify_purego_only.sh +42 -0
@@ -0,0 +1,42 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+# Use the directory containing this script's parent as repo root,
5+# avoiding git which fails in CI with "dubious ownership" errors.
6+repo_root="$(cd "$(dirname "$0")/.." && pwd)"
7+cd "$repo_root"
8+
9+denied_patterns=(
10+ 'import "C"'
11+ '^//export '
12+)
13+
14+all_paths=(cmd internal pkg scripts)
15+search_paths=()
16+for p in "${all_paths[@]}"; do
17+ [ -d "$p" ] && search_paths+=("$p")
18+done
19+
20+if [ ${#search_paths[@]} -eq 0 ]; then
21+ echo "verify_purego_only: no source directories found"
22+ exit 1
23+fi
24+
25+for pattern in "${denied_patterns[@]}"; do
26+ if command -v rg >/dev/null 2>&1; then
27+ if rg -n --glob '*.go' "$pattern" "${search_paths[@]}" >/dev/null; then
28+ echo "Forbidden pattern detected: $pattern"
29+ rg -n --glob '*.go' "$pattern" "${search_paths[@]}"
30+ exit 1
31+ fi
32+ continue
33+ fi
34+
35+ if grep -RInE --include='*.go' "$pattern" "${search_paths[@]}" >/dev/null; then
36+ echo "Forbidden pattern detected: $pattern"
37+ grep -RInE --include='*.go' "$pattern" "${search_paths[@]}"
38+ exit 1
39+ fi
40+done
41+
42+echo "verify_purego_only: OK"
@@ -0,0 +1,42 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+# Use the directory containing this script's parent as repo root,
5+# avoiding git which fails in CI with "dubious ownership" errors.
6+repo_root="$(cd "$(dirname "$0")/.." && pwd)"
7+cd "$repo_root"
8+
9+denied_patterns=(
10+ 'import "C"'
11+ '^//export '
12+)
13+
14+all_paths=(cmd internal pkg scripts)
15+search_paths=()
16+for p in "${all_paths[@]}"; do
17+ [ -d "$p" ] && search_paths+=("$p")
18+done
19+
20+if [ ${#search_paths[@]} -eq 0 ]; then
21+ echo "verify_purego_only: no source directories found"
22+ exit 1
23+fi
24+
25+for pattern in "${denied_patterns[@]}"; do
26+ if command -v rg >/dev/null 2>&1; then
27+ if rg -n --glob '*.go' "$pattern" "${search_paths[@]}" >/dev/null; then
28+ echo "Forbidden pattern detected: $pattern"
29+ rg -n --glob '*.go' "$pattern" "${search_paths[@]}"
30+ exit 1
31+ fi
32+ continue
33+ fi
34+
35+ if grep -RInE --include='*.go' "$pattern" "${search_paths[@]}" >/dev/null; then
36+ echo "Forbidden pattern detected: $pattern"
37+ grep -RInE --include='*.go' "$pattern" "${search_paths[@]}"
38+ exit 1
39+ fi
40+done
41+
42+echo "verify_purego_only: OK"

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