v0.32.0 0617278

file_dialog.go RawHistoryFolder

1package cef
2
3import (
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
21type cefFileDialogRequest struct {
22	Mode               purecef.FileDialogMode
23	Title              string
24	DefaultFilePath    string
25	AcceptFilters      []string
26	AcceptExtensions   []string
27	AcceptDescriptions []string
28}
29
30type fileDialogPresenter func(*WebView, cefFileDialogRequest, purecef.FileDialogCallback)
31
32var maxExpandedFolderUploadFiles = 5000
33
34var (
35	decodeCEFStringList   = purecef.StringListToSlice
36	newCEFStringList      = purecef.NewStringList
37	freeCEFStringList     = purecef.FreeStringList
38	continueCEFFileDialog = continueCEFFileDialogWithLogs
39)
40
41func (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
80func 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
128func 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
156func 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
169func 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.
189type 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.
198func 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
233func 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
255func 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
289func 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
298func dialogFilterExtensions(req cefFileDialogRequest, index int) string {
299	if index >= len(req.AcceptExtensions) {
300		return ""
301	}
302	return req.AcceptExtensions[index]
303}
304
305func 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.SplitSeq(extensions, ";") {
311		part = strings.TrimSpace(part)
312		if part != "" {
313			tokens = append(tokens, part)
314		}
315	}
316	return tokens
317}
318
319func 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
342func 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
374func 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
434func gioFileDialogPath(file *gio.FileBase) string {
435	if file == nil {
436		return ""
437	}
438	return strings.TrimSpace(file.GetPath())
439}
440
441func unrefGObjectPointer(ptr uintptr) {
442	if ptr == 0 {
443		return
444	}
445	obj := &gobject.Object{}
446	obj.SetGoPointer(ptr)
447	obj.Unref()
448}
449
450func normalizeGTKResponseID(responseID int) int {
451	return int(int32(uint32(responseID)))
452}
453
454func 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
485func 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
540func nonNilCEFFileDialogLogger(logger *zerolog.Logger) *zerolog.Logger {
541	if logger != nil {
542		return logger
543	}
544	return logging.FromContext(context.Background())
545}
546
547type cefRawPointerHolder interface {
548	RawPointer() unsafe.Pointer
549}
550
551func 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
562func 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}

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