detached eb6293e

Add GTK-backed CEF file dialog experiments

commit
eb6293ed2eb1f77600edf5312d5bc5c7042ecd9b
author
brice · · 2026-05-07 16:25 UTC
parent
4069a5d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +902 -4
A assets/manual-tests/file-dialogs.html +233 -0
@@ -0,0 +1,233 @@
1+<!DOCTYPE html>
2+<html lang="fr">
3+<head>
4+<meta charset="UTF-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+<title>Test des dialogues de fichiers — Dumber</title>
7+<style>
8+ body {
9+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
10+ max-width: 800px;
11+ margin: 2rem auto;
12+ padding: 0 1rem;
13+ background: #fafafa;
14+ color: #222;
15+ line-height: 1.5;
16+ }
17+ h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
18+ h2 { font-size: 1.1rem; margin: 1.5rem 0 0.75rem; }
19+ section {
20+ background: #fff;
21+ border: 1px solid #ddd;
22+ border-radius: 6px;
23+ padding: 1rem 1.25rem;
24+ margin-bottom: 1rem;
25+ }
26+ label { display: block; margin-bottom: 0.5rem; font-weight: 600; }
27+ input[type="file"] { display: block; margin-bottom: 0.75rem; }
28+ button {
29+ font-size: 0.95rem;
30+ padding: 0.4rem 1rem;
31+ border: 1px solid #888;
32+ border-radius: 4px;
33+ background: #eee;
34+ cursor: pointer;
35+ }
36+ button:active { background: #ddd; }
37+ #log {
38+ background: #1e1e1e;
39+ color: #d4d4d4;
40+ font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace;
41+ font-size: 0.85rem;
42+ padding: 0.75rem 1rem;
43+ border-radius: 6px;
44+ max-height: 20em;
45+ overflow-y: auto;
46+ white-space: pre-wrap;
47+ overflow-wrap: anywhere;
48+ }
49+ #log .info { color: #6bc; }
50+ #log .ok { color: #6c6; }
51+ #log .warn { color: #da5; }
52+ #log .error { color: #e55; }
53+ .instructions {
54+ background: #f0f6ff;
55+ border: 1px solid #b8d4fe;
56+ font-size: 0.9rem;
57+ padding: 0.75rem 1rem;
58+ border-radius: 6px;
59+ margin-bottom: 1.5rem;
60+ }
61+ .instructions code {
62+ background: #e3edf7;
63+ padding: 0.1em 0.4em;
64+ border-radius: 3px;
65+ font-size: 0.85rem;
66+ }
67+ hr { border: none; border-top: 1px solid #ddd; margin: 1.5rem 0; }
68+</style>
69+</head>
70+<body>
71+
72+<h1>🧪 Test des dialogues de fichiers</h1>
73+<p>Page HTML simple pour tester les sélecteurs de fichiers et l'emplacement de sauvegarde dans Dumber.</p>
74+
75+<div class="instructions">
76+ <strong>Ouvrir cette page dans Dumber :</strong><br>
77+ <code>ENV=dev DUMBER_LOG_LEVEL=debug ./dist/dumber browse /home/brice/dev/projects/dumber/assets/manual-tests/file-dialogs.html</code>
78+ <p style="margin:0.6rem 0 0; color:#555;">
79+ Note : le choix de dossier via <code>webkitdirectory</code> et la boîte de dialogue de sauvegarde via <code>showSaveFilePicker()</code>
80+ dépendent du moteur navigateur embarqué.
81+ </p>
82+</div>
83+
84+<!-- ───── Upload section ───── -->
85+<section>
86+ <h2>📤 Upload / Ouverture</h2>
87+
88+ <label for="single-file">Un seul fichier</label>
89+ <input type="file" id="single-file" onclick="this.value = null">
90+
91+ <label for="multi-file">Plusieurs fichiers</label>
92+ <input type="file" id="multi-file" multiple onclick="this.value = null">
93+
94+ <label for="dir-picker">Dossier (webkitdirectory)</label>
95+ <input type="file" id="dir-picker" webkitdirectory multiple onclick="this.value = null">
96+</section>
97+
98+<!-- ───── Save section ───── -->
99+<section>
100+ <h2>💾 Emplacement de sauvegarde</h2>
101+ <p style="margin:0 0 0.75rem; font-size:0.9rem; color:#555;">
102+ Utilise <code>window.showSaveFilePicker()</code> — disponible dans les navigateurs basés sur Chromium.
103+ </p>
104+ <button type="button" id="save-btn">Choisir un emplacement et écrire le fichier</button>
105+</section>
106+
107+<!-- ───── Status log ───── -->
108+<section>
109+ <div style="display:flex; align-items:center; justify-content:space-between; gap:1rem; margin-bottom:0.75rem;">
110+ <h2 style="margin:0;">📋 Journal des événements</h2>
111+ <button type="button" id="clear-log-btn">Vider le journal</button>
112+ </div>
113+ <div id="log">[journal prêt]</div>
114+</section>
115+
116+<script>
117+ const logger = (() => {
118+ const el = document.getElementById('log');
119+ return (level, msg) => {
120+ const line = document.createElement('div');
121+ line.className = level;
122+ line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
123+ el.appendChild(line);
124+ el.scrollTop = el.scrollHeight;
125+ };
126+ })();
127+
128+ function info(msg) { logger('info', msg); }
129+ function ok(msg) { logger('ok', msg); }
130+ function warn(msg) { logger('warn', msg); }
131+ function error(msg) { logger('error', msg); }
132+
133+ document.getElementById('clear-log-btn').addEventListener('click', function () {
134+ document.getElementById('log').textContent = '[journal vidé]';
135+ });
136+
137+ if (!('webkitdirectory' in document.createElement('input'))) {
138+ warn('Le support de webkitdirectory ne semble pas disponible dans ce navigateur.');
139+ }
140+
141+ if (!window.showSaveFilePicker) {
142+ warn('showSaveFilePicker() ne semble pas disponible dans ce navigateur.');
143+ }
144+
145+ // ── Single file ──
146+ document.getElementById('single-file').addEventListener('change', function (e) {
147+ const file = this.files[0];
148+ if (!file) { info('Single file: selection cleared'); return; }
149+ ok(`Single file: "${file.name}" (${(file.size / 1024).toFixed(1)} KB, type: ${file.type || 'unknown'})`);
150+ });
151+
152+ // ── Multiple files ──
153+ document.getElementById('multi-file').addEventListener('change', function () {
154+ const files = this.files;
155+ if (!files.length) { info('Multiple files: selection cleared'); return; }
156+ ok(`Multiple files: ${files.length} selected`);
157+ for (let i = 0; i < files.length; i++) {
158+ const f = files[i];
159+ ok(` [${i}] "${f.name}" (${(f.size / 1024).toFixed(1)} KB)`);
160+ }
161+ });
162+
163+ // ── Directory ──
164+ document.getElementById('dir-picker').addEventListener('change', function () {
165+ const files = this.files;
166+ if (!files.length) { info('Folder: selection cleared'); return; }
167+ ok(`Folder: ${files.length} entries selected`);
168+
169+ // Collect folder names
170+ const dirs = new Set();
171+ for (let i = 0; i < files.length; i++) {
172+ const path = files[i].webkitRelativePath;
173+ const parts = path.split('/');
174+ if (parts.length > 1) dirs.add(parts[0]);
175+ }
176+ for (const d of dirs) {
177+ ok(` folder: "${d}"`);
178+ }
179+ // Show up to 5 filenames
180+ const maxShow = 5;
181+ for (let i = 0; i < Math.min(files.length, maxShow); i++) {
182+ const f = files[i];
183+ ok(` file: "${f.webkitRelativePath}" (${(f.size / 1024).toFixed(1)} KB)`);
184+ }
185+ if (files.length > maxShow) {
186+ ok(` … and ${files.length - maxShow} more entries`);
187+ }
188+ });
189+
190+ // ── Save file picker ──
191+ const saveButton = document.getElementById('save-btn');
192+ saveButton.addEventListener('click', async function () {
193+ if (saveButton.disabled) return;
194+
195+ saveButton.disabled = true;
196+ info('Ouverture de la boîte de dialogue de sauvegarde…');
197+
198+ if (!window.showSaveFilePicker) {
199+ warn('showSaveFilePicker() n\'est pas disponible dans ce navigateur.');
200+ warn('Essayez avec un navigateur basé sur Chromium.');
201+ saveButton.disabled = false;
202+ return;
203+ }
204+
205+ try {
206+ const handle = await window.showSaveFilePicker({
207+ suggestedName: 'dumber-save-test.txt',
208+ types: [{
209+ description: 'Text files',
210+ accept: { 'text/plain': ['.txt'] }
211+ }]
212+ });
213+
214+ const writable = await handle.createWritable();
215+ const payload = 'Dumber save test — ' + new Date().toISOString() + '\n';
216+ await writable.write(payload);
217+ await writable.close();
218+
219+ ok(`Fichier sauvegardé : "${handle.name}"`);
220+ } catch (err) {
221+ if (err?.name === 'AbortError' || err?.name === 'SecurityError') {
222+ warn('Sauvegarde annulée ou permission refusée.');
223+ } else {
224+ error(`Échec de la sauvegarde : ${err?.message ?? err}`);
225+ }
226+ } finally {
227+ saveButton.disabled = false;
228+ }
229+ });
230+</script>
231+
232+</body>
233+</html>
@@ -0,0 +1,233 @@
1+<!DOCTYPE html>
2+<html lang="fr">
3+<head>
4+<meta charset="UTF-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+<title>Test des dialogues de fichiers — Dumber</title>
7+<style>
8+ body {
9+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
10+ max-width: 800px;
11+ margin: 2rem auto;
12+ padding: 0 1rem;
13+ background: #fafafa;
14+ color: #222;
15+ line-height: 1.5;
16+ }
17+ h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
18+ h2 { font-size: 1.1rem; margin: 1.5rem 0 0.75rem; }
19+ section {
20+ background: #fff;
21+ border: 1px solid #ddd;
22+ border-radius: 6px;
23+ padding: 1rem 1.25rem;
24+ margin-bottom: 1rem;
25+ }
26+ label { display: block; margin-bottom: 0.5rem; font-weight: 600; }
27+ input[type="file"] { display: block; margin-bottom: 0.75rem; }
28+ button {
29+ font-size: 0.95rem;
30+ padding: 0.4rem 1rem;
31+ border: 1px solid #888;
32+ border-radius: 4px;
33+ background: #eee;
34+ cursor: pointer;
35+ }
36+ button:active { background: #ddd; }
37+ #log {
38+ background: #1e1e1e;
39+ color: #d4d4d4;
40+ font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace;
41+ font-size: 0.85rem;
42+ padding: 0.75rem 1rem;
43+ border-radius: 6px;
44+ max-height: 20em;
45+ overflow-y: auto;
46+ white-space: pre-wrap;
47+ overflow-wrap: anywhere;
48+ }
49+ #log .info { color: #6bc; }
50+ #log .ok { color: #6c6; }
51+ #log .warn { color: #da5; }
52+ #log .error { color: #e55; }
53+ .instructions {
54+ background: #f0f6ff;
55+ border: 1px solid #b8d4fe;
56+ font-size: 0.9rem;
57+ padding: 0.75rem 1rem;
58+ border-radius: 6px;
59+ margin-bottom: 1.5rem;
60+ }
61+ .instructions code {
62+ background: #e3edf7;
63+ padding: 0.1em 0.4em;
64+ border-radius: 3px;
65+ font-size: 0.85rem;
66+ }
67+ hr { border: none; border-top: 1px solid #ddd; margin: 1.5rem 0; }
68+</style>
69+</head>
70+<body>
71+
72+<h1>🧪 Test des dialogues de fichiers</h1>
73+<p>Page HTML simple pour tester les sélecteurs de fichiers et l'emplacement de sauvegarde dans Dumber.</p>
74+
75+<div class="instructions">
76+ <strong>Ouvrir cette page dans Dumber :</strong><br>
77+ <code>ENV=dev DUMBER_LOG_LEVEL=debug ./dist/dumber browse /home/brice/dev/projects/dumber/assets/manual-tests/file-dialogs.html</code>
78+ <p style="margin:0.6rem 0 0; color:#555;">
79+ Note : le choix de dossier via <code>webkitdirectory</code> et la boîte de dialogue de sauvegarde via <code>showSaveFilePicker()</code>
80+ dépendent du moteur navigateur embarqué.
81+ </p>
82+</div>
83+
84+<!-- ───── Upload section ───── -->
85+<section>
86+ <h2>📤 Upload / Ouverture</h2>
87+
88+ <label for="single-file">Un seul fichier</label>
89+ <input type="file" id="single-file" onclick="this.value = null">
90+
91+ <label for="multi-file">Plusieurs fichiers</label>
92+ <input type="file" id="multi-file" multiple onclick="this.value = null">
93+
94+ <label for="dir-picker">Dossier (webkitdirectory)</label>
95+ <input type="file" id="dir-picker" webkitdirectory multiple onclick="this.value = null">
96+</section>
97+
98+<!-- ───── Save section ───── -->
99+<section>
100+ <h2>💾 Emplacement de sauvegarde</h2>
101+ <p style="margin:0 0 0.75rem; font-size:0.9rem; color:#555;">
102+ Utilise <code>window.showSaveFilePicker()</code> — disponible dans les navigateurs basés sur Chromium.
103+ </p>
104+ <button type="button" id="save-btn">Choisir un emplacement et écrire le fichier</button>
105+</section>
106+
107+<!-- ───── Status log ───── -->
108+<section>
109+ <div style="display:flex; align-items:center; justify-content:space-between; gap:1rem; margin-bottom:0.75rem;">
110+ <h2 style="margin:0;">📋 Journal des événements</h2>
111+ <button type="button" id="clear-log-btn">Vider le journal</button>
112+ </div>
113+ <div id="log">[journal prêt]</div>
114+</section>
115+
116+<script>
117+ const logger = (() => {
118+ const el = document.getElementById('log');
119+ return (level, msg) => {
120+ const line = document.createElement('div');
121+ line.className = level;
122+ line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
123+ el.appendChild(line);
124+ el.scrollTop = el.scrollHeight;
125+ };
126+ })();
127+
128+ function info(msg) { logger('info', msg); }
129+ function ok(msg) { logger('ok', msg); }
130+ function warn(msg) { logger('warn', msg); }
131+ function error(msg) { logger('error', msg); }
132+
133+ document.getElementById('clear-log-btn').addEventListener('click', function () {
134+ document.getElementById('log').textContent = '[journal vidé]';
135+ });
136+
137+ if (!('webkitdirectory' in document.createElement('input'))) {
138+ warn('Le support de webkitdirectory ne semble pas disponible dans ce navigateur.');
139+ }
140+
141+ if (!window.showSaveFilePicker) {
142+ warn('showSaveFilePicker() ne semble pas disponible dans ce navigateur.');
143+ }
144+
145+ // ── Single file ──
146+ document.getElementById('single-file').addEventListener('change', function (e) {
147+ const file = this.files[0];
148+ if (!file) { info('Single file: selection cleared'); return; }
149+ ok(`Single file: "${file.name}" (${(file.size / 1024).toFixed(1)} KB, type: ${file.type || 'unknown'})`);
150+ });
151+
152+ // ── Multiple files ──
153+ document.getElementById('multi-file').addEventListener('change', function () {
154+ const files = this.files;
155+ if (!files.length) { info('Multiple files: selection cleared'); return; }
156+ ok(`Multiple files: ${files.length} selected`);
157+ for (let i = 0; i < files.length; i++) {
158+ const f = files[i];
159+ ok(` [${i}] "${f.name}" (${(f.size / 1024).toFixed(1)} KB)`);
160+ }
161+ });
162+
163+ // ── Directory ──
164+ document.getElementById('dir-picker').addEventListener('change', function () {
165+ const files = this.files;
166+ if (!files.length) { info('Folder: selection cleared'); return; }
167+ ok(`Folder: ${files.length} entries selected`);
168+
169+ // Collect folder names
170+ const dirs = new Set();
171+ for (let i = 0; i < files.length; i++) {
172+ const path = files[i].webkitRelativePath;
173+ const parts = path.split('/');
174+ if (parts.length > 1) dirs.add(parts[0]);
175+ }
176+ for (const d of dirs) {
177+ ok(` folder: "${d}"`);
178+ }
179+ // Show up to 5 filenames
180+ const maxShow = 5;
181+ for (let i = 0; i < Math.min(files.length, maxShow); i++) {
182+ const f = files[i];
183+ ok(` file: "${f.webkitRelativePath}" (${(f.size / 1024).toFixed(1)} KB)`);
184+ }
185+ if (files.length > maxShow) {
186+ ok(` … and ${files.length - maxShow} more entries`);
187+ }
188+ });
189+
190+ // ── Save file picker ──
191+ const saveButton = document.getElementById('save-btn');
192+ saveButton.addEventListener('click', async function () {
193+ if (saveButton.disabled) return;
194+
195+ saveButton.disabled = true;
196+ info('Ouverture de la boîte de dialogue de sauvegarde…');
197+
198+ if (!window.showSaveFilePicker) {
199+ warn('showSaveFilePicker() n\'est pas disponible dans ce navigateur.');
200+ warn('Essayez avec un navigateur basé sur Chromium.');
201+ saveButton.disabled = false;
202+ return;
203+ }
204+
205+ try {
206+ const handle = await window.showSaveFilePicker({
207+ suggestedName: 'dumber-save-test.txt',
208+ types: [{
209+ description: 'Text files',
210+ accept: { 'text/plain': ['.txt'] }
211+ }]
212+ });
213+
214+ const writable = await handle.createWritable();
215+ const payload = 'Dumber save test — ' + new Date().toISOString() + '\n';
216+ await writable.write(payload);
217+ await writable.close();
218+
219+ ok(`Fichier sauvegardé : "${handle.name}"`);
220+ } catch (err) {
221+ if (err?.name === 'AbortError' || err?.name === 'SecurityError') {
222+ warn('Sauvegarde annulée ou permission refusée.');
223+ } else {
224+ error(`Échec de la sauvegarde : ${err?.message ?? err}`);
225+ }
226+ } finally {
227+ saveButton.disabled = false;
228+ }
229+ });
230+</script>
231+
232+</body>
233+</html>
M go.mod +2 -0
@@ -80,3 +80,5 @@ require (
8080 )
8181
8282 tool github.com/a-h/templ/cmd/templ
83+
84+replace github.com/bnema/purego-cef => ../purego-cef
@@ -80,3 +80,5 @@ require (
80 )80 )
81 81
82 tool github.com/a-h/templ/cmd/templ82 tool github.com/a-h/templ/cmd/templ
83+
84+replace github.com/bnema/purego-cef => ../purego-cef
A internal/infrastructure/cef/file_dialog.go +530 -0
@@ -0,0 +1,530 @@
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+const maxFolderUploadExperimentFiles = 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+ Str("default_file_path", 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+ Str("default_file_path", 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 cancelled")
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+ Strs("paths", paths).
120+ Msg("cef: file dialog selection captured")
121+ paths = folderUploadExperimentPaths(wv, req.Mode, paths)
122+ dispatchFileDialogResult(wv, callback, paths)
123+ }
124+ dialog.ConnectResponse(&onResponse)
125+ dialog.Show()
126+ })
127+}
128+
129+func newNativeFileDialog(wv *WebView, req cefFileDialogRequest) (*gtk.FileChooserNative, bool) {
130+ action, ok := fileChooserActionForMode(req.Mode)
131+ if !ok {
132+ logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: unsupported file dialog mode")
133+ return nil, false
134+ }
135+
136+ var titlePtr *string
137+ title := strings.TrimSpace(req.Title)
138+ if title != "" {
139+ titlePtr = &title
140+ }
141+
142+ dialog := gtk.NewFileChooserNative(titlePtr, fileDialogParentWindow(wv), action, nil, nil)
143+ if dialog == nil {
144+ logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: failed to create GTK file chooser dialog")
145+ return nil, false
146+ }
147+
148+ dialog.SetModal(true)
149+ if req.Mode == purecef.FileDialogModeFileDialogOpenMultiple {
150+ dialog.SetSelectMultiple(true)
151+ }
152+ applyDefaultDialogPath(dialog, req)
153+ applyDialogFilters(dialog, req)
154+ return dialog, true
155+}
156+
157+func fileChooserActionForMode(mode purecef.FileDialogMode) (gtk.FileChooserAction, bool) {
158+ switch mode {
159+ case purecef.FileDialogModeFileDialogOpen, purecef.FileDialogModeFileDialogOpenMultiple:
160+ return gtk.FileChooserActionOpenValue, true
161+ case purecef.FileDialogModeFileDialogOpenFolder:
162+ return gtk.FileChooserActionSelectFolderValue, true
163+ case purecef.FileDialogModeFileDialogSave:
164+ return gtk.FileChooserActionSaveValue, true
165+ default:
166+ return 0, false
167+ }
168+}
169+
170+func fileDialogParentWindow(wv *WebView) *gtk.Window {
171+ if wv == nil || wv.viewBridge == nil {
172+ return nil
173+ }
174+ widget := wv.viewBridge.Widget()
175+ if widget == nil {
176+ return nil
177+ }
178+ ancestor := widget.GetAncestor(gtk.WindowGLibType())
179+ if ancestor == nil {
180+ return nil
181+ }
182+ // puregotk's GetAncestor wrapper adds a reference before returning.
183+ defer ancestor.Unref()
184+ return gtk.WindowNewFromInternalPtr(ancestor.GoPointer())
185+}
186+
187+func applyDefaultDialogPath(dialog *gtk.FileChooserNative, req cefFileDialogRequest) {
188+ if dialog == nil {
189+ return
190+ }
191+ path := strings.TrimSpace(req.DefaultFilePath)
192+ if path == "" {
193+ return
194+ }
195+
196+ if req.Mode == purecef.FileDialogModeFileDialogSave {
197+ if dir := filepath.Dir(path); dir != "." && dir != "" {
198+ if folder := gio.FileNewForPath(dir); folder != nil {
199+ _, _ = dialog.SetCurrentFolder(folder)
200+ }
201+ }
202+ if name := filepath.Base(path); name != "." && name != string(filepath.Separator) {
203+ dialog.SetCurrentName(name)
204+ }
205+ return
206+ }
207+
208+ if info, err := os.Stat(path); err == nil && info.IsDir() {
209+ if folder := gio.FileNewForPath(path); folder != nil {
210+ _, _ = dialog.SetCurrentFolder(folder)
211+ }
212+ return
213+ }
214+
215+ if dir := filepath.Dir(path); dir != "." && dir != "" {
216+ if folder := gio.FileNewForPath(dir); folder != nil {
217+ _, _ = dialog.SetCurrentFolder(folder)
218+ }
219+ }
220+ if req.Mode != purecef.FileDialogModeFileDialogOpenFolder {
221+ if file := gio.FileNewForPath(path); file != nil {
222+ _, _ = dialog.SetFile(file)
223+ }
224+ }
225+}
226+
227+func applyDialogFilters(dialog *gtk.FileChooserNative, req cefFileDialogRequest) {
228+ if dialog == nil {
229+ return
230+ }
231+
232+ appliedAny := false
233+ for i, spec := range req.AcceptFilters {
234+ filter := gtk.NewFileFilter()
235+ if filter == nil {
236+ continue
237+ }
238+
239+ if name := dialogFilterName(req, i, spec); name != "" {
240+ filter.SetName(&name)
241+ }
242+
243+ added := false
244+ for _, token := range dialogFilterTokens(spec, dialogFilterExtensions(req, i)) {
245+ if addDialogFilterToken(filter, token) {
246+ added = true
247+ }
248+ }
249+ if !added {
250+ continue
251+ }
252+
253+ dialog.AddFilter(filter)
254+ if !appliedAny {
255+ dialog.SetFilter(filter)
256+ appliedAny = true
257+ }
258+ }
259+}
260+
261+func dialogFilterName(req cefFileDialogRequest, index int, fallback string) string {
262+ if index < len(req.AcceptDescriptions) {
263+ if name := strings.TrimSpace(req.AcceptDescriptions[index]); name != "" {
264+ return name
265+ }
266+ }
267+ return strings.TrimSpace(fallback)
268+}
269+
270+func dialogFilterExtensions(req cefFileDialogRequest, index int) string {
271+ if index >= len(req.AcceptExtensions) {
272+ return ""
273+ }
274+ return req.AcceptExtensions[index]
275+}
276+
277+func dialogFilterTokens(spec, extensions string) []string {
278+ tokens := make([]string, 0, 4)
279+ if spec = strings.TrimSpace(spec); spec != "" {
280+ tokens = append(tokens, spec)
281+ }
282+ for _, part := range strings.Split(extensions, ";") {
283+ part = strings.TrimSpace(part)
284+ if part != "" {
285+ tokens = append(tokens, part)
286+ }
287+ }
288+ return tokens
289+}
290+
291+func addDialogFilterToken(filter *gtk.FileFilter, token string) bool {
292+ if filter == nil {
293+ return false
294+ }
295+ token = strings.TrimSpace(token)
296+ if token == "" {
297+ return false
298+ }
299+ if strings.Contains(token, "/") && !strings.HasPrefix(token, ".") {
300+ filter.AddMimeType(token)
301+ return true
302+ }
303+ if strings.HasPrefix(token, ".") {
304+ filter.AddPattern("*" + token)
305+ return true
306+ }
307+ if strings.ContainsRune(token, '*') {
308+ filter.AddPattern(token)
309+ return true
310+ }
311+ return false
312+}
313+
314+func selectedFileDialogPaths(dialog *gtk.FileChooserNative, mode purecef.FileDialogMode) []string {
315+ if dialog == nil {
316+ return nil
317+ }
318+ if mode == purecef.FileDialogModeFileDialogOpenMultiple {
319+ model := dialog.GetFiles()
320+ if model == nil {
321+ return nil
322+ }
323+ defer unrefGObjectPointer(model.GoPointer())
324+ paths := make([]string, 0, model.GetNItems())
325+ for i := uint(0); i < model.GetNItems(); i++ {
326+ obj := model.GetObject(i)
327+ if obj == nil {
328+ continue
329+ }
330+ file := &gio.FileBase{Ptr: obj.GoPointer()}
331+ if path := gioFileDialogPath(file); path != "" {
332+ paths = append(paths, path)
333+ }
334+ obj.Unref()
335+ }
336+ return paths
337+ }
338+ file := dialog.GetFile()
339+ defer unrefGObjectPointer(file.GoPointer())
340+ if path := gioFileDialogPath(file); path != "" {
341+ return []string{path}
342+ }
343+ return nil
344+}
345+
346+func folderUploadExperimentPaths(wv *WebView, mode purecef.FileDialogMode, paths []string) []string {
347+ if mode != purecef.FileDialogModeFileDialogOpenFolder || len(paths) != 1 {
348+ return paths
349+ }
350+ folder := strings.TrimSpace(paths[0])
351+ if folder == "" {
352+ return paths
353+ }
354+ info, err := os.Stat(folder)
355+ if err != nil || !info.IsDir() {
356+ logCEFFileDialog(wv).Warn().Err(err).Str("path", folder).Msg("cef: folder mode experiment skipped; selected path is not a directory")
357+ return paths
358+ }
359+
360+ files := make([]string, 0, 64)
361+ truncated := false
362+ walkErr := filepath.WalkDir(folder, func(path string, entry fs.DirEntry, err error) error {
363+ if err != nil {
364+ logCEFFileDialog(wv).Debug().Err(err).Str("path", path).Msg("cef: folder mode experiment skipped unreadable entry")
365+ return nil
366+ }
367+ if path == folder || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 {
368+ return nil
369+ }
370+ info, err := entry.Info()
371+ if err != nil || !info.Mode().IsRegular() {
372+ return nil
373+ }
374+ files = append(files, path)
375+ if len(files) >= maxFolderUploadExperimentFiles {
376+ truncated = true
377+ return filepath.SkipAll
378+ }
379+ return nil
380+ })
381+ if walkErr != nil {
382+ logCEFFileDialog(wv).Warn().Err(walkErr).Str("folder", folder).Msg("cef: folder mode experiment failed to enumerate selected folder")
383+ return paths
384+ }
385+ if len(files) == 0 {
386+ logCEFFileDialog(wv).Warn().Str("folder", folder).Msg("cef: folder mode experiment found no descendant files; using selected directory path")
387+ return paths
388+ }
389+
390+ logCEFFileDialog(wv).Warn().
391+ Str("folder", folder).
392+ Int("file_count", len(files)).
393+ Bool("truncated", truncated).
394+ Msg("cef: folder mode experiment passing descendant file paths instead of selected directory")
395+ return files
396+}
397+
398+func gioFileDialogPath(file *gio.FileBase) string {
399+ if file == nil {
400+ return ""
401+ }
402+ return strings.TrimSpace(file.GetPath())
403+}
404+
405+func unrefGObjectPointer(ptr uintptr) {
406+ if ptr == 0 {
407+ return
408+ }
409+ obj := &gobject.Object{}
410+ obj.SetGoPointer(ptr)
411+ obj.Unref()
412+}
413+
414+func normalizeGTKResponseID(responseID int) int {
415+ return int(int32(uint32(responseID)))
416+}
417+
418+func dispatchFileDialogResult(wv *WebView, callback purecef.FileDialogCallback, paths []string) {
419+ if callback == nil {
420+ return
421+ }
422+ if wv == nil || wv.engine == nil {
423+ return
424+ }
425+
426+ task := cefNewTask(cefTaskFunc(func() {
427+ logger := logCEFFileDialog(wv)
428+ logger.Debug().
429+ Int("path_count", len(paths)).
430+ Strs("paths", paths).
431+ Int32("currently_on_ui", purecef.CurrentlyOn(purecef.ThreadIDTidUi)).
432+ Msg("cef: file dialog continuation task executing")
433+ continueCEFFileDialog(logger, callback, paths...)
434+ logger.Debug().
435+ Int("path_count", len(paths)).
436+ Strs("paths", paths).
437+ Msg("cef: file dialog continuation task finished")
438+ }))
439+ if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
440+ logCEFFileDialog(wv).Warn().Int32("post_result", result).Msg("cef: failed to post file dialog continuation to CEF UI thread")
441+ } else {
442+ logCEFFileDialog(wv).Debug().Int("path_count", len(paths)).Strs("paths", paths).Msg("cef: file dialog continuation task posted")
443+ }
444+}
445+
446+func continueCEFFileDialogWithLogs(logger *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) {
447+ logger = nonNilCEFFileDialogLogger(logger)
448+ callbackPtr := rawCEFPointer(callback)
449+ if callback == nil {
450+ logger.Debug().Msg("cef: file dialog callback continuation skipped; callback is nil")
451+ return
452+ }
453+
454+ if len(filePaths) == 0 {
455+ logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: invoking file dialog callback cancel")
456+ callback.Cancel()
457+ runtime.KeepAlive(callback)
458+ logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned")
459+ return
460+ }
461+
462+ logger.Debug().
463+ Uint64("callback_ptr", uint64(callbackPtr)).
464+ Int("path_count", len(filePaths)).
465+ Strs("paths", filePaths).
466+ Msg("cef: allocating file dialog CEF string list")
467+ list := newCEFStringList(filePaths...)
468+ if list == 0 {
469+ logger.Warn().
470+ Uint64("callback_ptr", uint64(callbackPtr)).
471+ Int("path_count", len(filePaths)).
472+ Msg("cef: file dialog CEF string list allocation failed; canceling")
473+ callback.Cancel()
474+ runtime.KeepAlive(callback)
475+ logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned after allocation failure")
476+ return
477+ }
478+
479+ listValue := uint64(list)
480+ logger.Debug().
481+ Uint64("callback_ptr", uint64(callbackPtr)).
482+ Uint64("string_list", listValue).
483+ Int("path_count", len(filePaths)).
484+ Msg("cef: file dialog CEF string list allocated")
485+ logger.Debug().
486+ Uint64("callback_ptr", uint64(callbackPtr)).
487+ Uint64("string_list", listValue).
488+ Int("path_count", len(filePaths)).
489+ Strs("paths", filePaths).
490+ Msg("cef: invoking file dialog callback continuation")
491+ callback.Cont(list)
492+ logger.Debug().
493+ Uint64("callback_ptr", uint64(callbackPtr)).
494+ Uint64("string_list", listValue).
495+ Int("path_count", len(filePaths)).
496+ Msg("cef: file dialog callback continuation returned")
497+ logger.Debug().Uint64("string_list", listValue).Msg("cef: freeing file dialog CEF string list")
498+ freeCEFStringList(list)
499+ logger.Debug().Uint64("string_list", listValue).Msg("cef: freed file dialog CEF string list")
500+ runtime.KeepAlive(callback)
501+}
502+
503+func nonNilCEFFileDialogLogger(logger *zerolog.Logger) *zerolog.Logger {
504+ if logger != nil {
505+ return logger
506+ }
507+ return logging.FromContext(context.Background())
508+}
509+
510+type cefRawPointerHolder interface {
511+ RawPointer() unsafe.Pointer
512+}
513+
514+func rawCEFPointer(v any) uintptr {
515+ if v == nil {
516+ return 0
517+ }
518+ holder, ok := v.(cefRawPointerHolder)
519+ if !ok {
520+ return 0
521+ }
522+ return uintptr(holder.RawPointer())
523+}
524+
525+func logCEFFileDialog(wv *WebView) *zerolog.Logger {
526+ if wv == nil || wv.ctx == nil {
527+ return logging.FromContext(context.Background())
528+ }
529+ return logging.FromContext(wv.ctx)
530+}
@@ -0,0 +1,530 @@
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+const maxFolderUploadExperimentFiles = 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+ Str("default_file_path", 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+ Str("default_file_path", 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 cancelled")
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+ Strs("paths", paths).
120+ Msg("cef: file dialog selection captured")
121+ paths = folderUploadExperimentPaths(wv, req.Mode, paths)
122+ dispatchFileDialogResult(wv, callback, paths)
123+ }
124+ dialog.ConnectResponse(&onResponse)
125+ dialog.Show()
126+ })
127+}
128+
129+func newNativeFileDialog(wv *WebView, req cefFileDialogRequest) (*gtk.FileChooserNative, bool) {
130+ action, ok := fileChooserActionForMode(req.Mode)
131+ if !ok {
132+ logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: unsupported file dialog mode")
133+ return nil, false
134+ }
135+
136+ var titlePtr *string
137+ title := strings.TrimSpace(req.Title)
138+ if title != "" {
139+ titlePtr = &title
140+ }
141+
142+ dialog := gtk.NewFileChooserNative(titlePtr, fileDialogParentWindow(wv), action, nil, nil)
143+ if dialog == nil {
144+ logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: failed to create GTK file chooser dialog")
145+ return nil, false
146+ }
147+
148+ dialog.SetModal(true)
149+ if req.Mode == purecef.FileDialogModeFileDialogOpenMultiple {
150+ dialog.SetSelectMultiple(true)
151+ }
152+ applyDefaultDialogPath(dialog, req)
153+ applyDialogFilters(dialog, req)
154+ return dialog, true
155+}
156+
157+func fileChooserActionForMode(mode purecef.FileDialogMode) (gtk.FileChooserAction, bool) {
158+ switch mode {
159+ case purecef.FileDialogModeFileDialogOpen, purecef.FileDialogModeFileDialogOpenMultiple:
160+ return gtk.FileChooserActionOpenValue, true
161+ case purecef.FileDialogModeFileDialogOpenFolder:
162+ return gtk.FileChooserActionSelectFolderValue, true
163+ case purecef.FileDialogModeFileDialogSave:
164+ return gtk.FileChooserActionSaveValue, true
165+ default:
166+ return 0, false
167+ }
168+}
169+
170+func fileDialogParentWindow(wv *WebView) *gtk.Window {
171+ if wv == nil || wv.viewBridge == nil {
172+ return nil
173+ }
174+ widget := wv.viewBridge.Widget()
175+ if widget == nil {
176+ return nil
177+ }
178+ ancestor := widget.GetAncestor(gtk.WindowGLibType())
179+ if ancestor == nil {
180+ return nil
181+ }
182+ // puregotk's GetAncestor wrapper adds a reference before returning.
183+ defer ancestor.Unref()
184+ return gtk.WindowNewFromInternalPtr(ancestor.GoPointer())
185+}
186+
187+func applyDefaultDialogPath(dialog *gtk.FileChooserNative, req cefFileDialogRequest) {
188+ if dialog == nil {
189+ return
190+ }
191+ path := strings.TrimSpace(req.DefaultFilePath)
192+ if path == "" {
193+ return
194+ }
195+
196+ if req.Mode == purecef.FileDialogModeFileDialogSave {
197+ if dir := filepath.Dir(path); dir != "." && dir != "" {
198+ if folder := gio.FileNewForPath(dir); folder != nil {
199+ _, _ = dialog.SetCurrentFolder(folder)
200+ }
201+ }
202+ if name := filepath.Base(path); name != "." && name != string(filepath.Separator) {
203+ dialog.SetCurrentName(name)
204+ }
205+ return
206+ }
207+
208+ if info, err := os.Stat(path); err == nil && info.IsDir() {
209+ if folder := gio.FileNewForPath(path); folder != nil {
210+ _, _ = dialog.SetCurrentFolder(folder)
211+ }
212+ return
213+ }
214+
215+ if dir := filepath.Dir(path); dir != "." && dir != "" {
216+ if folder := gio.FileNewForPath(dir); folder != nil {
217+ _, _ = dialog.SetCurrentFolder(folder)
218+ }
219+ }
220+ if req.Mode != purecef.FileDialogModeFileDialogOpenFolder {
221+ if file := gio.FileNewForPath(path); file != nil {
222+ _, _ = dialog.SetFile(file)
223+ }
224+ }
225+}
226+
227+func applyDialogFilters(dialog *gtk.FileChooserNative, req cefFileDialogRequest) {
228+ if dialog == nil {
229+ return
230+ }
231+
232+ appliedAny := false
233+ for i, spec := range req.AcceptFilters {
234+ filter := gtk.NewFileFilter()
235+ if filter == nil {
236+ continue
237+ }
238+
239+ if name := dialogFilterName(req, i, spec); name != "" {
240+ filter.SetName(&name)
241+ }
242+
243+ added := false
244+ for _, token := range dialogFilterTokens(spec, dialogFilterExtensions(req, i)) {
245+ if addDialogFilterToken(filter, token) {
246+ added = true
247+ }
248+ }
249+ if !added {
250+ continue
251+ }
252+
253+ dialog.AddFilter(filter)
254+ if !appliedAny {
255+ dialog.SetFilter(filter)
256+ appliedAny = true
257+ }
258+ }
259+}
260+
261+func dialogFilterName(req cefFileDialogRequest, index int, fallback string) string {
262+ if index < len(req.AcceptDescriptions) {
263+ if name := strings.TrimSpace(req.AcceptDescriptions[index]); name != "" {
264+ return name
265+ }
266+ }
267+ return strings.TrimSpace(fallback)
268+}
269+
270+func dialogFilterExtensions(req cefFileDialogRequest, index int) string {
271+ if index >= len(req.AcceptExtensions) {
272+ return ""
273+ }
274+ return req.AcceptExtensions[index]
275+}
276+
277+func dialogFilterTokens(spec, extensions string) []string {
278+ tokens := make([]string, 0, 4)
279+ if spec = strings.TrimSpace(spec); spec != "" {
280+ tokens = append(tokens, spec)
281+ }
282+ for _, part := range strings.Split(extensions, ";") {
283+ part = strings.TrimSpace(part)
284+ if part != "" {
285+ tokens = append(tokens, part)
286+ }
287+ }
288+ return tokens
289+}
290+
291+func addDialogFilterToken(filter *gtk.FileFilter, token string) bool {
292+ if filter == nil {
293+ return false
294+ }
295+ token = strings.TrimSpace(token)
296+ if token == "" {
297+ return false
298+ }
299+ if strings.Contains(token, "/") && !strings.HasPrefix(token, ".") {
300+ filter.AddMimeType(token)
301+ return true
302+ }
303+ if strings.HasPrefix(token, ".") {
304+ filter.AddPattern("*" + token)
305+ return true
306+ }
307+ if strings.ContainsRune(token, '*') {
308+ filter.AddPattern(token)
309+ return true
310+ }
311+ return false
312+}
313+
314+func selectedFileDialogPaths(dialog *gtk.FileChooserNative, mode purecef.FileDialogMode) []string {
315+ if dialog == nil {
316+ return nil
317+ }
318+ if mode == purecef.FileDialogModeFileDialogOpenMultiple {
319+ model := dialog.GetFiles()
320+ if model == nil {
321+ return nil
322+ }
323+ defer unrefGObjectPointer(model.GoPointer())
324+ paths := make([]string, 0, model.GetNItems())
325+ for i := uint(0); i < model.GetNItems(); i++ {
326+ obj := model.GetObject(i)
327+ if obj == nil {
328+ continue
329+ }
330+ file := &gio.FileBase{Ptr: obj.GoPointer()}
331+ if path := gioFileDialogPath(file); path != "" {
332+ paths = append(paths, path)
333+ }
334+ obj.Unref()
335+ }
336+ return paths
337+ }
338+ file := dialog.GetFile()
339+ defer unrefGObjectPointer(file.GoPointer())
340+ if path := gioFileDialogPath(file); path != "" {
341+ return []string{path}
342+ }
343+ return nil
344+}
345+
346+func folderUploadExperimentPaths(wv *WebView, mode purecef.FileDialogMode, paths []string) []string {
347+ if mode != purecef.FileDialogModeFileDialogOpenFolder || len(paths) != 1 {
348+ return paths
349+ }
350+ folder := strings.TrimSpace(paths[0])
351+ if folder == "" {
352+ return paths
353+ }
354+ info, err := os.Stat(folder)
355+ if err != nil || !info.IsDir() {
356+ logCEFFileDialog(wv).Warn().Err(err).Str("path", folder).Msg("cef: folder mode experiment skipped; selected path is not a directory")
357+ return paths
358+ }
359+
360+ files := make([]string, 0, 64)
361+ truncated := false
362+ walkErr := filepath.WalkDir(folder, func(path string, entry fs.DirEntry, err error) error {
363+ if err != nil {
364+ logCEFFileDialog(wv).Debug().Err(err).Str("path", path).Msg("cef: folder mode experiment skipped unreadable entry")
365+ return nil
366+ }
367+ if path == folder || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 {
368+ return nil
369+ }
370+ info, err := entry.Info()
371+ if err != nil || !info.Mode().IsRegular() {
372+ return nil
373+ }
374+ files = append(files, path)
375+ if len(files) >= maxFolderUploadExperimentFiles {
376+ truncated = true
377+ return filepath.SkipAll
378+ }
379+ return nil
380+ })
381+ if walkErr != nil {
382+ logCEFFileDialog(wv).Warn().Err(walkErr).Str("folder", folder).Msg("cef: folder mode experiment failed to enumerate selected folder")
383+ return paths
384+ }
385+ if len(files) == 0 {
386+ logCEFFileDialog(wv).Warn().Str("folder", folder).Msg("cef: folder mode experiment found no descendant files; using selected directory path")
387+ return paths
388+ }
389+
390+ logCEFFileDialog(wv).Warn().
391+ Str("folder", folder).
392+ Int("file_count", len(files)).
393+ Bool("truncated", truncated).
394+ Msg("cef: folder mode experiment passing descendant file paths instead of selected directory")
395+ return files
396+}
397+
398+func gioFileDialogPath(file *gio.FileBase) string {
399+ if file == nil {
400+ return ""
401+ }
402+ return strings.TrimSpace(file.GetPath())
403+}
404+
405+func unrefGObjectPointer(ptr uintptr) {
406+ if ptr == 0 {
407+ return
408+ }
409+ obj := &gobject.Object{}
410+ obj.SetGoPointer(ptr)
411+ obj.Unref()
412+}
413+
414+func normalizeGTKResponseID(responseID int) int {
415+ return int(int32(uint32(responseID)))
416+}
417+
418+func dispatchFileDialogResult(wv *WebView, callback purecef.FileDialogCallback, paths []string) {
419+ if callback == nil {
420+ return
421+ }
422+ if wv == nil || wv.engine == nil {
423+ return
424+ }
425+
426+ task := cefNewTask(cefTaskFunc(func() {
427+ logger := logCEFFileDialog(wv)
428+ logger.Debug().
429+ Int("path_count", len(paths)).
430+ Strs("paths", paths).
431+ Int32("currently_on_ui", purecef.CurrentlyOn(purecef.ThreadIDTidUi)).
432+ Msg("cef: file dialog continuation task executing")
433+ continueCEFFileDialog(logger, callback, paths...)
434+ logger.Debug().
435+ Int("path_count", len(paths)).
436+ Strs("paths", paths).
437+ Msg("cef: file dialog continuation task finished")
438+ }))
439+ if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
440+ logCEFFileDialog(wv).Warn().Int32("post_result", result).Msg("cef: failed to post file dialog continuation to CEF UI thread")
441+ } else {
442+ logCEFFileDialog(wv).Debug().Int("path_count", len(paths)).Strs("paths", paths).Msg("cef: file dialog continuation task posted")
443+ }
444+}
445+
446+func continueCEFFileDialogWithLogs(logger *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) {
447+ logger = nonNilCEFFileDialogLogger(logger)
448+ callbackPtr := rawCEFPointer(callback)
449+ if callback == nil {
450+ logger.Debug().Msg("cef: file dialog callback continuation skipped; callback is nil")
451+ return
452+ }
453+
454+ if len(filePaths) == 0 {
455+ logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: invoking file dialog callback cancel")
456+ callback.Cancel()
457+ runtime.KeepAlive(callback)
458+ logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned")
459+ return
460+ }
461+
462+ logger.Debug().
463+ Uint64("callback_ptr", uint64(callbackPtr)).
464+ Int("path_count", len(filePaths)).
465+ Strs("paths", filePaths).
466+ Msg("cef: allocating file dialog CEF string list")
467+ list := newCEFStringList(filePaths...)
468+ if list == 0 {
469+ logger.Warn().
470+ Uint64("callback_ptr", uint64(callbackPtr)).
471+ Int("path_count", len(filePaths)).
472+ Msg("cef: file dialog CEF string list allocation failed; canceling")
473+ callback.Cancel()
474+ runtime.KeepAlive(callback)
475+ logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned after allocation failure")
476+ return
477+ }
478+
479+ listValue := uint64(list)
480+ logger.Debug().
481+ Uint64("callback_ptr", uint64(callbackPtr)).
482+ Uint64("string_list", listValue).
483+ Int("path_count", len(filePaths)).
484+ Msg("cef: file dialog CEF string list allocated")
485+ logger.Debug().
486+ Uint64("callback_ptr", uint64(callbackPtr)).
487+ Uint64("string_list", listValue).
488+ Int("path_count", len(filePaths)).
489+ Strs("paths", filePaths).
490+ Msg("cef: invoking file dialog callback continuation")
491+ callback.Cont(list)
492+ logger.Debug().
493+ Uint64("callback_ptr", uint64(callbackPtr)).
494+ Uint64("string_list", listValue).
495+ Int("path_count", len(filePaths)).
496+ Msg("cef: file dialog callback continuation returned")
497+ logger.Debug().Uint64("string_list", listValue).Msg("cef: freeing file dialog CEF string list")
498+ freeCEFStringList(list)
499+ logger.Debug().Uint64("string_list", listValue).Msg("cef: freed file dialog CEF string list")
500+ runtime.KeepAlive(callback)
501+}
502+
503+func nonNilCEFFileDialogLogger(logger *zerolog.Logger) *zerolog.Logger {
504+ if logger != nil {
505+ return logger
506+ }
507+ return logging.FromContext(context.Background())
508+}
509+
510+type cefRawPointerHolder interface {
511+ RawPointer() unsafe.Pointer
512+}
513+
514+func rawCEFPointer(v any) uintptr {
515+ if v == nil {
516+ return 0
517+ }
518+ holder, ok := v.(cefRawPointerHolder)
519+ if !ok {
520+ return 0
521+ }
522+ return uintptr(holder.RawPointer())
523+}
524+
525+func logCEFFileDialog(wv *WebView) *zerolog.Logger {
526+ if wv == nil || wv.ctx == nil {
527+ return logging.FromContext(context.Background())
528+ }
529+ return logging.FromContext(wv.ctx)
530+}
A internal/infrastructure/cef/file_dialog_test.go +131 -0
@@ -0,0 +1,131 @@
1+package cef
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ purecef "github.com/bnema/purego-cef/cef"
10+ "github.com/rs/zerolog"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type stubFileDialogCallback struct {
15+ list purecef.StringList
16+ paths []string
17+ canceled bool
18+}
19+
20+func (s *stubFileDialogCallback) Cont(filePaths purecef.StringList) {
21+ s.list = filePaths
22+ s.paths = decodeCEFStringList(filePaths)
23+}
24+
25+func (s *stubFileDialogCallback) Cancel() {
26+ s.canceled = true
27+}
28+
29+func TestGetDialogHandlerEnabled(t *testing.T) {
30+ h := &handlerSet{}
31+ require.Same(t, h, h.GetDialogHandler())
32+}
33+
34+func TestOnFileDialogRejectsNilWebViewOrCallback(t *testing.T) {
35+ require.Zero(t, (*handlerSet)(nil).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{}))
36+ require.Zero(t, (&handlerSet{}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{}))
37+ require.Zero(t, (&handlerSet{wv: &WebView{ctx: context.Background()}}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, nil))
38+}
39+
40+func TestNormalizeGTKResponseID(t *testing.T) {
41+ require.Equal(t, -3, normalizeGTKResponseID(4294967293))
42+ require.Equal(t, -6, normalizeGTKResponseID(4294967290))
43+ require.Equal(t, 0, normalizeGTKResponseID(0))
44+}
45+
46+func TestOnFileDialogDelegatesDecodedRequestToPresenter(t *testing.T) {
47+ prevDecode := decodeCEFStringList
48+ decodeCEFStringList = func(list purecef.StringList) []string {
49+ switch list {
50+ case purecef.StringList(11):
51+ return []string{"image/*", ".txt"}
52+ case purecef.StringList(12):
53+ return []string{".png;.jpg", ".txt"}
54+ case purecef.StringList(13):
55+ return []string{"Image Files", "Text Files"}
56+ default:
57+ return nil
58+ }
59+ }
60+ defer func() { decodeCEFStringList = prevDecode }()
61+
62+ var gotReq cefFileDialogRequest
63+ var gotCalls int
64+ h := &handlerSet{
65+ wv: &WebView{ctx: context.Background()},
66+ fileDialogPresenter: func(_ *WebView, req cefFileDialogRequest, _ purecef.FileDialogCallback) {
67+ gotReq = req
68+ gotCalls++
69+ },
70+ }
71+
72+ handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogOpenMultiple, "Pick files", "/tmp/start.txt", purecef.StringList(11), purecef.StringList(12), purecef.StringList(13), &stubFileDialogCallback{})
73+
74+ require.Equal(t, int32(1), handled)
75+ require.Equal(t, 1, gotCalls)
76+ require.Equal(t, purecef.FileDialogModeFileDialogOpenMultiple, gotReq.Mode)
77+ require.Equal(t, "Pick files", gotReq.Title)
78+ require.Equal(t, "/tmp/start.txt", gotReq.DefaultFilePath)
79+ require.Equal(t, []string{"image/*", ".txt"}, gotReq.AcceptFilters)
80+ require.Equal(t, []string{".png;.jpg", ".txt"}, gotReq.AcceptExtensions)
81+ require.Equal(t, []string{"Image Files", "Text Files"}, gotReq.AcceptDescriptions)
82+}
83+
84+func TestFolderUploadExperimentPathsExpandsDirectoryFiles(t *testing.T) {
85+ dir := t.TempDir()
86+ first := filepath.Join(dir, "first.txt")
87+ second := filepath.Join(dir, "nested", "second.txt")
88+ require.NoError(t, os.MkdirAll(filepath.Dir(second), 0o755))
89+ require.NoError(t, os.WriteFile(first, []byte("first"), 0o644))
90+ require.NoError(t, os.WriteFile(second, []byte("second"), 0o644))
91+
92+ got := folderUploadExperimentPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir})
93+
94+ require.ElementsMatch(t, []string{first, second}, got)
95+}
96+
97+func TestFolderUploadExperimentPathsKeepsEmptyDirectoryPath(t *testing.T) {
98+ dir := t.TempDir()
99+
100+ got := folderUploadExperimentPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir})
101+
102+ require.Equal(t, []string{dir}, got)
103+}
104+
105+func TestOnFileDialogPresenterCanContinueCallback(t *testing.T) {
106+ prevDecode := decodeCEFStringList
107+ decodeCEFStringList = func(purecef.StringList) []string { return nil }
108+ defer func() { decodeCEFStringList = prevDecode }()
109+
110+ prevContinue := continueCEFFileDialog
111+ continueCEFFileDialog = func(_ *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) {
112+ recorder, ok := callback.(*stubFileDialogCallback)
113+ require.True(t, ok)
114+ recorder.paths = append([]string(nil), filePaths...)
115+ }
116+ defer func() { continueCEFFileDialog = prevContinue }()
117+
118+ callback := &stubFileDialogCallback{}
119+ h := &handlerSet{
120+ wv: &WebView{ctx: context.Background()},
121+ fileDialogPresenter: func(_ *WebView, _ cefFileDialogRequest, cb purecef.FileDialogCallback) {
122+ continueCEFFileDialog(nil, cb, "/tmp/report.txt")
123+ },
124+ }
125+
126+ handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogSave, "", "", 0, 0, 0, callback)
127+
128+ require.Equal(t, int32(1), handled)
129+ require.False(t, callback.canceled)
130+ require.Equal(t, []string{"/tmp/report.txt"}, callback.paths)
131+}
@@ -0,0 +1,131 @@
1+package cef
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ purecef "github.com/bnema/purego-cef/cef"
10+ "github.com/rs/zerolog"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+type stubFileDialogCallback struct {
15+ list purecef.StringList
16+ paths []string
17+ canceled bool
18+}
19+
20+func (s *stubFileDialogCallback) Cont(filePaths purecef.StringList) {
21+ s.list = filePaths
22+ s.paths = decodeCEFStringList(filePaths)
23+}
24+
25+func (s *stubFileDialogCallback) Cancel() {
26+ s.canceled = true
27+}
28+
29+func TestGetDialogHandlerEnabled(t *testing.T) {
30+ h := &handlerSet{}
31+ require.Same(t, h, h.GetDialogHandler())
32+}
33+
34+func TestOnFileDialogRejectsNilWebViewOrCallback(t *testing.T) {
35+ require.Zero(t, (*handlerSet)(nil).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{}))
36+ require.Zero(t, (&handlerSet{}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{}))
37+ require.Zero(t, (&handlerSet{wv: &WebView{ctx: context.Background()}}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, nil))
38+}
39+
40+func TestNormalizeGTKResponseID(t *testing.T) {
41+ require.Equal(t, -3, normalizeGTKResponseID(4294967293))
42+ require.Equal(t, -6, normalizeGTKResponseID(4294967290))
43+ require.Equal(t, 0, normalizeGTKResponseID(0))
44+}
45+
46+func TestOnFileDialogDelegatesDecodedRequestToPresenter(t *testing.T) {
47+ prevDecode := decodeCEFStringList
48+ decodeCEFStringList = func(list purecef.StringList) []string {
49+ switch list {
50+ case purecef.StringList(11):
51+ return []string{"image/*", ".txt"}
52+ case purecef.StringList(12):
53+ return []string{".png;.jpg", ".txt"}
54+ case purecef.StringList(13):
55+ return []string{"Image Files", "Text Files"}
56+ default:
57+ return nil
58+ }
59+ }
60+ defer func() { decodeCEFStringList = prevDecode }()
61+
62+ var gotReq cefFileDialogRequest
63+ var gotCalls int
64+ h := &handlerSet{
65+ wv: &WebView{ctx: context.Background()},
66+ fileDialogPresenter: func(_ *WebView, req cefFileDialogRequest, _ purecef.FileDialogCallback) {
67+ gotReq = req
68+ gotCalls++
69+ },
70+ }
71+
72+ handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogOpenMultiple, "Pick files", "/tmp/start.txt", purecef.StringList(11), purecef.StringList(12), purecef.StringList(13), &stubFileDialogCallback{})
73+
74+ require.Equal(t, int32(1), handled)
75+ require.Equal(t, 1, gotCalls)
76+ require.Equal(t, purecef.FileDialogModeFileDialogOpenMultiple, gotReq.Mode)
77+ require.Equal(t, "Pick files", gotReq.Title)
78+ require.Equal(t, "/tmp/start.txt", gotReq.DefaultFilePath)
79+ require.Equal(t, []string{"image/*", ".txt"}, gotReq.AcceptFilters)
80+ require.Equal(t, []string{".png;.jpg", ".txt"}, gotReq.AcceptExtensions)
81+ require.Equal(t, []string{"Image Files", "Text Files"}, gotReq.AcceptDescriptions)
82+}
83+
84+func TestFolderUploadExperimentPathsExpandsDirectoryFiles(t *testing.T) {
85+ dir := t.TempDir()
86+ first := filepath.Join(dir, "first.txt")
87+ second := filepath.Join(dir, "nested", "second.txt")
88+ require.NoError(t, os.MkdirAll(filepath.Dir(second), 0o755))
89+ require.NoError(t, os.WriteFile(first, []byte("first"), 0o644))
90+ require.NoError(t, os.WriteFile(second, []byte("second"), 0o644))
91+
92+ got := folderUploadExperimentPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir})
93+
94+ require.ElementsMatch(t, []string{first, second}, got)
95+}
96+
97+func TestFolderUploadExperimentPathsKeepsEmptyDirectoryPath(t *testing.T) {
98+ dir := t.TempDir()
99+
100+ got := folderUploadExperimentPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir})
101+
102+ require.Equal(t, []string{dir}, got)
103+}
104+
105+func TestOnFileDialogPresenterCanContinueCallback(t *testing.T) {
106+ prevDecode := decodeCEFStringList
107+ decodeCEFStringList = func(purecef.StringList) []string { return nil }
108+ defer func() { decodeCEFStringList = prevDecode }()
109+
110+ prevContinue := continueCEFFileDialog
111+ continueCEFFileDialog = func(_ *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) {
112+ recorder, ok := callback.(*stubFileDialogCallback)
113+ require.True(t, ok)
114+ recorder.paths = append([]string(nil), filePaths...)
115+ }
116+ defer func() { continueCEFFileDialog = prevContinue }()
117+
118+ callback := &stubFileDialogCallback{}
119+ h := &handlerSet{
120+ wv: &WebView{ctx: context.Background()},
121+ fileDialogPresenter: func(_ *WebView, _ cefFileDialogRequest, cb purecef.FileDialogCallback) {
122+ continueCEFFileDialog(nil, cb, "/tmp/report.txt")
123+ },
124+ }
125+
126+ handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogSave, "", "", 0, 0, 0, callback)
127+
128+ require.Equal(t, int32(1), handled)
129+ require.False(t, callback.canceled)
130+ require.Equal(t, []string{"/tmp/report.txt"}, callback.paths)
131+}
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