detached 5714070

Merge pull request #245 from bnema/feat/cef-file-dialog-support

Support CEF file dialogs with GTK
commit
5714070767712e6a8a1fb845495bebf9e5f2d0e0
author
bnema · · 2026-05-10 09:44 UTC
committer
GitHub · · 2026-05-10 09:44 UTC
merge of
ccddb61 + f479dae · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +802 -7
M go.mod +1 -1
@@ -6,7 +6,7 @@ require (
66 github.com/a-h/templ v0.3.1001
77 github.com/andybalholm/brotli v1.2.1
88 github.com/bnema/purego v0.11.0-bnema.2
9- github.com/bnema/purego-cef v0.12.0
9+ github.com/bnema/purego-cef v0.13.0
1010 github.com/bnema/purego-cef2gtk v0.1.0
1111 github.com/bnema/purego-pipewire v0.1.2
1212 github.com/bnema/purego-sqlite v0.1.2
@@ -6,7 +6,7 @@ require (
6 github.com/a-h/templ v0.3.10016 github.com/a-h/templ v0.3.1001
7 github.com/andybalholm/brotli v1.2.17 github.com/andybalholm/brotli v1.2.1
8 github.com/bnema/purego v0.11.0-bnema.28 github.com/bnema/purego v0.11.0-bnema.2
9- github.com/bnema/purego-cef v0.12.09+ github.com/bnema/purego-cef v0.13.0
10 github.com/bnema/purego-cef2gtk v0.1.010 github.com/bnema/purego-cef2gtk v0.1.0
11 github.com/bnema/purego-pipewire v0.1.211 github.com/bnema/purego-pipewire v0.1.2
12 github.com/bnema/purego-sqlite v0.1.212 github.com/bnema/purego-sqlite v0.1.2
M go.sum +2 -2 generated file: collapsed
A internal/infrastructure/cef/file_dialog.go +567 -0
@@ -0,0 +1,567 @@
1+package cef
2+
3+import (
4+ "context"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "strings"
10+ "unsafe"
11+
12+ purecef "github.com/bnema/purego-cef/cef"
13+ "github.com/bnema/puregotk/v4/gio"
14+ "github.com/bnema/puregotk/v4/gobject"
15+ "github.com/bnema/puregotk/v4/gtk"
16+ "github.com/rs/zerolog"
17+
18+ "github.com/bnema/dumber/internal/logging"
19+)
20+
21+type cefFileDialogRequest struct {
22+ Mode purecef.FileDialogMode
23+ Title string
24+ DefaultFilePath string
25+ AcceptFilters []string
26+ AcceptExtensions []string
27+ AcceptDescriptions []string
28+}
29+
30+type fileDialogPresenter func(*WebView, cefFileDialogRequest, purecef.FileDialogCallback)
31+
32+var maxExpandedFolderUploadFiles = 5000
33+
34+var (
35+ decodeCEFStringList = purecef.StringListToSlice
36+ newCEFStringList = purecef.NewStringList
37+ freeCEFStringList = purecef.FreeStringList
38+ continueCEFFileDialog = continueCEFFileDialogWithLogs
39+)
40+
41+func (h *handlerSet) OnFileDialog(
42+ _ purecef.Browser,
43+ mode purecef.FileDialogMode,
44+ title string,
45+ defaultFilePath string,
46+ acceptFilters purecef.StringList,
47+ acceptExtensions purecef.StringList,
48+ acceptDescriptions purecef.StringList,
49+ callback purecef.FileDialogCallback,
50+) int32 {
51+ if h == nil || h.wv == nil || callback == nil {
52+ return 0
53+ }
54+
55+ presenter := h.fileDialogPresenter
56+ if presenter == nil {
57+ presenter = presentGTKFileDialog
58+ }
59+
60+ req := cefFileDialogRequest{
61+ Mode: mode,
62+ Title: title,
63+ DefaultFilePath: defaultFilePath,
64+ AcceptFilters: decodeCEFStringList(acceptFilters),
65+ AcceptExtensions: decodeCEFStringList(acceptExtensions),
66+ AcceptDescriptions: decodeCEFStringList(acceptDescriptions),
67+ }
68+ logCEFFileDialog(h.wv).Debug().
69+ Int32("mode", mode).
70+ Str("title", title).
71+ Bool("has_default_file_path", strings.TrimSpace(defaultFilePath) != "").
72+ Int("accept_filter_count", len(req.AcceptFilters)).
73+ Int("accept_extension_count", len(req.AcceptExtensions)).
74+ Msg("cef: file dialog requested")
75+
76+ presenter(h.wv, req, callback)
77+ return 1
78+}
79+
80+func presentGTKFileDialog(wv *WebView, req cefFileDialogRequest, callback purecef.FileDialogCallback) {
81+ if callback == nil || wv == nil {
82+ return
83+ }
84+
85+ wv.runOnGTK(func() {
86+ logCEFFileDialog(wv).Debug().
87+ Int32("mode", req.Mode).
88+ Str("title", req.Title).
89+ Bool("has_default_file_path", strings.TrimSpace(req.DefaultFilePath) != "").
90+ Msg("cef: presenting GTK file dialog")
91+ dialog, ok := newNativeFileDialog(wv, req)
92+ if !ok || dialog == nil {
93+ dispatchFileDialogResult(wv, callback, nil)
94+ return
95+ }
96+
97+ onResponse := func(_ gtk.NativeDialog, responseID int) {
98+ defer func() {
99+ logCEFFileDialog(wv).Debug().Int32("mode", req.Mode).Msg("cef: releasing GTK file dialog")
100+ dialog.Unref()
101+ logCEFFileDialog(wv).Debug().Int32("mode", req.Mode).Msg("cef: released GTK file dialog")
102+ }()
103+
104+ normalizedResponseID := normalizeGTKResponseID(responseID)
105+ logCEFFileDialog(wv).Debug().
106+ Int("response_id_raw", responseID).
107+ Int("response_id_normalized", normalizedResponseID).
108+ Int32("mode", req.Mode).
109+ Msg("cef: GTK file dialog response received")
110+ if normalizedResponseID != int(gtk.ResponseAcceptValue) {
111+ logCEFFileDialog(wv).Debug().Msg("cef: file dialog canceled")
112+ dispatchFileDialogResult(wv, callback, nil)
113+ return
114+ }
115+
116+ paths := selectedFileDialogPaths(dialog, req.Mode)
117+ logCEFFileDialog(wv).Debug().
118+ Int("path_count", len(paths)).
119+ Msg("cef: file dialog selection captured")
120+ paths = folderUploadPaths(wv, req.Mode, paths)
121+ dispatchFileDialogResult(wv, callback, paths)
122+ }
123+ dialog.ConnectResponse(&onResponse)
124+ dialog.Show()
125+ })
126+}
127+
128+func newNativeFileDialog(wv *WebView, req cefFileDialogRequest) (*gtk.FileChooserNative, bool) {
129+ action, ok := fileChooserActionForMode(req.Mode)
130+ if !ok {
131+ logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: unsupported file dialog mode")
132+ return nil, false
133+ }
134+
135+ var titlePtr *string
136+ title := strings.TrimSpace(req.Title)
137+ if title != "" {
138+ titlePtr = &title
139+ }
140+
141+ dialog := gtk.NewFileChooserNative(titlePtr, fileDialogParentWindow(wv), action, nil, nil)
142+ if dialog == nil {
143+ logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: failed to create GTK file chooser dialog")
144+ return nil, false
145+ }
146+
147+ dialog.SetModal(true)
148+ if req.Mode == purecef.FileDialogModeFileDialogOpenMultiple {
149+ dialog.SetSelectMultiple(true)
150+ }
151+ applyDefaultDialogPath(dialog, req)
152+ applyDialogFilters(dialog, req)
153+ return dialog, true
154+}
155+
156+func fileChooserActionForMode(mode purecef.FileDialogMode) (gtk.FileChooserAction, bool) {
157+ switch mode {
158+ case purecef.FileDialogModeFileDialogOpen, purecef.FileDialogModeFileDialogOpenMultiple:
159+ return gtk.FileChooserActionOpenValue, true
160+ case purecef.FileDialogModeFileDialogOpenFolder:
161+ return gtk.FileChooserActionSelectFolderValue, true
162+ case purecef.FileDialogModeFileDialogSave:
163+ return gtk.FileChooserActionSaveValue, true
164+ default:
165+ return 0, false
166+ }
167+}
168+
169+func fileDialogParentWindow(wv *WebView) *gtk.Window {
170+ if wv == nil || wv.viewBridge == nil {
171+ return nil
172+ }
173+ widget := wv.viewBridge.Widget()
174+ if widget == nil {
175+ return nil
176+ }
177+ ancestor := widget.GetAncestor(gtk.WindowGLibType())
178+ if ancestor == nil {
179+ return nil
180+ }
181+ // puregotk's GetAncestor wrapper adds a reference before returning.
182+ defer ancestor.Unref()
183+ return gtk.WindowNewFromInternalPtr(ancestor.GoPointer())
184+}
185+
186+// defaultDialogPathInfo holds the result of parsing DefaultFilePath for use by
187+// applyDefaultDialogPath. It is a pure value type to remain unit-testable without
188+// GTK objects.
189+type defaultDialogPathInfo struct {
190+ setFolder string // path to set as current folder (empty = don't set)
191+ setName string // name to set as current name (empty = don't set)
192+ setFile string // path to set as selected file (empty = don't set)
193+}
194+
195+// parseDefaultDialogPath extracts folder/name/file settings from DefaultFilePath
196+// without any GTK dependencies. For save mode, if the path points to an existing
197+// directory, it sets that as the current folder without a current name.
198+func parseDefaultDialogPath(mode purecef.FileDialogMode, defaultFilePath string) defaultDialogPathInfo {
199+ path := strings.TrimSpace(defaultFilePath)
200+ if path == "" {
201+ return defaultDialogPathInfo{}
202+ }
203+
204+ if mode == purecef.FileDialogModeFileDialogSave {
205+ if info, err := os.Stat(path); err == nil && info.IsDir() {
206+ return defaultDialogPathInfo{setFolder: path}
207+ }
208+ name := filepath.Base(path)
209+ if name == "." || name == string(filepath.Separator) {
210+ name = ""
211+ }
212+ info := defaultDialogPathInfo{setName: name}
213+ if dir := filepath.Dir(path); dir != "." && dir != "" {
214+ info.setFolder = dir
215+ }
216+ return info
217+ }
218+
219+ if info, err := os.Stat(path); err == nil && info.IsDir() {
220+ return defaultDialogPathInfo{setFolder: path}
221+ }
222+
223+ info := defaultDialogPathInfo{}
224+ if dir := filepath.Dir(path); dir != "." && dir != "" {
225+ info.setFolder = dir
226+ }
227+ if mode != purecef.FileDialogModeFileDialogOpenFolder {
228+ info.setFile = path
229+ }
230+ return info
231+}
232+
233+func applyDefaultDialogPath(dialog *gtk.FileChooserNative, req cefFileDialogRequest) {
234+ if dialog == nil {
235+ return
236+ }
237+ dpi := parseDefaultDialogPath(req.Mode, req.DefaultFilePath)
238+ if dpi.setFolder != "" {
239+ if folder := gio.FileNewForPath(dpi.setFolder); folder != nil {
240+ defer unrefGObjectPointer(folder.GoPointer())
241+ _, _ = dialog.SetCurrentFolder(folder)
242+ }
243+ }
244+ if dpi.setName != "" {
245+ dialog.SetCurrentName(dpi.setName)
246+ }
247+ if dpi.setFile != "" {
248+ if file := gio.FileNewForPath(dpi.setFile); file != nil {
249+ defer unrefGObjectPointer(file.GoPointer())
250+ _, _ = dialog.SetFile(file)
251+ }
252+ }
253+}
254+
255+func applyDialogFilters(dialog *gtk.FileChooserNative, req cefFileDialogRequest) {
256+ if dialog == nil {
257+ return
258+ }
259+
260+ appliedAny := false
261+ for i, spec := range req.AcceptFilters {
262+ filter := gtk.NewFileFilter()
263+ if filter == nil {
264+ continue
265+ }
266+
267+ if name := dialogFilterName(req, i, spec); name != "" {
268+ filter.SetName(&name)
269+ }
270+
271+ added := false
272+ for _, token := range dialogFilterTokens(spec, dialogFilterExtensions(req, i)) {
273+ if addDialogFilterToken(filter, token) {
274+ added = true
275+ }
276+ }
277+ if !added {
278+ continue
279+ }
280+
281+ dialog.AddFilter(filter)
282+ if !appliedAny {
283+ dialog.SetFilter(filter)
284+ appliedAny = true
285+ }
286+ }
287+}
288+
289+func dialogFilterName(req cefFileDialogRequest, index int, fallback string) string {
290+ if index < len(req.AcceptDescriptions) {
291+ if name := strings.TrimSpace(req.AcceptDescriptions[index]); name != "" {
292+ return name
293+ }
294+ }
295+ return strings.TrimSpace(fallback)
296+}
297+
298+func dialogFilterExtensions(req cefFileDialogRequest, index int) string {
299+ if index >= len(req.AcceptExtensions) {
300+ return ""
301+ }
302+ return req.AcceptExtensions[index]
303+}
304+
305+func dialogFilterTokens(spec, extensions string) []string {
306+ tokens := make([]string, 0, 4)
307+ if spec = strings.TrimSpace(spec); spec != "" {
308+ tokens = append(tokens, spec)
309+ }
310+ for _, part := range strings.Split(extensions, ";") {
311+ part = strings.TrimSpace(part)
312+ if part != "" {
313+ tokens = append(tokens, part)
314+ }
315+ }
316+ return tokens
317+}
318+
319+func addDialogFilterToken(filter *gtk.FileFilter, token string) bool {
320+ if filter == nil {
321+ return false
322+ }
323+ token = strings.TrimSpace(token)
324+ if token == "" {
325+ return false
326+ }
327+ if strings.Contains(token, "/") && !strings.HasPrefix(token, ".") {
328+ filter.AddMimeType(token)
329+ return true
330+ }
331+ if strings.HasPrefix(token, ".") {
332+ filter.AddPattern("*" + token)
333+ return true
334+ }
335+ if strings.ContainsRune(token, '*') {
336+ filter.AddPattern(token)
337+ return true
338+ }
339+ return false
340+}
341+
342+func selectedFileDialogPaths(dialog *gtk.FileChooserNative, mode purecef.FileDialogMode) []string {
343+ if dialog == nil {
344+ return nil
345+ }
346+ if mode == purecef.FileDialogModeFileDialogOpenMultiple {
347+ model := dialog.GetFiles()
348+ if model == nil {
349+ return nil
350+ }
351+ defer unrefGObjectPointer(model.GoPointer())
352+ paths := make([]string, 0, model.GetNItems())
353+ for i := uint(0); i < model.GetNItems(); i++ {
354+ obj := model.GetObject(i)
355+ if obj == nil {
356+ continue
357+ }
358+ file := &gio.FileBase{Ptr: obj.GoPointer()}
359+ if path := gioFileDialogPath(file); path != "" {
360+ paths = append(paths, path)
361+ }
362+ obj.Unref()
363+ }
364+ return paths
365+ }
366+ file := dialog.GetFile()
367+ defer unrefGObjectPointer(file.GoPointer())
368+ if path := gioFileDialogPath(file); path != "" {
369+ return []string{path}
370+ }
371+ return nil
372+}
373+
374+func folderUploadPaths(wv *WebView, mode purecef.FileDialogMode, paths []string) []string {
375+ if mode != purecef.FileDialogModeFileDialogOpenFolder || len(paths) != 1 {
376+ return paths
377+ }
378+ folder := strings.TrimSpace(paths[0])
379+ if folder == "" {
380+ return paths
381+ }
382+ info, err := os.Stat(folder)
383+ if err != nil || !info.IsDir() {
384+ logCEFFileDialog(wv).Warn().
385+ Msg("cef: folder upload path expansion skipped; selected path is not a directory")
386+ return paths
387+ }
388+
389+ files := make([]string, 0, 64)
390+ truncated := false
391+ walkErr := filepath.WalkDir(folder, func(path string, entry fs.DirEntry, err error) error {
392+ if err != nil {
393+ logCEFFileDialog(wv).Debug().Msg("cef: folder upload path expansion skipped unreadable entry")
394+ return nil
395+ }
396+ if path == folder || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 {
397+ return nil
398+ }
399+ info, err := entry.Info()
400+ if err != nil || !info.Mode().IsRegular() {
401+ return nil
402+ }
403+ files = append(files, path)
404+ if len(files) >= maxExpandedFolderUploadFiles {
405+ truncated = true
406+ return filepath.SkipAll
407+ }
408+ return nil
409+ })
410+ if walkErr != nil {
411+ logCEFFileDialog(wv).Warn().
412+ Msg("cef: folder upload descendant enumeration failed; using selected directory path")
413+ return paths
414+ }
415+ if len(files) == 0 {
416+ logCEFFileDialog(wv).Warn().
417+ Msg("cef: folder upload descendant enumeration found no regular files; using selected directory path")
418+ return paths
419+ }
420+
421+ if truncated {
422+ logCEFFileDialog(wv).Warn().
423+ Int("limit", maxExpandedFolderUploadFiles).
424+ Msg("cef: folder upload expansion truncated; canceling selection")
425+ return nil
426+ }
427+
428+ logCEFFileDialog(wv).Debug().
429+ Int("file_count", len(files)).
430+ Msg("cef: folder upload expanded selected directory to descendant file paths")
431+ return files
432+}
433+
434+func gioFileDialogPath(file *gio.FileBase) string {
435+ if file == nil {
436+ return ""
437+ }
438+ return strings.TrimSpace(file.GetPath())
439+}
440+
441+func unrefGObjectPointer(ptr uintptr) {
442+ if ptr == 0 {
443+ return
444+ }
445+ obj := &gobject.Object{}
446+ obj.SetGoPointer(ptr)
447+ obj.Unref()
448+}
449+
450+func normalizeGTKResponseID(responseID int) int {
451+ return int(int32(uint32(responseID)))
452+}
453+
454+func dispatchFileDialogResult(wv *WebView, callback purecef.FileDialogCallback, paths []string) {
455+ if callback == nil {
456+ return
457+ }
458+ logger := logCEFFileDialog(wv)
459+ if wv == nil || wv.engine == nil {
460+ logger.Warn().Msg("cef: webview engine unavailable; canceling file dialog selection")
461+ continueCEFFileDialog(logger, callback)
462+ return
463+ }
464+
465+ task := cefNewTask(cefTaskFunc(func() {
466+ logger.Debug().
467+ Int("path_count", len(paths)).
468+ Int32("currently_on_ui", purecef.CurrentlyOn(purecef.ThreadIDTidUi)).
469+ Msg("cef: file dialog continuation task executing")
470+ continueCEFFileDialog(logger, callback, paths...)
471+ logger.Debug().
472+ Int("path_count", len(paths)).
473+ Msg("cef: file dialog continuation task finished")
474+ }))
475+ if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
476+ logger.Warn().
477+ Int32("post_result", result).
478+ Msg("cef: failed to post file dialog continuation to CEF UI thread; canceling selection")
479+ continueCEFFileDialog(logger, callback)
480+ } else {
481+ logger.Debug().Int("path_count", len(paths)).Msg("cef: file dialog continuation task posted")
482+ }
483+}
484+
485+func continueCEFFileDialogWithLogs(logger *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) {
486+ logger = nonNilCEFFileDialogLogger(logger)
487+ if callback == nil {
488+ logger.Debug().Msg("cef: file dialog callback continuation skipped; callback is nil")
489+ return
490+ }
491+ callbackPtr := rawCEFPointer(callback)
492+
493+ if len(filePaths) == 0 {
494+ logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: invoking file dialog callback cancel")
495+ callback.Cancel()
496+ runtime.KeepAlive(callback)
497+ logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned")
498+ return
499+ }
500+
501+ logger.Debug().
502+ Uint64("callback_ptr", uint64(callbackPtr)).
503+ Int("path_count", len(filePaths)).
504+ Msg("cef: allocating file dialog CEF string list")
505+ list := newCEFStringList(filePaths...)
506+ if list == 0 {
507+ logger.Warn().
508+ Uint64("callback_ptr", uint64(callbackPtr)).
509+ Int("path_count", len(filePaths)).
510+ Msg("cef: file dialog CEF string list allocation failed; canceling")
511+ callback.Cancel()
512+ runtime.KeepAlive(callback)
513+ logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned after allocation failure")
514+ return
515+ }
516+
517+ listValue := uint64(list)
518+ logger.Debug().
519+ Uint64("callback_ptr", uint64(callbackPtr)).
520+ Uint64("string_list", listValue).
521+ Int("path_count", len(filePaths)).
522+ Msg("cef: file dialog CEF string list allocated")
523+ logger.Debug().
524+ Uint64("callback_ptr", uint64(callbackPtr)).
525+ Uint64("string_list", listValue).
526+ Int("path_count", len(filePaths)).
527+ Msg("cef: invoking file dialog callback continuation")
528+ callback.Cont(list)
529+ logger.Debug().
530+ Uint64("callback_ptr", uint64(callbackPtr)).
531+ Uint64("string_list", listValue).
532+ Int("path_count", len(filePaths)).
533+ Msg("cef: file dialog callback continuation returned")
534+ logger.Debug().Uint64("string_list", listValue).Msg("cef: freeing file dialog CEF string list")
535+ freeCEFStringList(list)
536+ logger.Debug().Uint64("string_list", listValue).Msg("cef: freed file dialog CEF string list")
537+ runtime.KeepAlive(callback)
538+}
539+
540+func nonNilCEFFileDialogLogger(logger *zerolog.Logger) *zerolog.Logger {
541+ if logger != nil {
542+ return logger
543+ }
544+ return logging.FromContext(context.Background())
545+}
546+
547+type cefRawPointerHolder interface {
548+ RawPointer() unsafe.Pointer
549+}
550+
551+func rawCEFPointer(v any) uintptr {
552+ if v == nil {
553+ return 0
554+ }
555+ holder, ok := v.(cefRawPointerHolder)
556+ if !ok {
557+ return 0
558+ }
559+ return uintptr(holder.RawPointer())
560+}
561+
562+func logCEFFileDialog(wv *WebView) *zerolog.Logger {
563+ if wv == nil || wv.ctx == nil {
564+ return logging.FromContext(context.Background())
565+ }
566+ return logging.FromContext(wv.ctx)
567+}
@@ -0,0 +1,567 @@
1+package cef
2+
3+import (
4+ "context"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "runtime"
9+ "strings"
10+ "unsafe"
11+
12+ purecef "github.com/bnema/purego-cef/cef"
13+ "github.com/bnema/puregotk/v4/gio"
14+ "github.com/bnema/puregotk/v4/gobject"
15+ "github.com/bnema/puregotk/v4/gtk"
16+ "github.com/rs/zerolog"
17+
18+ "github.com/bnema/dumber/internal/logging"
19+)
20+
21+type cefFileDialogRequest struct {
22+ Mode purecef.FileDialogMode
23+ Title string
24+ DefaultFilePath string
25+ AcceptFilters []string
26+ AcceptExtensions []string
27+ AcceptDescriptions []string
28+}
29+
30+type fileDialogPresenter func(*WebView, cefFileDialogRequest, purecef.FileDialogCallback)
31+
32+var maxExpandedFolderUploadFiles = 5000
33+
34+var (
35+ decodeCEFStringList = purecef.StringListToSlice
36+ newCEFStringList = purecef.NewStringList
37+ freeCEFStringList = purecef.FreeStringList
38+ continueCEFFileDialog = continueCEFFileDialogWithLogs
39+)
40+
41+func (h *handlerSet) OnFileDialog(
42+ _ purecef.Browser,
43+ mode purecef.FileDialogMode,
44+ title string,
45+ defaultFilePath string,
46+ acceptFilters purecef.StringList,
47+ acceptExtensions purecef.StringList,
48+ acceptDescriptions purecef.StringList,
49+ callback purecef.FileDialogCallback,
50+) int32 {
51+ if h == nil || h.wv == nil || callback == nil {
52+ return 0
53+ }
54+
55+ presenter := h.fileDialogPresenter
56+ if presenter == nil {
57+ presenter = presentGTKFileDialog
58+ }
59+
60+ req := cefFileDialogRequest{
61+ Mode: mode,
62+ Title: title,
63+ DefaultFilePath: defaultFilePath,
64+ AcceptFilters: decodeCEFStringList(acceptFilters),
65+ AcceptExtensions: decodeCEFStringList(acceptExtensions),
66+ AcceptDescriptions: decodeCEFStringList(acceptDescriptions),
67+ }
68+ logCEFFileDialog(h.wv).Debug().
69+ Int32("mode", mode).
70+ Str("title", title).
71+ Bool("has_default_file_path", strings.TrimSpace(defaultFilePath) != "").
72+ Int("accept_filter_count", len(req.AcceptFilters)).
73+ Int("accept_extension_count", len(req.AcceptExtensions)).
74+ Msg("cef: file dialog requested")
75+
76+ presenter(h.wv, req, callback)
77+ return 1
78+}
79+
80+func presentGTKFileDialog(wv *WebView, req cefFileDialogRequest, callback purecef.FileDialogCallback) {
81+ if callback == nil || wv == nil {
82+ return
83+ }
84+
85+ wv.runOnGTK(func() {
86+ logCEFFileDialog(wv).Debug().
87+ Int32("mode", req.Mode).
88+ Str("title", req.Title).
89+ Bool("has_default_file_path", strings.TrimSpace(req.DefaultFilePath) != "").
90+ Msg("cef: presenting GTK file dialog")
91+ dialog, ok := newNativeFileDialog(wv, req)
92+ if !ok || dialog == nil {
93+ dispatchFileDialogResult(wv, callback, nil)
94+ return
95+ }
96+
97+ onResponse := func(_ gtk.NativeDialog, responseID int) {
98+ defer func() {
99+ logCEFFileDialog(wv).Debug().Int32("mode", req.Mode).Msg("cef: releasing GTK file dialog")
100+ dialog.Unref()
101+ logCEFFileDialog(wv).Debug().Int32("mode", req.Mode).Msg("cef: released GTK file dialog")
102+ }()
103+
104+ normalizedResponseID := normalizeGTKResponseID(responseID)
105+ logCEFFileDialog(wv).Debug().
106+ Int("response_id_raw", responseID).
107+ Int("response_id_normalized", normalizedResponseID).
108+ Int32("mode", req.Mode).
109+ Msg("cef: GTK file dialog response received")
110+ if normalizedResponseID != int(gtk.ResponseAcceptValue) {
111+ logCEFFileDialog(wv).Debug().Msg("cef: file dialog canceled")
112+ dispatchFileDialogResult(wv, callback, nil)
113+ return
114+ }
115+
116+ paths := selectedFileDialogPaths(dialog, req.Mode)
117+ logCEFFileDialog(wv).Debug().
118+ Int("path_count", len(paths)).
119+ Msg("cef: file dialog selection captured")
120+ paths = folderUploadPaths(wv, req.Mode, paths)
121+ dispatchFileDialogResult(wv, callback, paths)
122+ }
123+ dialog.ConnectResponse(&onResponse)
124+ dialog.Show()
125+ })
126+}
127+
128+func newNativeFileDialog(wv *WebView, req cefFileDialogRequest) (*gtk.FileChooserNative, bool) {
129+ action, ok := fileChooserActionForMode(req.Mode)
130+ if !ok {
131+ logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: unsupported file dialog mode")
132+ return nil, false
133+ }
134+
135+ var titlePtr *string
136+ title := strings.TrimSpace(req.Title)
137+ if title != "" {
138+ titlePtr = &title
139+ }
140+
141+ dialog := gtk.NewFileChooserNative(titlePtr, fileDialogParentWindow(wv), action, nil, nil)
142+ if dialog == nil {
143+ logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: failed to create GTK file chooser dialog")
144+ return nil, false
145+ }
146+
147+ dialog.SetModal(true)
148+ if req.Mode == purecef.FileDialogModeFileDialogOpenMultiple {
149+ dialog.SetSelectMultiple(true)
150+ }
151+ applyDefaultDialogPath(dialog, req)
152+ applyDialogFilters(dialog, req)
153+ return dialog, true
154+}
155+
156+func fileChooserActionForMode(mode purecef.FileDialogMode) (gtk.FileChooserAction, bool) {
157+ switch mode {
158+ case purecef.FileDialogModeFileDialogOpen, purecef.FileDialogModeFileDialogOpenMultiple:
159+ return gtk.FileChooserActionOpenValue, true
160+ case purecef.FileDialogModeFileDialogOpenFolder:
161+ return gtk.FileChooserActionSelectFolderValue, true
162+ case purecef.FileDialogModeFileDialogSave:
163+ return gtk.FileChooserActionSaveValue, true
164+ default:
165+ return 0, false
166+ }
167+}
168+
169+func fileDialogParentWindow(wv *WebView) *gtk.Window {
170+ if wv == nil || wv.viewBridge == nil {
171+ return nil
172+ }
173+ widget := wv.viewBridge.Widget()
174+ if widget == nil {
175+ return nil
176+ }
177+ ancestor := widget.GetAncestor(gtk.WindowGLibType())
178+ if ancestor == nil {
179+ return nil
180+ }
181+ // puregotk's GetAncestor wrapper adds a reference before returning.
182+ defer ancestor.Unref()
183+ return gtk.WindowNewFromInternalPtr(ancestor.GoPointer())
184+}
185+
186+// defaultDialogPathInfo holds the result of parsing DefaultFilePath for use by
187+// applyDefaultDialogPath. It is a pure value type to remain unit-testable without
188+// GTK objects.
189+type defaultDialogPathInfo struct {
190+ setFolder string // path to set as current folder (empty = don't set)
191+ setName string // name to set as current name (empty = don't set)
192+ setFile string // path to set as selected file (empty = don't set)
193+}
194+
195+// parseDefaultDialogPath extracts folder/name/file settings from DefaultFilePath
196+// without any GTK dependencies. For save mode, if the path points to an existing
197+// directory, it sets that as the current folder without a current name.
198+func parseDefaultDialogPath(mode purecef.FileDialogMode, defaultFilePath string) defaultDialogPathInfo {
199+ path := strings.TrimSpace(defaultFilePath)
200+ if path == "" {
201+ return defaultDialogPathInfo{}
202+ }
203+
204+ if mode == purecef.FileDialogModeFileDialogSave {
205+ if info, err := os.Stat(path); err == nil && info.IsDir() {
206+ return defaultDialogPathInfo{setFolder: path}
207+ }
208+ name := filepath.Base(path)
209+ if name == "." || name == string(filepath.Separator) {
210+ name = ""
211+ }
212+ info := defaultDialogPathInfo{setName: name}
213+ if dir := filepath.Dir(path); dir != "." && dir != "" {
214+ info.setFolder = dir
215+ }
216+ return info
217+ }
218+
219+ if info, err := os.Stat(path); err == nil && info.IsDir() {
220+ return defaultDialogPathInfo{setFolder: path}
221+ }
222+
223+ info := defaultDialogPathInfo{}
224+ if dir := filepath.Dir(path); dir != "." && dir != "" {
225+ info.setFolder = dir
226+ }
227+ if mode != purecef.FileDialogModeFileDialogOpenFolder {
228+ info.setFile = path
229+ }
230+ return info
231+}
232+
233+func applyDefaultDialogPath(dialog *gtk.FileChooserNative, req cefFileDialogRequest) {
234+ if dialog == nil {
235+ return
236+ }
237+ dpi := parseDefaultDialogPath(req.Mode, req.DefaultFilePath)
238+ if dpi.setFolder != "" {
239+ if folder := gio.FileNewForPath(dpi.setFolder); folder != nil {
240+ defer unrefGObjectPointer(folder.GoPointer())
241+ _, _ = dialog.SetCurrentFolder(folder)
242+ }
243+ }
244+ if dpi.setName != "" {
245+ dialog.SetCurrentName(dpi.setName)
246+ }
247+ if dpi.setFile != "" {
248+ if file := gio.FileNewForPath(dpi.setFile); file != nil {
249+ defer unrefGObjectPointer(file.GoPointer())
250+ _, _ = dialog.SetFile(file)
251+ }
252+ }
253+}
254+
255+func applyDialogFilters(dialog *gtk.FileChooserNative, req cefFileDialogRequest) {
256+ if dialog == nil {
257+ return
258+ }
259+
260+ appliedAny := false
261+ for i, spec := range req.AcceptFilters {
262+ filter := gtk.NewFileFilter()
263+ if filter == nil {
264+ continue
265+ }
266+
267+ if name := dialogFilterName(req, i, spec); name != "" {
268+ filter.SetName(&name)
269+ }
270+
271+ added := false
272+ for _, token := range dialogFilterTokens(spec, dialogFilterExtensions(req, i)) {
273+ if addDialogFilterToken(filter, token) {
274+ added = true
275+ }
276+ }
277+ if !added {
278+ continue
279+ }
280+
281+ dialog.AddFilter(filter)
282+ if !appliedAny {
283+ dialog.SetFilter(filter)
284+ appliedAny = true
285+ }
286+ }
287+}
288+
289+func dialogFilterName(req cefFileDialogRequest, index int, fallback string) string {
290+ if index < len(req.AcceptDescriptions) {
291+ if name := strings.TrimSpace(req.AcceptDescriptions[index]); name != "" {
292+ return name
293+ }
294+ }
295+ return strings.TrimSpace(fallback)
296+}
297+
298+func dialogFilterExtensions(req cefFileDialogRequest, index int) string {
299+ if index >= len(req.AcceptExtensions) {
300+ return ""
301+ }
302+ return req.AcceptExtensions[index]
303+}
304+
305+func dialogFilterTokens(spec, extensions string) []string {
306+ tokens := make([]string, 0, 4)
307+ if spec = strings.TrimSpace(spec); spec != "" {
308+ tokens = append(tokens, spec)
309+ }
310+ for _, part := range strings.Split(extensions, ";") {
311+ part = strings.TrimSpace(part)
312+ if part != "" {
313+ tokens = append(tokens, part)
314+ }
315+ }
316+ return tokens
317+}
318+
319+func addDialogFilterToken(filter *gtk.FileFilter, token string) bool {
320+ if filter == nil {
321+ return false
322+ }
323+ token = strings.TrimSpace(token)
324+ if token == "" {
325+ return false
326+ }
327+ if strings.Contains(token, "/") && !strings.HasPrefix(token, ".") {
328+ filter.AddMimeType(token)
329+ return true
330+ }
331+ if strings.HasPrefix(token, ".") {
332+ filter.AddPattern("*" + token)
333+ return true
334+ }
335+ if strings.ContainsRune(token, '*') {
336+ filter.AddPattern(token)
337+ return true
338+ }
339+ return false
340+}
341+
342+func selectedFileDialogPaths(dialog *gtk.FileChooserNative, mode purecef.FileDialogMode) []string {
343+ if dialog == nil {
344+ return nil
345+ }
346+ if mode == purecef.FileDialogModeFileDialogOpenMultiple {
347+ model := dialog.GetFiles()
348+ if model == nil {
349+ return nil
350+ }
351+ defer unrefGObjectPointer(model.GoPointer())
352+ paths := make([]string, 0, model.GetNItems())
353+ for i := uint(0); i < model.GetNItems(); i++ {
354+ obj := model.GetObject(i)
355+ if obj == nil {
356+ continue
357+ }
358+ file := &gio.FileBase{Ptr: obj.GoPointer()}
359+ if path := gioFileDialogPath(file); path != "" {
360+ paths = append(paths, path)
361+ }
362+ obj.Unref()
363+ }
364+ return paths
365+ }
366+ file := dialog.GetFile()
367+ defer unrefGObjectPointer(file.GoPointer())
368+ if path := gioFileDialogPath(file); path != "" {
369+ return []string{path}
370+ }
371+ return nil
372+}
373+
374+func folderUploadPaths(wv *WebView, mode purecef.FileDialogMode, paths []string) []string {
375+ if mode != purecef.FileDialogModeFileDialogOpenFolder || len(paths) != 1 {
376+ return paths
377+ }
378+ folder := strings.TrimSpace(paths[0])
379+ if folder == "" {
380+ return paths
381+ }
382+ info, err := os.Stat(folder)
383+ if err != nil || !info.IsDir() {
384+ logCEFFileDialog(wv).Warn().
385+ Msg("cef: folder upload path expansion skipped; selected path is not a directory")
386+ return paths
387+ }
388+
389+ files := make([]string, 0, 64)
390+ truncated := false
391+ walkErr := filepath.WalkDir(folder, func(path string, entry fs.DirEntry, err error) error {
392+ if err != nil {
393+ logCEFFileDialog(wv).Debug().Msg("cef: folder upload path expansion skipped unreadable entry")
394+ return nil
395+ }
396+ if path == folder || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 {
397+ return nil
398+ }
399+ info, err := entry.Info()
400+ if err != nil || !info.Mode().IsRegular() {
401+ return nil
402+ }
403+ files = append(files, path)
404+ if len(files) >= maxExpandedFolderUploadFiles {
405+ truncated = true
406+ return filepath.SkipAll
407+ }
408+ return nil
409+ })
410+ if walkErr != nil {
411+ logCEFFileDialog(wv).Warn().
412+ Msg("cef: folder upload descendant enumeration failed; using selected directory path")
413+ return paths
414+ }
415+ if len(files) == 0 {
416+ logCEFFileDialog(wv).Warn().
417+ Msg("cef: folder upload descendant enumeration found no regular files; using selected directory path")
418+ return paths
419+ }
420+
421+ if truncated {
422+ logCEFFileDialog(wv).Warn().
423+ Int("limit", maxExpandedFolderUploadFiles).
424+ Msg("cef: folder upload expansion truncated; canceling selection")
425+ return nil
426+ }
427+
428+ logCEFFileDialog(wv).Debug().
429+ Int("file_count", len(files)).
430+ Msg("cef: folder upload expanded selected directory to descendant file paths")
431+ return files
432+}
433+
434+func gioFileDialogPath(file *gio.FileBase) string {
435+ if file == nil {
436+ return ""
437+ }
438+ return strings.TrimSpace(file.GetPath())
439+}
440+
441+func unrefGObjectPointer(ptr uintptr) {
442+ if ptr == 0 {
443+ return
444+ }
445+ obj := &gobject.Object{}
446+ obj.SetGoPointer(ptr)
447+ obj.Unref()
448+}
449+
450+func normalizeGTKResponseID(responseID int) int {
451+ return int(int32(uint32(responseID)))
452+}
453+
454+func dispatchFileDialogResult(wv *WebView, callback purecef.FileDialogCallback, paths []string) {
455+ if callback == nil {
456+ return
457+ }
458+ logger := logCEFFileDialog(wv)
459+ if wv == nil || wv.engine == nil {
460+ logger.Warn().Msg("cef: webview engine unavailable; canceling file dialog selection")
461+ continueCEFFileDialog(logger, callback)
462+ return
463+ }
464+
465+ task := cefNewTask(cefTaskFunc(func() {
466+ logger.Debug().
467+ Int("path_count", len(paths)).
468+ Int32("currently_on_ui", purecef.CurrentlyOn(purecef.ThreadIDTidUi)).
469+ Msg("cef: file dialog continuation task executing")
470+ continueCEFFileDialog(logger, callback, paths...)
471+ logger.Debug().
472+ Int("path_count", len(paths)).
473+ Msg("cef: file dialog continuation task finished")
474+ }))
475+ if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
476+ logger.Warn().
477+ Int32("post_result", result).
478+ Msg("cef: failed to post file dialog continuation to CEF UI thread; canceling selection")
479+ continueCEFFileDialog(logger, callback)
480+ } else {
481+ logger.Debug().Int("path_count", len(paths)).Msg("cef: file dialog continuation task posted")
482+ }
483+}
484+
485+func continueCEFFileDialogWithLogs(logger *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) {
486+ logger = nonNilCEFFileDialogLogger(logger)
487+ if callback == nil {
488+ logger.Debug().Msg("cef: file dialog callback continuation skipped; callback is nil")
489+ return
490+ }
491+ callbackPtr := rawCEFPointer(callback)
492+
493+ if len(filePaths) == 0 {
494+ logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: invoking file dialog callback cancel")
495+ callback.Cancel()
496+ runtime.KeepAlive(callback)
497+ logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned")
498+ return
499+ }
500+
501+ logger.Debug().
502+ Uint64("callback_ptr", uint64(callbackPtr)).
503+ Int("path_count", len(filePaths)).
504+ Msg("cef: allocating file dialog CEF string list")
505+ list := newCEFStringList(filePaths...)
506+ if list == 0 {
507+ logger.Warn().
508+ Uint64("callback_ptr", uint64(callbackPtr)).
509+ Int("path_count", len(filePaths)).
510+ Msg("cef: file dialog CEF string list allocation failed; canceling")
511+ callback.Cancel()
512+ runtime.KeepAlive(callback)
513+ logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned after allocation failure")
514+ return
515+ }
516+
517+ listValue := uint64(list)
518+ logger.Debug().
519+ Uint64("callback_ptr", uint64(callbackPtr)).
520+ Uint64("string_list", listValue).
521+ Int("path_count", len(filePaths)).
522+ Msg("cef: file dialog CEF string list allocated")
523+ logger.Debug().
524+ Uint64("callback_ptr", uint64(callbackPtr)).
525+ Uint64("string_list", listValue).
526+ Int("path_count", len(filePaths)).
527+ Msg("cef: invoking file dialog callback continuation")
528+ callback.Cont(list)
529+ logger.Debug().
530+ Uint64("callback_ptr", uint64(callbackPtr)).
531+ Uint64("string_list", listValue).
532+ Int("path_count", len(filePaths)).
533+ Msg("cef: file dialog callback continuation returned")
534+ logger.Debug().Uint64("string_list", listValue).Msg("cef: freeing file dialog CEF string list")
535+ freeCEFStringList(list)
536+ logger.Debug().Uint64("string_list", listValue).Msg("cef: freed file dialog CEF string list")
537+ runtime.KeepAlive(callback)
538+}
539+
540+func nonNilCEFFileDialogLogger(logger *zerolog.Logger) *zerolog.Logger {
541+ if logger != nil {
542+ return logger
543+ }
544+ return logging.FromContext(context.Background())
545+}
546+
547+type cefRawPointerHolder interface {
548+ RawPointer() unsafe.Pointer
549+}
550+
551+func rawCEFPointer(v any) uintptr {
552+ if v == nil {
553+ return 0
554+ }
555+ holder, ok := v.(cefRawPointerHolder)
556+ if !ok {
557+ return 0
558+ }
559+ return uintptr(holder.RawPointer())
560+}
561+
562+func logCEFFileDialog(wv *WebView) *zerolog.Logger {
563+ if wv == nil || wv.ctx == nil {
564+ return logging.FromContext(context.Background())
565+ }
566+ return logging.FromContext(wv.ctx)
567+}
A internal/infrastructure/cef/file_dialog_test.go +226 -0
@@ -0,0 +1,226 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ purecef "github.com/bnema/purego-cef/cef"
11+ "github.com/rs/zerolog"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type stubFileDialogCallback struct {
16+ list purecef.StringList
17+ paths []string
18+ canceled bool
19+}
20+
21+func (s *stubFileDialogCallback) Cont(filePaths purecef.StringList) {
22+ s.list = filePaths
23+ s.paths = decodeCEFStringList(filePaths)
24+}
25+
26+func (s *stubFileDialogCallback) Cancel() {
27+ s.canceled = true
28+}
29+
30+func TestGetDialogHandlerEnabled(t *testing.T) {
31+ h := &handlerSet{}
32+ require.Same(t, h, h.GetDialogHandler())
33+}
34+
35+func TestOnFileDialogRejectsNilWebViewOrCallback(t *testing.T) {
36+ require.Zero(t, (*handlerSet)(nil).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{}))
37+ require.Zero(t, (&handlerSet{}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{}))
38+ require.Zero(t, (&handlerSet{wv: &WebView{ctx: context.Background()}}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, nil))
39+}
40+
41+func TestNormalizeGTKResponseID(t *testing.T) {
42+ require.Equal(t, -3, normalizeGTKResponseID(4294967293))
43+ require.Equal(t, -6, normalizeGTKResponseID(4294967290))
44+ require.Equal(t, 0, normalizeGTKResponseID(0))
45+}
46+
47+func TestOnFileDialogDelegatesDecodedRequestToPresenter(t *testing.T) {
48+ prevDecode := decodeCEFStringList
49+ decodeCEFStringList = func(list purecef.StringList) []string {
50+ switch list {
51+ case purecef.StringList(11):
52+ return []string{"image/*", ".txt"}
53+ case purecef.StringList(12):
54+ return []string{".png;.jpg", ".txt"}
55+ case purecef.StringList(13):
56+ return []string{"Image Files", "Text Files"}
57+ default:
58+ return nil
59+ }
60+ }
61+ defer func() { decodeCEFStringList = prevDecode }()
62+
63+ var gotReq cefFileDialogRequest
64+ var gotCalls int
65+ h := &handlerSet{
66+ wv: &WebView{ctx: context.Background()},
67+ fileDialogPresenter: func(_ *WebView, req cefFileDialogRequest, _ purecef.FileDialogCallback) {
68+ gotReq = req
69+ gotCalls++
70+ },
71+ }
72+
73+ handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogOpenMultiple, "Pick files", "/tmp/start.txt", purecef.StringList(11), purecef.StringList(12), purecef.StringList(13), &stubFileDialogCallback{})
74+
75+ require.Equal(t, int32(1), handled)
76+ require.Equal(t, 1, gotCalls)
77+ require.Equal(t, purecef.FileDialogModeFileDialogOpenMultiple, gotReq.Mode)
78+ require.Equal(t, "Pick files", gotReq.Title)
79+ require.Equal(t, "/tmp/start.txt", gotReq.DefaultFilePath)
80+ require.Equal(t, []string{"image/*", ".txt"}, gotReq.AcceptFilters)
81+ require.Equal(t, []string{".png;.jpg", ".txt"}, gotReq.AcceptExtensions)
82+ require.Equal(t, []string{"Image Files", "Text Files"}, gotReq.AcceptDescriptions)
83+}
84+
85+func TestFolderUploadPathsExpandsDirectoryFiles(t *testing.T) {
86+ dir := t.TempDir()
87+ first := filepath.Join(dir, "first.txt")
88+ second := filepath.Join(dir, "nested", "second.txt")
89+ require.NoError(t, os.MkdirAll(filepath.Dir(second), 0o755))
90+ require.NoError(t, os.WriteFile(first, []byte("first"), 0o644))
91+ require.NoError(t, os.WriteFile(second, []byte("second"), 0o644))
92+
93+ got := folderUploadPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir})
94+
95+ require.ElementsMatch(t, []string{first, second}, got)
96+}
97+
98+func TestFolderUploadPathsKeepsEmptyDirectoryPath(t *testing.T) {
99+ dir := t.TempDir()
100+
101+ got := folderUploadPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir})
102+
103+ require.Equal(t, []string{dir}, got)
104+}
105+
106+func TestParseDefaultDialogPathSaveModeWithExistingDirectory(t *testing.T) {
107+ dir := t.TempDir()
108+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, dir)
109+ require.Equal(t, dir, info.setFolder)
110+ require.Empty(t, info.setName)
111+ require.Empty(t, info.setFile)
112+}
113+
114+func TestParseDefaultDialogPathSaveModeWithFilePath(t *testing.T) {
115+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, "/tmp/foo/bar.txt")
116+ require.Equal(t, "/tmp/foo", info.setFolder)
117+ require.Equal(t, "bar.txt", info.setName)
118+ require.Empty(t, info.setFile)
119+}
120+
121+func TestParseDefaultDialogPathSaveModeWithRelativeFilePath(t *testing.T) {
122+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, "bar.txt")
123+ require.Empty(t, info.setFolder)
124+ require.Equal(t, "bar.txt", info.setName)
125+ require.Empty(t, info.setFile)
126+}
127+
128+func TestParseDefaultDialogPathOpenModeWithDirectory(t *testing.T) {
129+ dir := t.TempDir()
130+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpen, dir)
131+ require.Equal(t, dir, info.setFolder)
132+ require.Empty(t, info.setFile)
133+}
134+
135+func TestParseDefaultDialogPathOpenModeWithFilePath(t *testing.T) {
136+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpen, "/tmp/foo/bar.txt")
137+ require.Equal(t, "/tmp/foo", info.setFolder)
138+ require.Equal(t, "/tmp/foo/bar.txt", info.setFile)
139+}
140+
141+func TestParseDefaultDialogPathOpenFolderMode(t *testing.T) {
142+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpenFolder, "/tmp/foo/bar.txt")
143+ require.Equal(t, "/tmp/foo", info.setFolder)
144+ require.Empty(t, info.setFile)
145+}
146+
147+func TestParseDefaultDialogPathEmptyPath(t *testing.T) {
148+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, "")
149+ require.Empty(t, info.setFolder)
150+ require.Empty(t, info.setName)
151+ require.Empty(t, info.setFile)
152+
153+ info = parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpen, " ")
154+ require.Empty(t, info.setFolder)
155+ require.Empty(t, info.setName)
156+ require.Empty(t, info.setFile)
157+}
158+
159+func TestFolderUploadPathsTruncationCancels(t *testing.T) {
160+ prev := maxExpandedFolderUploadFiles
161+ maxExpandedFolderUploadFiles = 2
162+ defer func() { maxExpandedFolderUploadFiles = prev }()
163+
164+ dir := t.TempDir()
165+ for i := 0; i < 5; i++ {
166+ p := filepath.Join(dir, fmt.Sprintf("file_%d.txt", i))
167+ require.NoError(t, os.WriteFile(p, []byte("data"), 0o644))
168+ }
169+
170+ got := folderUploadPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir})
171+ require.Nil(t, got)
172+}
173+
174+func TestDispatchFileDialogResultCancelsWhenEngineMissing(t *testing.T) {
175+ callback := &stubFileDialogCallback{}
176+ dispatchFileDialogResult(&WebView{ctx: context.Background()}, callback, []string{"/tmp/test.txt"})
177+ require.True(t, callback.canceled)
178+}
179+
180+func TestDispatchFileDialogResultCancelsWhenCefPostTaskFails(t *testing.T) {
181+ prevNewTask := cefNewTask
182+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
183+ defer func() { cefNewTask = prevNewTask }()
184+
185+ prevPost := cefPostTask
186+ cefPostTask = func(_ purecef.ThreadID, _ purecef.Task) int32 {
187+ return 0
188+ }
189+ defer func() { cefPostTask = prevPost }()
190+
191+ callback := &stubFileDialogCallback{}
192+ wv := &WebView{
193+ ctx: context.Background(),
194+ engine: &Engine{},
195+ }
196+ dispatchFileDialogResult(wv, callback, []string{"/tmp/test.txt"})
197+ require.True(t, callback.canceled)
198+}
199+
200+func TestOnFileDialogPresenterCanContinueCallback(t *testing.T) {
201+ prevDecode := decodeCEFStringList
202+ decodeCEFStringList = func(purecef.StringList) []string { return nil }
203+ defer func() { decodeCEFStringList = prevDecode }()
204+
205+ prevContinue := continueCEFFileDialog
206+ continueCEFFileDialog = func(_ *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) {
207+ recorder, ok := callback.(*stubFileDialogCallback)
208+ require.True(t, ok)
209+ recorder.paths = append([]string(nil), filePaths...)
210+ }
211+ defer func() { continueCEFFileDialog = prevContinue }()
212+
213+ callback := &stubFileDialogCallback{}
214+ h := &handlerSet{
215+ wv: &WebView{ctx: context.Background()},
216+ fileDialogPresenter: func(_ *WebView, _ cefFileDialogRequest, cb purecef.FileDialogCallback) {
217+ continueCEFFileDialog(nil, cb, "/tmp/report.txt")
218+ },
219+ }
220+
221+ handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogSave, "", "", 0, 0, 0, callback)
222+
223+ require.Equal(t, int32(1), handled)
224+ require.False(t, callback.canceled)
225+ require.Equal(t, []string{"/tmp/report.txt"}, callback.paths)
226+}
@@ -0,0 +1,226 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ purecef "github.com/bnema/purego-cef/cef"
11+ "github.com/rs/zerolog"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type stubFileDialogCallback struct {
16+ list purecef.StringList
17+ paths []string
18+ canceled bool
19+}
20+
21+func (s *stubFileDialogCallback) Cont(filePaths purecef.StringList) {
22+ s.list = filePaths
23+ s.paths = decodeCEFStringList(filePaths)
24+}
25+
26+func (s *stubFileDialogCallback) Cancel() {
27+ s.canceled = true
28+}
29+
30+func TestGetDialogHandlerEnabled(t *testing.T) {
31+ h := &handlerSet{}
32+ require.Same(t, h, h.GetDialogHandler())
33+}
34+
35+func TestOnFileDialogRejectsNilWebViewOrCallback(t *testing.T) {
36+ require.Zero(t, (*handlerSet)(nil).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{}))
37+ require.Zero(t, (&handlerSet{}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{}))
38+ require.Zero(t, (&handlerSet{wv: &WebView{ctx: context.Background()}}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, nil))
39+}
40+
41+func TestNormalizeGTKResponseID(t *testing.T) {
42+ require.Equal(t, -3, normalizeGTKResponseID(4294967293))
43+ require.Equal(t, -6, normalizeGTKResponseID(4294967290))
44+ require.Equal(t, 0, normalizeGTKResponseID(0))
45+}
46+
47+func TestOnFileDialogDelegatesDecodedRequestToPresenter(t *testing.T) {
48+ prevDecode := decodeCEFStringList
49+ decodeCEFStringList = func(list purecef.StringList) []string {
50+ switch list {
51+ case purecef.StringList(11):
52+ return []string{"image/*", ".txt"}
53+ case purecef.StringList(12):
54+ return []string{".png;.jpg", ".txt"}
55+ case purecef.StringList(13):
56+ return []string{"Image Files", "Text Files"}
57+ default:
58+ return nil
59+ }
60+ }
61+ defer func() { decodeCEFStringList = prevDecode }()
62+
63+ var gotReq cefFileDialogRequest
64+ var gotCalls int
65+ h := &handlerSet{
66+ wv: &WebView{ctx: context.Background()},
67+ fileDialogPresenter: func(_ *WebView, req cefFileDialogRequest, _ purecef.FileDialogCallback) {
68+ gotReq = req
69+ gotCalls++
70+ },
71+ }
72+
73+ handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogOpenMultiple, "Pick files", "/tmp/start.txt", purecef.StringList(11), purecef.StringList(12), purecef.StringList(13), &stubFileDialogCallback{})
74+
75+ require.Equal(t, int32(1), handled)
76+ require.Equal(t, 1, gotCalls)
77+ require.Equal(t, purecef.FileDialogModeFileDialogOpenMultiple, gotReq.Mode)
78+ require.Equal(t, "Pick files", gotReq.Title)
79+ require.Equal(t, "/tmp/start.txt", gotReq.DefaultFilePath)
80+ require.Equal(t, []string{"image/*", ".txt"}, gotReq.AcceptFilters)
81+ require.Equal(t, []string{".png;.jpg", ".txt"}, gotReq.AcceptExtensions)
82+ require.Equal(t, []string{"Image Files", "Text Files"}, gotReq.AcceptDescriptions)
83+}
84+
85+func TestFolderUploadPathsExpandsDirectoryFiles(t *testing.T) {
86+ dir := t.TempDir()
87+ first := filepath.Join(dir, "first.txt")
88+ second := filepath.Join(dir, "nested", "second.txt")
89+ require.NoError(t, os.MkdirAll(filepath.Dir(second), 0o755))
90+ require.NoError(t, os.WriteFile(first, []byte("first"), 0o644))
91+ require.NoError(t, os.WriteFile(second, []byte("second"), 0o644))
92+
93+ got := folderUploadPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir})
94+
95+ require.ElementsMatch(t, []string{first, second}, got)
96+}
97+
98+func TestFolderUploadPathsKeepsEmptyDirectoryPath(t *testing.T) {
99+ dir := t.TempDir()
100+
101+ got := folderUploadPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir})
102+
103+ require.Equal(t, []string{dir}, got)
104+}
105+
106+func TestParseDefaultDialogPathSaveModeWithExistingDirectory(t *testing.T) {
107+ dir := t.TempDir()
108+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, dir)
109+ require.Equal(t, dir, info.setFolder)
110+ require.Empty(t, info.setName)
111+ require.Empty(t, info.setFile)
112+}
113+
114+func TestParseDefaultDialogPathSaveModeWithFilePath(t *testing.T) {
115+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, "/tmp/foo/bar.txt")
116+ require.Equal(t, "/tmp/foo", info.setFolder)
117+ require.Equal(t, "bar.txt", info.setName)
118+ require.Empty(t, info.setFile)
119+}
120+
121+func TestParseDefaultDialogPathSaveModeWithRelativeFilePath(t *testing.T) {
122+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, "bar.txt")
123+ require.Empty(t, info.setFolder)
124+ require.Equal(t, "bar.txt", info.setName)
125+ require.Empty(t, info.setFile)
126+}
127+
128+func TestParseDefaultDialogPathOpenModeWithDirectory(t *testing.T) {
129+ dir := t.TempDir()
130+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpen, dir)
131+ require.Equal(t, dir, info.setFolder)
132+ require.Empty(t, info.setFile)
133+}
134+
135+func TestParseDefaultDialogPathOpenModeWithFilePath(t *testing.T) {
136+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpen, "/tmp/foo/bar.txt")
137+ require.Equal(t, "/tmp/foo", info.setFolder)
138+ require.Equal(t, "/tmp/foo/bar.txt", info.setFile)
139+}
140+
141+func TestParseDefaultDialogPathOpenFolderMode(t *testing.T) {
142+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpenFolder, "/tmp/foo/bar.txt")
143+ require.Equal(t, "/tmp/foo", info.setFolder)
144+ require.Empty(t, info.setFile)
145+}
146+
147+func TestParseDefaultDialogPathEmptyPath(t *testing.T) {
148+ info := parseDefaultDialogPath(purecef.FileDialogModeFileDialogSave, "")
149+ require.Empty(t, info.setFolder)
150+ require.Empty(t, info.setName)
151+ require.Empty(t, info.setFile)
152+
153+ info = parseDefaultDialogPath(purecef.FileDialogModeFileDialogOpen, " ")
154+ require.Empty(t, info.setFolder)
155+ require.Empty(t, info.setName)
156+ require.Empty(t, info.setFile)
157+}
158+
159+func TestFolderUploadPathsTruncationCancels(t *testing.T) {
160+ prev := maxExpandedFolderUploadFiles
161+ maxExpandedFolderUploadFiles = 2
162+ defer func() { maxExpandedFolderUploadFiles = prev }()
163+
164+ dir := t.TempDir()
165+ for i := 0; i < 5; i++ {
166+ p := filepath.Join(dir, fmt.Sprintf("file_%d.txt", i))
167+ require.NoError(t, os.WriteFile(p, []byte("data"), 0o644))
168+ }
169+
170+ got := folderUploadPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir})
171+ require.Nil(t, got)
172+}
173+
174+func TestDispatchFileDialogResultCancelsWhenEngineMissing(t *testing.T) {
175+ callback := &stubFileDialogCallback{}
176+ dispatchFileDialogResult(&WebView{ctx: context.Background()}, callback, []string{"/tmp/test.txt"})
177+ require.True(t, callback.canceled)
178+}
179+
180+func TestDispatchFileDialogResultCancelsWhenCefPostTaskFails(t *testing.T) {
181+ prevNewTask := cefNewTask
182+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
183+ defer func() { cefNewTask = prevNewTask }()
184+
185+ prevPost := cefPostTask
186+ cefPostTask = func(_ purecef.ThreadID, _ purecef.Task) int32 {
187+ return 0
188+ }
189+ defer func() { cefPostTask = prevPost }()
190+
191+ callback := &stubFileDialogCallback{}
192+ wv := &WebView{
193+ ctx: context.Background(),
194+ engine: &Engine{},
195+ }
196+ dispatchFileDialogResult(wv, callback, []string{"/tmp/test.txt"})
197+ require.True(t, callback.canceled)
198+}
199+
200+func TestOnFileDialogPresenterCanContinueCallback(t *testing.T) {
201+ prevDecode := decodeCEFStringList
202+ decodeCEFStringList = func(purecef.StringList) []string { return nil }
203+ defer func() { decodeCEFStringList = prevDecode }()
204+
205+ prevContinue := continueCEFFileDialog
206+ continueCEFFileDialog = func(_ *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) {
207+ recorder, ok := callback.(*stubFileDialogCallback)
208+ require.True(t, ok)
209+ recorder.paths = append([]string(nil), filePaths...)
210+ }
211+ defer func() { continueCEFFileDialog = prevContinue }()
212+
213+ callback := &stubFileDialogCallback{}
214+ h := &handlerSet{
215+ wv: &WebView{ctx: context.Background()},
216+ fileDialogPresenter: func(_ *WebView, _ cefFileDialogRequest, cb purecef.FileDialogCallback) {
217+ continueCEFFileDialog(nil, cb, "/tmp/report.txt")
218+ },
219+ }
220+
221+ handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogSave, "", "", 0, 0, 0, callback)
222+
223+ require.Equal(t, int32(1), handled)
224+ require.False(t, callback.canceled)
225+ require.Equal(t, []string{"/tmp/report.txt"}, callback.paths)
226+}
M internal/infrastructure/cef/webview_handlers.go +6 -4
@@ -23,9 +23,10 @@ const (
2323 // owning WebView. A single struct is used so that the Client's Get*Handler
2424 // methods can return the same receiver, avoiding extra allocations.
2525 type handlerSet struct {
26- wv *WebView
27- renderHandlerOnce sync.Once
28- renderHandler purecef.RenderHandler
26+ wv *WebView
27+ fileDialogPresenter fileDialogPresenter
28+ renderHandlerOnce sync.Once
29+ renderHandler purecef.RenderHandler
2930 }
3031
3132 // Compile-time interface checks.
@@ -37,6 +38,7 @@ var (
3738 _ purecef.RequestHandler = (*handlerSet)(nil)
3839 _ purecef.AudioHandler = (*handlerSet)(nil)
3940 _ purecef.ContextMenuHandler = (*handlerSet)(nil)
41+ _ purecef.DialogHandler = (*handlerSet)(nil)
4042 _ purecef.DownloadHandler = (*handlerSet)(nil)
4143 _ purecef.FindHandler = (*handlerSet)(nil)
4244 )
@@ -54,7 +56,7 @@ func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
5456 }
5557 func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
5658 func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h }
57-func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }
59+func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return h }
5860 func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
5961 func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler {
6062 if h.downloadHandler() != nil {
@@ -23,9 +23,10 @@ const (
23 // owning WebView. A single struct is used so that the Client's Get*Handler23 // owning WebView. A single struct is used so that the Client's Get*Handler
24 // methods can return the same receiver, avoiding extra allocations.24 // methods can return the same receiver, avoiding extra allocations.
25 type handlerSet struct {25 type handlerSet struct {
26- wv *WebView26+ wv *WebView
27- renderHandlerOnce sync.Once27+ fileDialogPresenter fileDialogPresenter
28- renderHandler purecef.RenderHandler28+ renderHandlerOnce sync.Once
29+ renderHandler purecef.RenderHandler
29 }30 }
30 31
31 // Compile-time interface checks.32 // Compile-time interface checks.
@@ -37,6 +38,7 @@ var (
37 _ purecef.RequestHandler = (*handlerSet)(nil)38 _ purecef.RequestHandler = (*handlerSet)(nil)
38 _ purecef.AudioHandler = (*handlerSet)(nil)39 _ purecef.AudioHandler = (*handlerSet)(nil)
39 _ purecef.ContextMenuHandler = (*handlerSet)(nil)40 _ purecef.ContextMenuHandler = (*handlerSet)(nil)
41+ _ purecef.DialogHandler = (*handlerSet)(nil)
40 _ purecef.DownloadHandler = (*handlerSet)(nil)42 _ purecef.DownloadHandler = (*handlerSet)(nil)
41 _ purecef.FindHandler = (*handlerSet)(nil)43 _ purecef.FindHandler = (*handlerSet)(nil)
42 )44 )
@@ -54,7 +56,7 @@ func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
54 }56 }
55 func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }57 func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil }
56 func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h }58 func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h }
57-func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }59+func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return h }
58 func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }60 func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
59 func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler {61 func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler {
60 if h.downloadHandler() != nil {62 if h.downloadHandler() != nil {

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