detached 4d39f29

fix(context-menu): harden popup and clipboard fallbacks

commit
4d39f292aa79d6012bb1218b0b50cde50e9b74a0
author
brice · · 2026-04-14 07:41 UTC
committer
brice · · 2026-04-14 12:06 UTC
parent
e55f218
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +129 -7
M internal/infrastructure/cef/context_menu.go +10 -2
@@ -122,10 +122,11 @@ func logContextMenuPopupRequest(
122122 if h == nil || h.wv == nil || h.wv.ctx == nil || h.wv.pipeline == nil || glArea == nil {
123123 return
124124 }
125+ rawX, rawY := contextMenuRawPosition(params)
125126 parent := glArea.GetParent()
126127 logging.FromContext(h.wv.ctx).Debug().
127- Int32("raw_x", params.GetXcoord()).
128- Int32("raw_y", params.GetYcoord()).
128+ Int32("raw_x", rawX).
129+ Int32("raw_y", rawY).
129130 Int32("popup_x", x).
130131 Int32("popup_y", y).
131132 Int32("scale", h.wv.pipeline.scale).
@@ -136,6 +137,13 @@ func logContextMenuPopupRequest(
136137 Msg("cef: context menu popup request")
137138 }
138139
140+func contextMenuRawPosition(params purecef.ContextMenuParams) (int32, int32) {
141+ if params == nil {
142+ return 0, 0
143+ }
144+ return params.GetXcoord(), params.GetYcoord()
145+}
146+
139147 func cefWidgetAllocatedWidth(widget *gtk.Widget) int {
140148 if widget == nil {
141149 return 0
@@ -122,10 +122,11 @@ func logContextMenuPopupRequest(
122 if h == nil || h.wv == nil || h.wv.ctx == nil || h.wv.pipeline == nil || glArea == nil {122 if h == nil || h.wv == nil || h.wv.ctx == nil || h.wv.pipeline == nil || glArea == nil {
123 return123 return
124 }124 }
125+ rawX, rawY := contextMenuRawPosition(params)
125 parent := glArea.GetParent()126 parent := glArea.GetParent()
126 logging.FromContext(h.wv.ctx).Debug().127 logging.FromContext(h.wv.ctx).Debug().
127- Int32("raw_x", params.GetXcoord()).128+ Int32("raw_x", rawX).
128- Int32("raw_y", params.GetYcoord()).129+ Int32("raw_y", rawY).
129 Int32("popup_x", x).130 Int32("popup_x", x).
130 Int32("popup_y", y).131 Int32("popup_y", y).
131 Int32("scale", h.wv.pipeline.scale).132 Int32("scale", h.wv.pipeline.scale).
@@ -136,6 +137,13 @@ func logContextMenuPopupRequest(
136 Msg("cef: context menu popup request")137 Msg("cef: context menu popup request")
137 }138 }
138 139
140+func contextMenuRawPosition(params purecef.ContextMenuParams) (int32, int32) {
141+ if params == nil {
142+ return 0, 0
143+ }
144+ return params.GetXcoord(), params.GetYcoord()
145+}
146+
139 func cefWidgetAllocatedWidth(widget *gtk.Widget) int {147 func cefWidgetAllocatedWidth(widget *gtk.Widget) int {
140 if widget == nil {148 if widget == nil {
141 return 0149 return 0
M internal/infrastructure/cef/context_menu_adapter_test.go +7 -0
@@ -172,3 +172,10 @@ func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) {
172172 require.Equal(t, int32(160), x)
173173 require.Equal(t, int32(90), y)
174174 }
175+
176+func TestContextMenuRawPositionDefaultsToZeroWhenParamsNil(t *testing.T) {
177+ x, y := contextMenuRawPosition(nil)
178+
179+ require.Zero(t, x)
180+ require.Zero(t, y)
181+}
@@ -172,3 +172,10 @@ func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) {
172 require.Equal(t, int32(160), x)172 require.Equal(t, int32(160), x)
173 require.Equal(t, int32(90), y)173 require.Equal(t, int32(90), y)
174 }174 }
175+
176+func TestContextMenuRawPositionDefaultsToZeroWhenParamsNil(t *testing.T) {
177+ x, y := contextMenuRawPosition(nil)
178+
179+ require.Zero(t, x)
180+ require.Zero(t, y)
181+}
M internal/infrastructure/clipboard/clipboard.go +83 -5
@@ -13,11 +13,13 @@ import (
1313 "github.com/bnema/dumber/internal/domain/entity"
1414 "github.com/bnema/dumber/internal/logging"
1515 "github.com/bnema/puregotk/v4/gdk"
16+ "github.com/bnema/puregotk/v4/gio"
1617 "github.com/bnema/puregotk/v4/glib"
1718 "github.com/rs/zerolog"
1819 )
1920
2021 type toolkitClipboard interface {
22+ ReadText(ctx context.Context) (string, error)
2123 WriteText(ctx context.Context, text string) error
2224 WriteImage(ctx context.Context, image entity.ImageData) error
2325 }
@@ -82,6 +84,49 @@ func (c *gdkToolkitClipboard) WriteText(ctx context.Context, text string) error
8284 })
8385 }
8486
87+func (c *gdkToolkitClipboard) ReadText(ctx context.Context) (string, error) {
88+ var text string
89+ err := withToolkitClipboard(ctx, func() error {
90+ if c == nil || c.clipboard == nil {
91+ return fmt.Errorf("toolkit clipboard not available")
92+ }
93+
94+ resultCh := make(chan struct {
95+ text string
96+ err error
97+ }, 1)
98+ cb := gio.AsyncReadyCallback(func(_ uintptr, result uintptr, _ uintptr) {
99+ asyncResult := &gio.AsyncResultBase{}
100+ asyncResult.SetGoPointer(result)
101+ readText, readErr := c.clipboard.ReadTextFinish(asyncResult)
102+ resultCh <- struct {
103+ text string
104+ err error
105+ }{text: readText, err: readErr}
106+ })
107+ c.clipboard.ReadTextAsync(nil, &cb, 0)
108+
109+ mainContext := glib.MainContextDefault()
110+ if mainContext == nil {
111+ return fmt.Errorf("toolkit main context not available")
112+ }
113+
114+ for {
115+ select {
116+ case result := <-resultCh:
117+ text = result.text
118+ return result.err
119+ default:
120+ }
121+ if ctx.Err() != nil {
122+ return ctx.Err()
123+ }
124+ mainContext.Iteration(true)
125+ }
126+ })
127+ return text, err
128+}
129+
85130 func (c *gdkToolkitClipboard) WriteImage(ctx context.Context, image entity.ImageData) error {
86131 return withToolkitClipboard(ctx, func() error {
87132 if c == nil || c.clipboard == nil {
@@ -288,10 +333,7 @@ func (a *Adapter) writeImageWithCommand(ctx context.Context, image entity.ImageD
288333 return nil
289334 }
290335
291-// ReadText reads text from the clipboard.
292-func (a *Adapter) ReadText(ctx context.Context) (string, error) {
293- log := logging.FromContext(ctx)
294-
336+func (a *Adapter) readTextWithCommand(ctx context.Context, log *zerolog.Logger) (string, error) {
295337 if a.pasteCmd == "" {
296338 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
297339 log.Error().Err(err).Msg("clipboard read failed")
@@ -313,7 +355,8 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) {
313355
314356 out, err := cmd.Output()
315357 if err != nil {
316- log.Debug().Err(err).Str("tool", a.pasteCmd).Msg("clipboard read failed (may be empty)")
358+ err = fmt.Errorf("run clipboard tool %s: %w", a.pasteCmd, err)
359+ log.Debug().Err(err).Str("tool", a.pasteCmd).Msg("clipboard read failed")
317360 return "", err
318361 }
319362
@@ -321,6 +364,41 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) {
321364 return string(out), nil
322365 }
323366
367+// ReadText reads text from the clipboard.
368+func (a *Adapter) ReadText(ctx context.Context) (string, error) {
369+ log := logging.FromContext(ctx)
370+ var commandErr error
371+
372+ if a.pasteCmd != "" {
373+ text, err := a.readTextWithCommand(ctx, log)
374+ if err == nil {
375+ return text, nil
376+ }
377+ commandErr = err
378+ log.Debug().Err(err).Msg("system clipboard read failed; falling back")
379+ }
380+
381+ if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
382+ text, err := toolkit.ReadText(ctx)
383+ if err == nil {
384+ log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard read success")
385+ return text, nil
386+ }
387+ log.Debug().Err(err).Msg("toolkit clipboard read failed")
388+ if commandErr != nil {
389+ return "", commandErr
390+ }
391+ return "", err
392+ }
393+
394+ if commandErr != nil {
395+ return "", commandErr
396+ }
397+ err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
398+ log.Error().Err(err).Msg("clipboard read failed")
399+ return "", err
400+}
401+
324402 // Clear clears the clipboard contents.
325403 func (a *Adapter) Clear(ctx context.Context) error {
326404 return a.WriteText(ctx, "")
@@ -13,11 +13,13 @@ import (
13 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
15 "github.com/bnema/puregotk/v4/gdk"15 "github.com/bnema/puregotk/v4/gdk"
16+ "github.com/bnema/puregotk/v4/gio"
16 "github.com/bnema/puregotk/v4/glib"17 "github.com/bnema/puregotk/v4/glib"
17 "github.com/rs/zerolog"18 "github.com/rs/zerolog"
18 )19 )
19 20
20 type toolkitClipboard interface {21 type toolkitClipboard interface {
22+ ReadText(ctx context.Context) (string, error)
21 WriteText(ctx context.Context, text string) error23 WriteText(ctx context.Context, text string) error
22 WriteImage(ctx context.Context, image entity.ImageData) error24 WriteImage(ctx context.Context, image entity.ImageData) error
23 }25 }
@@ -82,6 +84,49 @@ func (c *gdkToolkitClipboard) WriteText(ctx context.Context, text string) error
82 })84 })
83 }85 }
84 86
87+func (c *gdkToolkitClipboard) ReadText(ctx context.Context) (string, error) {
88+ var text string
89+ err := withToolkitClipboard(ctx, func() error {
90+ if c == nil || c.clipboard == nil {
91+ return fmt.Errorf("toolkit clipboard not available")
92+ }
93+
94+ resultCh := make(chan struct {
95+ text string
96+ err error
97+ }, 1)
98+ cb := gio.AsyncReadyCallback(func(_ uintptr, result uintptr, _ uintptr) {
99+ asyncResult := &gio.AsyncResultBase{}
100+ asyncResult.SetGoPointer(result)
101+ readText, readErr := c.clipboard.ReadTextFinish(asyncResult)
102+ resultCh <- struct {
103+ text string
104+ err error
105+ }{text: readText, err: readErr}
106+ })
107+ c.clipboard.ReadTextAsync(nil, &cb, 0)
108+
109+ mainContext := glib.MainContextDefault()
110+ if mainContext == nil {
111+ return fmt.Errorf("toolkit main context not available")
112+ }
113+
114+ for {
115+ select {
116+ case result := <-resultCh:
117+ text = result.text
118+ return result.err
119+ default:
120+ }
121+ if ctx.Err() != nil {
122+ return ctx.Err()
123+ }
124+ mainContext.Iteration(true)
125+ }
126+ })
127+ return text, err
128+}
129+
85 func (c *gdkToolkitClipboard) WriteImage(ctx context.Context, image entity.ImageData) error {130 func (c *gdkToolkitClipboard) WriteImage(ctx context.Context, image entity.ImageData) error {
86 return withToolkitClipboard(ctx, func() error {131 return withToolkitClipboard(ctx, func() error {
87 if c == nil || c.clipboard == nil {132 if c == nil || c.clipboard == nil {
@@ -288,10 +333,7 @@ func (a *Adapter) writeImageWithCommand(ctx context.Context, image entity.ImageD
288 return nil333 return nil
289 }334 }
290 335
291-// ReadText reads text from the clipboard.336+func (a *Adapter) readTextWithCommand(ctx context.Context, log *zerolog.Logger) (string, error) {
292-func (a *Adapter) ReadText(ctx context.Context) (string, error) {
293- log := logging.FromContext(ctx)
294-
295 if a.pasteCmd == "" {337 if a.pasteCmd == "" {
296 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")338 err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
297 log.Error().Err(err).Msg("clipboard read failed")339 log.Error().Err(err).Msg("clipboard read failed")
@@ -313,7 +355,8 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) {
313 355
314 out, err := cmd.Output()356 out, err := cmd.Output()
315 if err != nil {357 if err != nil {
316- log.Debug().Err(err).Str("tool", a.pasteCmd).Msg("clipboard read failed (may be empty)")358+ err = fmt.Errorf("run clipboard tool %s: %w", a.pasteCmd, err)
359+ log.Debug().Err(err).Str("tool", a.pasteCmd).Msg("clipboard read failed")
317 return "", err360 return "", err
318 }361 }
319 362
@@ -321,6 +364,41 @@ func (a *Adapter) ReadText(ctx context.Context) (string, error) {
321 return string(out), nil364 return string(out), nil
322 }365 }
323 366
367+// ReadText reads text from the clipboard.
368+func (a *Adapter) ReadText(ctx context.Context) (string, error) {
369+ log := logging.FromContext(ctx)
370+ var commandErr error
371+
372+ if a.pasteCmd != "" {
373+ text, err := a.readTextWithCommand(ctx, log)
374+ if err == nil {
375+ return text, nil
376+ }
377+ commandErr = err
378+ log.Debug().Err(err).Msg("system clipboard read failed; falling back")
379+ }
380+
381+ if toolkit := a.ensureToolkitClipboard(); toolkit != nil {
382+ text, err := toolkit.ReadText(ctx)
383+ if err == nil {
384+ log.Debug().Str("backend", "toolkit").Int("len", len(text)).Msg("clipboard read success")
385+ return text, nil
386+ }
387+ log.Debug().Err(err).Msg("toolkit clipboard read failed")
388+ if commandErr != nil {
389+ return "", commandErr
390+ }
391+ return "", err
392+ }
393+
394+ if commandErr != nil {
395+ return "", commandErr
396+ }
397+ err := fmt.Errorf("no clipboard tool available (install wl-clipboard or xclip)")
398+ log.Error().Err(err).Msg("clipboard read failed")
399+ return "", err
400+}
401+
324 // Clear clears the clipboard contents.402 // Clear clears the clipboard contents.
325 func (a *Adapter) Clear(ctx context.Context) error {403 func (a *Adapter) Clear(ctx context.Context) error {
326 return a.WriteText(ctx, "")404 return a.WriteText(ctx, "")
M internal/infrastructure/clipboard/clipboard_test.go +29 -0
@@ -14,10 +14,12 @@ import (
1414
1515 type fakeToolkitClipboard struct {
1616 writeTextCalls int
17+ readTextCalls int
1718 writeImageCalls int
1819 text string
1920 image entity.ImageData
2021 writeTextErr error
22+ readTextErr error
2123 writeImageErr error
2224 }
2325
@@ -27,12 +29,39 @@ func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error {
2729 return f.writeTextErr
2830 }
2931
32+func (f *fakeToolkitClipboard) ReadText(_ context.Context) (string, error) {
33+ f.readTextCalls++
34+ return f.text, f.readTextErr
35+}
36+
3037 func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image entity.ImageData) error {
3138 f.writeImageCalls++
3239 f.image = image
3340 return f.writeImageErr
3441 }
3542
43+func TestAdapter_ReadTextFallsBackToToolkitClipboardWhenNoSystemTool(t *testing.T) {
44+ fake := &fakeToolkitClipboard{text: "toolkit text"}
45+ adapter := &Adapter{toolkit: fake}
46+
47+ text, err := adapter.ReadText(context.Background())
48+
49+ require.NoError(t, err)
50+ require.Equal(t, "toolkit text", text)
51+ require.Equal(t, 1, fake.readTextCalls)
52+}
53+
54+func TestAdapter_HasTextReturnsTrueWhenToolkitFallbackHasText(t *testing.T) {
55+ fake := &fakeToolkitClipboard{text: "toolkit text"}
56+ adapter := &Adapter{toolkit: fake}
57+
58+ hasText, err := adapter.HasText(context.Background())
59+
60+ require.NoError(t, err)
61+ require.True(t, hasText)
62+ require.Equal(t, 1, fake.readTextCalls)
63+}
64+
3665 func TestAdapter_WriteTextReturnsToolkitErrorWhenNoSystemTool(t *testing.T) {
3766 sentinel := errors.New("toolkit text failed")
3867 fake := &fakeToolkitClipboard{writeTextErr: sentinel}
@@ -14,10 +14,12 @@ import (
14 14
15 type fakeToolkitClipboard struct {15 type fakeToolkitClipboard struct {
16 writeTextCalls int16 writeTextCalls int
17+ readTextCalls int
17 writeImageCalls int18 writeImageCalls int
18 text string19 text string
19 image entity.ImageData20 image entity.ImageData
20 writeTextErr error21 writeTextErr error
22+ readTextErr error
21 writeImageErr error23 writeImageErr error
22 }24 }
23 25
@@ -27,12 +29,39 @@ func (f *fakeToolkitClipboard) WriteText(_ context.Context, text string) error {
27 return f.writeTextErr29 return f.writeTextErr
28 }30 }
29 31
32+func (f *fakeToolkitClipboard) ReadText(_ context.Context) (string, error) {
33+ f.readTextCalls++
34+ return f.text, f.readTextErr
35+}
36+
30 func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image entity.ImageData) error {37 func (f *fakeToolkitClipboard) WriteImage(_ context.Context, image entity.ImageData) error {
31 f.writeImageCalls++38 f.writeImageCalls++
32 f.image = image39 f.image = image
33 return f.writeImageErr40 return f.writeImageErr
34 }41 }
35 42
43+func TestAdapter_ReadTextFallsBackToToolkitClipboardWhenNoSystemTool(t *testing.T) {
44+ fake := &fakeToolkitClipboard{text: "toolkit text"}
45+ adapter := &Adapter{toolkit: fake}
46+
47+ text, err := adapter.ReadText(context.Background())
48+
49+ require.NoError(t, err)
50+ require.Equal(t, "toolkit text", text)
51+ require.Equal(t, 1, fake.readTextCalls)
52+}
53+
54+func TestAdapter_HasTextReturnsTrueWhenToolkitFallbackHasText(t *testing.T) {
55+ fake := &fakeToolkitClipboard{text: "toolkit text"}
56+ adapter := &Adapter{toolkit: fake}
57+
58+ hasText, err := adapter.HasText(context.Background())
59+
60+ require.NoError(t, err)
61+ require.True(t, hasText)
62+ require.Equal(t, 1, fake.readTextCalls)
63+}
64+
36 func TestAdapter_WriteTextReturnsToolkitErrorWhenNoSystemTool(t *testing.T) {65 func TestAdapter_WriteTextReturnsToolkitErrorWhenNoSystemTool(t *testing.T) {
37 sentinel := errors.New("toolkit text failed")66 sentinel := errors.New("toolkit text failed")
38 fake := &fakeToolkitClipboard{writeTextErr: sentinel}67 fake := &fakeToolkitClipboard{writeTextErr: sentinel}

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