detached 47ccc89

Merge pull request #110 from bnema/fix/v0.24.0-patches

fix: v0.24.0 patch fixes
commit
47ccc8970c5e06c975b98e4dedab686e7fcd492f
author
bnema · · 2026-01-06 19:18 UTC
committer
GitHub · · 2026-01-06 19:18 UTC
merge of
a0d51a2 + be07ff2 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
25 files · +386 -742
M .github/workflows/aur.yml +1 -1
@@ -88,7 +88,7 @@ jobs:
8888 'package() {' \
8989 ' cd dumber' \
9090 ' install -Dm755 dist/dumber "${pkgdir}/usr/bin/dumber"' \
91- ' install -Dm644 dev.bnema.Dumber.desktop "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"' \
91+ ' install -Dm644 flatpak/dev.bnema.Dumber.desktop "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"' \
9292 ' install -Dm644 assets/logo-512.png "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png"' \
9393 ' install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"' \
9494 '}' \
@@ -88,7 +88,7 @@ jobs:
88 'package() {' \88 'package() {' \
89 ' cd dumber' \89 ' cd dumber' \
90 ' install -Dm755 dist/dumber "${pkgdir}/usr/bin/dumber"' \90 ' install -Dm755 dist/dumber "${pkgdir}/usr/bin/dumber"' \
91- ' install -Dm644 dev.bnema.Dumber.desktop "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"' \91+ ' install -Dm644 flatpak/dev.bnema.Dumber.desktop "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"' \
92 ' install -Dm644 assets/logo-512.png "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png"' \92 ' install -Dm644 assets/logo-512.png "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png"' \
93 ' install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"' \93 ' install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"' \
94 '}' \94 '}' \
M .github/workflows/flatpak.yml +4 -2
@@ -94,7 +94,8 @@ jobs:
9494 url: https://github.com/bnema/dumber/releases/download/${VERSION}/dumber_linux_x86_64.tar.gz
9595 sha256: ${SHA256}
9696 - type: file
97- path: dev.bnema.Dumber.desktop
97+ path: flatpak/dev.bnema.Dumber.desktop
98+ dest-filename: dev.bnema.Dumber.desktop
9899 - type: file
99100 path: assets/logo.svg
100101 dest-filename: logo.svg
@@ -102,7 +103,8 @@ jobs:
102103 path: assets/logo-512.png
103104 dest-filename: logo-512.png
104105 - type: file
105- path: dev.bnema.Dumber.metainfo.xml
106+ path: flatpak/dev.bnema.Dumber.metainfo.xml
107+ dest-filename: dev.bnema.Dumber.metainfo.xml
106108 EOF
107109 echo "Generated manifest:"
108110 cat dev.bnema.Dumber.yml
@@ -94,7 +94,8 @@ jobs:
94 url: https://github.com/bnema/dumber/releases/download/${VERSION}/dumber_linux_x86_64.tar.gz94 url: https://github.com/bnema/dumber/releases/download/${VERSION}/dumber_linux_x86_64.tar.gz
95 sha256: ${SHA256}95 sha256: ${SHA256}
96 - type: file96 - type: file
97- path: dev.bnema.Dumber.desktop97+ path: flatpak/dev.bnema.Dumber.desktop
98+ dest-filename: dev.bnema.Dumber.desktop
98 - type: file99 - type: file
99 path: assets/logo.svg100 path: assets/logo.svg
100 dest-filename: logo.svg101 dest-filename: logo.svg
@@ -102,7 +103,8 @@ jobs:
102 path: assets/logo-512.png103 path: assets/logo-512.png
103 dest-filename: logo-512.png104 dest-filename: logo-512.png
104 - type: file105 - type: file
105- path: dev.bnema.Dumber.metainfo.xml106+ path: flatpak/dev.bnema.Dumber.metainfo.xml
107+ dest-filename: dev.bnema.Dumber.metainfo.xml
106 EOF108 EOF
107 echo "Generated manifest:"109 echo "Generated manifest:"
108 cat dev.bnema.Dumber.yml110 cat dev.bnema.Dumber.yml
M .goreleaser.yaml +3 -1
@@ -45,7 +45,9 @@ archives:
4545 - LICENSE
4646 - README.md
4747 - CHANGELOG.md
48- - dev.bnema.Dumber.desktop
48+ - src: flatpak/dev.bnema.Dumber.desktop
49+ dst: .
50+ strip_parent: true
4951 - src: assets/logo-512.png
5052 dst: .
5153 strip_parent: true
@@ -45,7 +45,9 @@ archives:
45 - LICENSE45 - LICENSE
46 - README.md46 - README.md
47 - CHANGELOG.md47 - CHANGELOG.md
48- - dev.bnema.Dumber.desktop48+ - src: flatpak/dev.bnema.Dumber.desktop
49+ dst: .
50+ strip_parent: true
49 - src: assets/logo-512.png51 - src: assets/logo-512.png
50 dst: .52 dst: .
51 strip_parent: true53 strip_parent: true
M CHANGELOG.md +17 -0
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+### Added
8+- **Print shortcut**: `Ctrl+Shift+P` opens the print dialog for the current page. Fixes #99.
9+
10+### Changed
11+- **Flatpak files location**: Moved flatpak-related files to `flatpak/` directory. Fixes #101.
12+- **Navigation shortcuts**: `Ctrl+Left/Right` now triggers back/forward navigation, `Alt+Left/Right` moves focus between panes. Fixes #98.
13+
14+### Fixed
15+- **Flatpak favicons**: Fixed favicons not displaying in omnibox for Flatpak installs by ensuring cache directory exists before saving. Fixes #105.
16+- **Flatpak desktop launcher**: Fixed .desktop file not launching application from app launchers by adding locale environment variables. Fixes #104.
17+- **Flatpak update notifications**: Skip GitHub update checks for Flatpak and AUR installs (use package manager instead). Fixes #107.
18+- **Active border with stacked panes**: Hide active pane border when only one pane area is visible (stacked panes count as one). Fixes #108.
19+- **Setup command for AUR**: `dumber setup` now detects AUR and Flatpak installations and locates the correct .desktop file. Fixes #102.
20+
21+### Removed
22+- **dev/ directory**: Removed development scripts directory. Fixes #103.
23+
724 ## [0.24.0] - 2026-01-06
825
926 ### Added
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
7+### Added
8+- **Print shortcut**: `Ctrl+Shift+P` opens the print dialog for the current page. Fixes #99.
9+
10+### Changed
11+- **Flatpak files location**: Moved flatpak-related files to `flatpak/` directory. Fixes #101.
12+- **Navigation shortcuts**: `Ctrl+Left/Right` now triggers back/forward navigation, `Alt+Left/Right` moves focus between panes. Fixes #98.
13+
14+### Fixed
15+- **Flatpak favicons**: Fixed favicons not displaying in omnibox for Flatpak installs by ensuring cache directory exists before saving. Fixes #105.
16+- **Flatpak desktop launcher**: Fixed .desktop file not launching application from app launchers by adding locale environment variables. Fixes #104.
17+- **Flatpak update notifications**: Skip GitHub update checks for Flatpak and AUR installs (use package manager instead). Fixes #107.
18+- **Active border with stacked panes**: Hide active pane border when only one pane area is visible (stacked panes count as one). Fixes #108.
19+- **Setup command for AUR**: `dumber setup` now detects AUR and Flatpak installations and locates the correct .desktop file. Fixes #102.
20+
21+### Removed
22+- **dev/ directory**: Removed development scripts directory. Fixes #103.
23+
7 ## [0.24.0] - 2026-01-0624 ## [0.24.0] - 2026-01-06
8 25
9 ### Added26 ### Added
M aur/dumber-browser-git/PKGBUILD +1 -1
@@ -43,7 +43,7 @@ build() {
4343 package() {
4444 cd dumber
4545 install -Dm755 dist/dumber "${pkgdir}/usr/bin/dumber"
46- install -Dm644 dev.bnema.Dumber.desktop "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"
46+ install -Dm644 flatpak/dev.bnema.Dumber.desktop "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"
4747 install -Dm644 assets/logo-512.png "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png"
4848 install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
4949 }
@@ -43,7 +43,7 @@ build() {
43 package() {43 package() {
44 cd dumber44 cd dumber
45 install -Dm755 dist/dumber "${pkgdir}/usr/bin/dumber"45 install -Dm755 dist/dumber "${pkgdir}/usr/bin/dumber"
46- install -Dm644 dev.bnema.Dumber.desktop "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"46+ install -Dm644 flatpak/dev.bnema.Dumber.desktop "${pkgdir}/usr/share/applications/dev.bnema.Dumber.desktop"
47 install -Dm644 assets/logo-512.png "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png"47 install -Dm644 assets/logo-512.png "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png"
48 install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"48 install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
49 }49 }
D dev/check-docs.go +0 -658
@@ -1,658 +0,0 @@
1-//go:build devtools
2-// +build devtools
3-
4-package main
5-
6-import (
7- "bufio"
8- "bytes"
9- "errors"
10- "flag"
11- "fmt"
12- "go/ast"
13- "go/parser"
14- "go/token"
15- "io"
16- "os"
17- "path/filepath"
18- "regexp"
19- "sort"
20- "strings"
21- "time"
22-)
23-
24-type issueLevel string
25-
26-const (
27- levelError issueLevel = "ERROR"
28- levelWarning issueLevel = "WARNING"
29- levelInfo issueLevel = "INFO"
30-)
31-
32-type Issue struct {
33- Level issueLevel
34- File string
35- Line int
36- Message string
37- Detail string
38-}
39-
40-type Report struct {
41- GeneratedAt time.Time
42- Issues []Issue
43-}
44-
45-func (r *Report) Add(level issueLevel, file string, line int, message, detail string) {
46- r.Issues = append(r.Issues, Issue{Level: level, File: file, Line: line, Message: message, Detail: detail})
47-}
48-
49-func (r *Report) Count(level issueLevel) int {
50- count := 0
51- for _, it := range r.Issues {
52- if it.Level == level {
53- count++
54- }
55- }
56- return count
57-}
58-
59-func main() {
60- failOnError := flag.Bool("fail-on-error", false, "exit non-zero if errors found")
61- out := flag.String("out", "dev/docs-report.txt", "write report to file (relative to repo root unless absolute)")
62- flag.Parse()
63-
64- repoRoot, err := findRepoRoot()
65- if err != nil {
66- fmt.Fprintln(os.Stderr, err)
67- os.Exit(2)
68- }
69-
70- report := &Report{GeneratedAt: time.Now()}
71-
72- schemaKeys, err := extractConfigKeys(filepath.Join(repoRoot, "internal", "infrastructure", "config", "schema.go"))
73- if err != nil {
74- fmt.Fprintln(os.Stderr, "failed to parse config schema:", err)
75- os.Exit(2)
76- }
77-
78- actionConstByName, err := extractActions(filepath.Join(repoRoot, "internal", "ui", "input", "shortcuts.go"))
79- if err != nil {
80- fmt.Fprintln(os.Stderr, "failed to parse shortcuts:", err)
81- os.Exit(2)
82- }
83-
84- stubActionConstNames, err := extractStubbedActionConstNames(filepath.Join(repoRoot, "internal", "ui", "dispatcher", "keyboard.go"))
85- if err != nil {
86- fmt.Fprintln(os.Stderr, "failed to parse keyboard dispatcher:", err)
87- os.Exit(2)
88- }
89- stubActionValues := make(map[string]struct{}, len(stubActionConstNames))
90- for _, name := range stubActionConstNames {
91- if val, ok := actionConstByName[name]; ok {
92- stubActionValues[val] = struct{}{}
93- }
94- }
95-
96- cliUses, err := extractCobraUseStrings(filepath.Join(repoRoot, "internal", "cli", "cmd"))
97- if err != nil {
98- fmt.Fprintln(os.Stderr, "failed to parse cli commands:", err)
99- os.Exit(2)
100- }
101-
102- readmePath := filepath.Join(repoRoot, "README.md")
103- readmeLines, err := readLines(readmePath)
104- if err != nil {
105- fmt.Fprintln(os.Stderr, "failed to read README:", err)
106- os.Exit(2)
107- }
108- configDocPath := filepath.Join(repoRoot, "docs", "CONFIG.md")
109- configDocLines, err := readLines(configDocPath)
110- if err != nil {
111- fmt.Fprintln(os.Stderr, "failed to read docs/CONFIG.md:", err)
112- os.Exit(2)
113- }
114-
115- verifyConfigDoc(report, schemaKeys, configDocPath, configDocLines)
116- verifyReadmeShortcuts(report, stubActionValues, readmePath, readmeLines)
117- verifyReadmeCommands(report, cliUses, readmePath, readmeLines)
118- verifyReadmeBuildClaims(report, readmePath, readmeLines)
119-
120- reportPath := *out
121- if !filepath.IsAbs(reportPath) {
122- reportPath = filepath.Join(repoRoot, reportPath)
123- }
124- if err := writeReport(reportPath, report); err != nil {
125- fmt.Fprintln(os.Stderr, "failed to write report:", err)
126- os.Exit(2)
127- }
128-
129- printSummary(report, reportPath)
130-
131- if *failOnError && report.Count(levelError) > 0 {
132- os.Exit(1)
133- }
134-}
135-
136-func findRepoRoot() (string, error) {
137- wd, err := os.Getwd()
138- if err != nil {
139- return "", err
140- }
141- dir := wd
142- for {
143- gitDir := filepath.Join(dir, ".git")
144- if st, err := os.Stat(gitDir); err == nil && st.IsDir() {
145- return dir, nil
146- }
147- parent := filepath.Dir(dir)
148- if parent == dir {
149- return "", errors.New("could not find repo root (.git)")
150- }
151- dir = parent
152- }
153-}
154-
155-func readLines(path string) ([]string, error) {
156- f, err := os.Open(path)
157- if err != nil {
158- return nil, err
159- }
160- defer f.Close()
161-
162- var lines []string
163- s := bufio.NewScanner(f)
164- for s.Scan() {
165- lines = append(lines, s.Text())
166- }
167- if err := s.Err(); err != nil {
168- return nil, err
169- }
170- return lines, nil
171-}
172-
173-type schemaField struct {
174- Tag string
175- TypeName string
176-}
177-
178-func extractConfigKeys(schemaPath string) (map[string]struct{}, error) {
179- fset := token.NewFileSet()
180- file, err := parser.ParseFile(fset, schemaPath, nil, parser.ParseComments)
181- if err != nil {
182- return nil, err
183- }
184-
185- typeFields := map[string][]schemaField{}
186- for _, decl := range file.Decls {
187- gen, ok := decl.(*ast.GenDecl)
188- if !ok || gen.Tok != token.TYPE {
189- continue
190- }
191- for _, spec := range gen.Specs {
192- ts, ok := spec.(*ast.TypeSpec)
193- if !ok {
194- continue
195- }
196- st, ok := ts.Type.(*ast.StructType)
197- if !ok {
198- continue
199- }
200- var fields []schemaField
201- for _, f := range st.Fields.List {
202- if len(f.Names) == 0 {
203- continue
204- }
205- tag := extractStructTagValue(f.Tag, "mapstructure")
206- if tag == "" || tag == "-" {
207- continue
208- }
209- fields = append(fields, schemaField{Tag: tag, TypeName: typeExprName(f.Type)})
210- }
211- typeFields[ts.Name.Name] = fields
212- }
213- }
214-
215- keys := make(map[string]struct{})
216- seen := map[string]bool{}
217- var walkType func(typeName, prefix string)
218- walkType = func(typeName, prefix string) {
219- if seen[prefix+"::"+typeName] {
220- return
221- }
222- seen[prefix+"::"+typeName] = true
223-
224- fields, ok := typeFields[typeName]
225- if !ok {
226- return
227- }
228- for _, f := range fields {
229- full := f.Tag
230- if prefix != "" {
231- full = prefix + "." + f.Tag
232- }
233- keys[full] = struct{}{}
234- if _, ok := typeFields[f.TypeName]; ok {
235- walkType(f.TypeName, full)
236- }
237- }
238- }
239-
240- walkType("Config", "")
241- return keys, nil
242-}
243-
244-func extractStructTagValue(tagLit *ast.BasicLit, key string) string {
245- if tagLit == nil {
246- return ""
247- }
248- tag := strings.Trim(tagLit.Value, "`")
249- parts := strings.Split(tag, " ")
250- for _, part := range parts {
251- kv := strings.SplitN(part, ":", 2)
252- if len(kv) != 2 {
253- continue
254- }
255- k := kv[0]
256- v := strings.Trim(kv[1], "\"")
257- if k != key {
258- continue
259- }
260- if idx := strings.IndexByte(v, ','); idx >= 0 {
261- return v[:idx]
262- }
263- return v
264- }
265- return ""
266-}
267-
268-func typeExprName(expr ast.Expr) string {
269- switch t := expr.(type) {
270- case *ast.Ident:
271- return t.Name
272- case *ast.SelectorExpr:
273- // pkg.Type
274- return t.Sel.Name
275- case *ast.StarExpr:
276- return typeExprName(t.X)
277- default:
278- return ""
279- }
280-}
281-
282-func extractActions(shortcutsPath string) (map[string]string, error) {
283- fset := token.NewFileSet()
284- file, err := parser.ParseFile(fset, shortcutsPath, nil, 0)
285- if err != nil {
286- return nil, err
287- }
288-
289- out := map[string]string{}
290- for _, decl := range file.Decls {
291- gen, ok := decl.(*ast.GenDecl)
292- if !ok || gen.Tok != token.CONST {
293- continue
294- }
295- for _, spec := range gen.Specs {
296- vs, ok := spec.(*ast.ValueSpec)
297- if !ok {
298- continue
299- }
300- for i, name := range vs.Names {
301- if i >= len(vs.Values) {
302- continue
303- }
304- bl, ok := vs.Values[i].(*ast.BasicLit)
305- if !ok || bl.Kind != token.STRING {
306- continue
307- }
308- if strings.HasPrefix(name.Name, "Action") {
309- out[name.Name] = strings.Trim(bl.Value, "\"")
310- }
311- }
312- }
313- }
314- return out, nil
315-}
316-
317-func extractStubbedActionConstNames(keyboardDispatcherPath string) ([]string, error) {
318- fset := token.NewFileSet()
319- file, err := parser.ParseFile(fset, keyboardDispatcherPath, nil, 0)
320- if err != nil {
321- return nil, err
322- }
323-
324- stubbedSet := map[string]struct{}{}
325-
326- ast.Inspect(file, func(n ast.Node) bool {
327- cl, ok := n.(*ast.CompositeLit)
328- if !ok {
329- return true
330- }
331- if _, ok := cl.Type.(*ast.MapType); !ok {
332- return true
333- }
334- for _, elt := range cl.Elts {
335- kv, ok := elt.(*ast.KeyValueExpr)
336- if !ok {
337- continue
338- }
339- keyName := selectorName(kv.Key)
340- if keyName == "" {
341- continue
342- }
343- fn, ok := kv.Value.(*ast.FuncLit)
344- if !ok {
345- continue
346- }
347- if funcCallsSelector(fn, "logNoop") {
348- stubbedSet[keyName] = struct{}{}
349- }
350- }
351- return true
352- })
353-
354- var stubbed []string
355- for k := range stubbedSet {
356- stubbed = append(stubbed, k)
357- }
358- sort.Strings(stubbed)
359- return stubbed, nil
360-}
361-
362-func selectorName(expr ast.Expr) string {
363- switch t := expr.(type) {
364- case *ast.SelectorExpr:
365- return t.Sel.Name
366- case *ast.Ident:
367- return t.Name
368- default:
369- return ""
370- }
371-}
372-
373-func funcCallsSelector(fn *ast.FuncLit, selName string) bool {
374- if fn == nil || fn.Body == nil {
375- return false
376- }
377- found := false
378- ast.Inspect(fn.Body, func(n ast.Node) bool {
379- call, ok := n.(*ast.CallExpr)
380- if !ok {
381- return true
382- }
383- sel, ok := call.Fun.(*ast.SelectorExpr)
384- if !ok {
385- return true
386- }
387- if sel.Sel.Name == selName {
388- found = true
389- return false
390- }
391- return true
392- })
393- return found
394-}
395-
396-func extractCobraUseStrings(cmdDir string) (map[string]struct{}, error) {
397- entries, err := os.ReadDir(cmdDir)
398- if err != nil {
399- return nil, err
400- }
401-
402- uses := map[string]struct{}{}
403- useRe := regexp.MustCompile(`\bUse:\s*"([^"]+)"`)
404-
405- for _, e := range entries {
406- if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
407- continue
408- }
409- b, err := os.ReadFile(filepath.Join(cmdDir, e.Name()))
410- if err != nil {
411- return nil, err
412- }
413- matches := useRe.FindAllSubmatch(b, -1)
414- for _, m := range matches {
415- use := string(m[1])
416- fields := strings.Fields(use)
417- if len(fields) == 0 {
418- continue
419- }
420- uses[fields[0]] = struct{}{}
421- }
422- }
423-
424- return uses, nil
425-}
426-
427-func verifyConfigDoc(report *Report, schemaKeys map[string]struct{}, configDocPath string, lines []string) {
428- docKeys := extractDocKeys(lines, schemaKeys)
429- for key, line := range docKeys {
430- if _, ok := schemaKeys[key]; ok {
431- continue
432- }
433- report.Add(levelWarning, rel(configDocPath), line, "config key documented but not present in schema", fmt.Sprintf("key: %s", key))
434- }
435-}
436-
437-func extractDocKeys(lines []string, schemaKeys map[string]struct{}) map[string]int {
438- keyRe := regexp.MustCompile("`([a-zA-Z0-9_.-]+)`")
439- tableRowRe := regexp.MustCompile(`^\|`)
440- inCode := false
441-
442- seen := map[string]int{}
443- for i, line := range lines {
444- trim := strings.TrimSpace(line)
445- if strings.HasPrefix(trim, "```") {
446- inCode = !inCode
447- continue
448- }
449- if inCode {
450- continue
451- }
452-
453- // Prefer extracting keys from the first column of Markdown tables.
454- // This avoids false-positives from enum values shown in other columns.
455- if tableRowRe.MatchString(trim) {
456- // Skip table separator rows.
457- if strings.Contains(trim, "---") {
458- continue
459- }
460- parts := strings.Split(line, "|")
461- if len(parts) >= 3 {
462- firstCell := strings.TrimSpace(parts[1])
463- matches := keyRe.FindAllStringSubmatch(firstCell, -1)
464- for _, m := range matches {
465- k := m[1]
466- if k == "" || k[0] < 'a' || k[0] > 'z' {
467- continue
468- }
469- if strings.ContainsAny(k, " /") {
470- continue
471- }
472- if _, already := seen[k]; !already {
473- seen[k] = i + 1
474- }
475- }
476- }
477- continue
478- }
479-
480- // Outside of tables, only accept dotted paths that look like config keys.
481- matches := keyRe.FindAllStringSubmatch(line, -1)
482- for _, m := range matches {
483- k := m[1]
484- if _, ok := schemaKeys[k]; ok {
485- if _, already := seen[k]; !already {
486- seen[k] = i + 1
487- }
488- continue
489- }
490- if !strings.Contains(k, ".") {
491- continue
492- }
493- if k == "" || k[0] < 'a' || k[0] > 'z' {
494- continue
495- }
496- if strings.ContainsAny(k, " /") {
497- continue
498- }
499- if _, already := seen[k]; !already {
500- seen[k] = i + 1
501- }
502- }
503- }
504- return seen
505-}
506-
507-func verifyReadmeShortcuts(report *Report, stubActionValues map[string]struct{}, readmePath string, lines []string) {
508- stubToDocPatterns := map[string][]string{
509- "rename_tab": {"Rename Tab"},
510- "toggle_fullscreen": {"Toggle Fullscreen"},
511- }
512- for action, patterns := range stubToDocPatterns {
513- if _, stubbed := stubActionValues[action]; !stubbed {
514- continue
515- }
516- for i, line := range lines {
517- for _, p := range patterns {
518- if strings.Contains(line, p) {
519- report.Add(levelError, rel(readmePath), i+1, "documented shortcut/feature appears stubbed in code", fmt.Sprintf("action: %s", action))
520- goto nextAction
521- }
522- }
523- }
524- nextAction:
525- }
526-}
527-
528-func verifyReadmeCommands(report *Report, cliUses map[string]struct{}, readmePath string, lines []string) {
529- cmdRe := regexp.MustCompile("`dumber\\s+([^`]+)`")
530-
531- for i, line := range lines {
532- matches := cmdRe.FindAllStringSubmatch(line, -1)
533- for _, m := range matches {
534- cmdLine := strings.TrimSpace(m[1])
535- fields := strings.Fields(cmdLine)
536- if len(fields) == 0 {
537- continue
538- }
539- cmd := fields[0]
540- // Ignore root flags like `dumber --dmenu`.
541- if strings.HasPrefix(cmd, "-") {
542- continue
543- }
544- if _, ok := cliUses[cmd]; !ok {
545- report.Add(levelWarning, rel(readmePath), i+1, "documented CLI command not found in cobra commands", fmt.Sprintf("command: dumber %s", cmd))
546- }
547- }
548- }
549-}
550-
551-func verifyReadmeBuildClaims(report *Report, readmePath string, lines []string) {
552- for i, line := range lines {
553- low := strings.ToLower(line)
554- if strings.Contains(low, "webkit_cgo") {
555- report.Add(levelWarning, rel(readmePath), i+1, "README references old CGO build tag", "reference: webkit_cgo")
556- }
557- if strings.Contains(low, "cgo") && strings.Contains(low, "enabled") {
558- report.Add(levelWarning, rel(readmePath), i+1, "README references CGO build requirements", "reference: CGO enabled")
559- }
560- if strings.Contains(low, "gotk4") {
561- report.Add(levelWarning, rel(readmePath), i+1, "README references gotk4 (refactor moved to puregotk)", "reference: gotk4")
562- }
563- }
564-}
565-
566-func writeReport(path string, report *Report) error {
567- if report == nil {
568- return errors.New("missing report")
569- }
570-
571- var buf bytes.Buffer
572- w := bufio.NewWriter(&buf)
573-
574- writeSeparator(w)
575- _, _ = fmt.Fprintln(w, "DUMBER DOCUMENTATION VERIFICATION REPORT")
576- writeSeparator(w)
577- _, _ = fmt.Fprintf(w, "Generated: %s\n\n", report.GeneratedAt.Format(time.RFC3339))
578-
579- _, _ = fmt.Fprintf(w, "Summary: %d errors, %d warnings, %d info\n\n", report.Count(levelError), report.Count(levelWarning), report.Count(levelInfo))
580-
581- issues := append([]Issue(nil), report.Issues...)
582- sort.SliceStable(issues, func(i, j int) bool {
583- if issues[i].Level != issues[j].Level {
584- return issues[i].Level < issues[j].Level
585- }
586- if issues[i].File != issues[j].File {
587- return issues[i].File < issues[j].File
588- }
589- return issues[i].Line < issues[j].Line
590- })
591-
592- for _, lvl := range []issueLevel{levelError, levelWarning, levelInfo} {
593- writeSeparator(w)
594- _, _ = fmt.Fprintf(w, "%sS\n", lvl)
595- writeSeparator(w)
596- idx := 0
597- for _, it := range issues {
598- if it.Level != lvl {
599- continue
600- }
601- idx++
602- loc := it.File
603- if it.Line > 0 {
604- loc = fmt.Sprintf("%s:%d", it.File, it.Line)
605- }
606- _, _ = fmt.Fprintf(w, "[%d] %s\n", idx, loc)
607- _, _ = fmt.Fprintf(w, " %s\n", it.Message)
608- if it.Detail != "" {
609- _, _ = fmt.Fprintf(w, " %s\n", it.Detail)
610- }
611- _, _ = fmt.Fprintln(w)
612- }
613- if idx == 0 {
614- _, _ = fmt.Fprintln(w, "(none)")
615- _, _ = fmt.Fprintln(w)
616- }
617- }
618-
619- if err := w.Flush(); err != nil {
620- return err
621- }
622-
623- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
624- return err
625- }
626- return os.WriteFile(path, buf.Bytes(), 0o644)
627-}
628-
629-func writeSeparator(w io.Writer) {
630- _, _ = fmt.Fprintln(w, strings.Repeat("=", 80))
631-}
632-
633-func printSummary(report *Report, reportPath string) {
634- fmt.Printf("check-docs: wrote %s\n", rel(reportPath))
635- fmt.Printf("check-docs: %d errors, %d warnings, %d info\n", report.Count(levelError), report.Count(levelWarning), report.Count(levelInfo))
636- if report.Count(levelError) > 0 {
637- fmt.Printf("check-docs: FAIL\n")
638- } else {
639- fmt.Printf("check-docs: OK\n")
640- }
641-}
642-
643-func rel(path string) string {
644- wd, err := os.Getwd()
645- if err != nil {
646- return path
647- }
648- rel, err := filepath.Rel(wd, path)
649- if err != nil {
650- return path
651- }
652- if strings.HasPrefix(rel, "..") {
653- return path
654- }
655- return rel
656-}
657-
658-var _ = errors.Is // keep go vet happy for future.
@@ -1,658 +0,0 @@
1-//go:build devtools
2-// +build devtools
3-
4-package main
5-
6-import (
7- "bufio"
8- "bytes"
9- "errors"
10- "flag"
11- "fmt"
12- "go/ast"
13- "go/parser"
14- "go/token"
15- "io"
16- "os"
17- "path/filepath"
18- "regexp"
19- "sort"
20- "strings"
21- "time"
22-)
23-
24-type issueLevel string
25-
26-const (
27- levelError issueLevel = "ERROR"
28- levelWarning issueLevel = "WARNING"
29- levelInfo issueLevel = "INFO"
30-)
31-
32-type Issue struct {
33- Level issueLevel
34- File string
35- Line int
36- Message string
37- Detail string
38-}
39-
40-type Report struct {
41- GeneratedAt time.Time
42- Issues []Issue
43-}
44-
45-func (r *Report) Add(level issueLevel, file string, line int, message, detail string) {
46- r.Issues = append(r.Issues, Issue{Level: level, File: file, Line: line, Message: message, Detail: detail})
47-}
48-
49-func (r *Report) Count(level issueLevel) int {
50- count := 0
51- for _, it := range r.Issues {
52- if it.Level == level {
53- count++
54- }
55- }
56- return count
57-}
58-
59-func main() {
60- failOnError := flag.Bool("fail-on-error", false, "exit non-zero if errors found")
61- out := flag.String("out", "dev/docs-report.txt", "write report to file (relative to repo root unless absolute)")
62- flag.Parse()
63-
64- repoRoot, err := findRepoRoot()
65- if err != nil {
66- fmt.Fprintln(os.Stderr, err)
67- os.Exit(2)
68- }
69-
70- report := &Report{GeneratedAt: time.Now()}
71-
72- schemaKeys, err := extractConfigKeys(filepath.Join(repoRoot, "internal", "infrastructure", "config", "schema.go"))
73- if err != nil {
74- fmt.Fprintln(os.Stderr, "failed to parse config schema:", err)
75- os.Exit(2)
76- }
77-
78- actionConstByName, err := extractActions(filepath.Join(repoRoot, "internal", "ui", "input", "shortcuts.go"))
79- if err != nil {
80- fmt.Fprintln(os.Stderr, "failed to parse shortcuts:", err)
81- os.Exit(2)
82- }
83-
84- stubActionConstNames, err := extractStubbedActionConstNames(filepath.Join(repoRoot, "internal", "ui", "dispatcher", "keyboard.go"))
85- if err != nil {
86- fmt.Fprintln(os.Stderr, "failed to parse keyboard dispatcher:", err)
87- os.Exit(2)
88- }
89- stubActionValues := make(map[string]struct{}, len(stubActionConstNames))
90- for _, name := range stubActionConstNames {
91- if val, ok := actionConstByName[name]; ok {
92- stubActionValues[val] = struct{}{}
93- }
94- }
95-
96- cliUses, err := extractCobraUseStrings(filepath.Join(repoRoot, "internal", "cli", "cmd"))
97- if err != nil {
98- fmt.Fprintln(os.Stderr, "failed to parse cli commands:", err)
99- os.Exit(2)
100- }
101-
102- readmePath := filepath.Join(repoRoot, "README.md")
103- readmeLines, err := readLines(readmePath)
104- if err != nil {
105- fmt.Fprintln(os.Stderr, "failed to read README:", err)
106- os.Exit(2)
107- }
108- configDocPath := filepath.Join(repoRoot, "docs", "CONFIG.md")
109- configDocLines, err := readLines(configDocPath)
110- if err != nil {
111- fmt.Fprintln(os.Stderr, "failed to read docs/CONFIG.md:", err)
112- os.Exit(2)
113- }
114-
115- verifyConfigDoc(report, schemaKeys, configDocPath, configDocLines)
116- verifyReadmeShortcuts(report, stubActionValues, readmePath, readmeLines)
117- verifyReadmeCommands(report, cliUses, readmePath, readmeLines)
118- verifyReadmeBuildClaims(report, readmePath, readmeLines)
119-
120- reportPath := *out
121- if !filepath.IsAbs(reportPath) {
122- reportPath = filepath.Join(repoRoot, reportPath)
123- }
124- if err := writeReport(reportPath, report); err != nil {
125- fmt.Fprintln(os.Stderr, "failed to write report:", err)
126- os.Exit(2)
127- }
128-
129- printSummary(report, reportPath)
130-
131- if *failOnError && report.Count(levelError) > 0 {
132- os.Exit(1)
133- }
134-}
135-
136-func findRepoRoot() (string, error) {
137- wd, err := os.Getwd()
138- if err != nil {
139- return "", err
140- }
141- dir := wd
142- for {
143- gitDir := filepath.Join(dir, ".git")
144- if st, err := os.Stat(gitDir); err == nil && st.IsDir() {
145- return dir, nil
146- }
147- parent := filepath.Dir(dir)
148- if parent == dir {
149- return "", errors.New("could not find repo root (.git)")
150- }
151- dir = parent
152- }
153-}
154-
155-func readLines(path string) ([]string, error) {
156- f, err := os.Open(path)
157- if err != nil {
158- return nil, err
159- }
160- defer f.Close()
161-
162- var lines []string
163- s := bufio.NewScanner(f)
164- for s.Scan() {
165- lines = append(lines, s.Text())
166- }
167- if err := s.Err(); err != nil {
168- return nil, err
169- }
170- return lines, nil
171-}
172-
173-type schemaField struct {
174- Tag string
175- TypeName string
176-}
177-
178-func extractConfigKeys(schemaPath string) (map[string]struct{}, error) {
179- fset := token.NewFileSet()
180- file, err := parser.ParseFile(fset, schemaPath, nil, parser.ParseComments)
181- if err != nil {
182- return nil, err
183- }
184-
185- typeFields := map[string][]schemaField{}
186- for _, decl := range file.Decls {
187- gen, ok := decl.(*ast.GenDecl)
188- if !ok || gen.Tok != token.TYPE {
189- continue
190- }
191- for _, spec := range gen.Specs {
192- ts, ok := spec.(*ast.TypeSpec)
193- if !ok {
194- continue
195- }
196- st, ok := ts.Type.(*ast.StructType)
197- if !ok {
198- continue
199- }
200- var fields []schemaField
201- for _, f := range st.Fields.List {
202- if len(f.Names) == 0 {
203- continue
204- }
205- tag := extractStructTagValue(f.Tag, "mapstructure")
206- if tag == "" || tag == "-" {
207- continue
208- }
209- fields = append(fields, schemaField{Tag: tag, TypeName: typeExprName(f.Type)})
210- }
211- typeFields[ts.Name.Name] = fields
212- }
213- }
214-
215- keys := make(map[string]struct{})
216- seen := map[string]bool{}
217- var walkType func(typeName, prefix string)
218- walkType = func(typeName, prefix string) {
219- if seen[prefix+"::"+typeName] {
220- return
221- }
222- seen[prefix+"::"+typeName] = true
223-
224- fields, ok := typeFields[typeName]
225- if !ok {
226- return
227- }
228- for _, f := range fields {
229- full := f.Tag
230- if prefix != "" {
231- full = prefix + "." + f.Tag
232- }
233- keys[full] = struct{}{}
234- if _, ok := typeFields[f.TypeName]; ok {
235- walkType(f.TypeName, full)
236- }
237- }
238- }
239-
240- walkType("Config", "")
241- return keys, nil
242-}
243-
244-func extractStructTagValue(tagLit *ast.BasicLit, key string) string {
245- if tagLit == nil {
246- return ""
247- }
248- tag := strings.Trim(tagLit.Value, "`")
249- parts := strings.Split(tag, " ")
250- for _, part := range parts {
251- kv := strings.SplitN(part, ":", 2)
252- if len(kv) != 2 {
253- continue
254- }
255- k := kv[0]
256- v := strings.Trim(kv[1], "\"")
257- if k != key {
258- continue
259- }
260- if idx := strings.IndexByte(v, ','); idx >= 0 {
261- return v[:idx]
262- }
263- return v
264- }
265- return ""
266-}
267-
268-func typeExprName(expr ast.Expr) string {
269- switch t := expr.(type) {
270- case *ast.Ident:
271- return t.Name
272- case *ast.SelectorExpr:
273- // pkg.Type
274- return t.Sel.Name
275- case *ast.StarExpr:
276- return typeExprName(t.X)
277- default:
278- return ""
279- }
280-}
281-
282-func extractActions(shortcutsPath string) (map[string]string, error) {
283- fset := token.NewFileSet()
284- file, err := parser.ParseFile(fset, shortcutsPath, nil, 0)
285- if err != nil {
286- return nil, err
287- }
288-
289- out := map[string]string{}
290- for _, decl := range file.Decls {
291- gen, ok := decl.(*ast.GenDecl)
292- if !ok || gen.Tok != token.CONST {
293- continue
294- }
295- for _, spec := range gen.Specs {
296- vs, ok := spec.(*ast.ValueSpec)
297- if !ok {
298- continue
299- }
300- for i, name := range vs.Names {
301- if i >= len(vs.Values) {
302- continue
303- }
304- bl, ok := vs.Values[i].(*ast.BasicLit)
305- if !ok || bl.Kind != token.STRING {
306- continue
307- }
308- if strings.HasPrefix(name.Name, "Action") {
309- out[name.Name] = strings.Trim(bl.Value, "\"")
310- }
311- }
312- }
313- }
314- return out, nil
315-}
316-
317-func extractStubbedActionConstNames(keyboardDispatcherPath string) ([]string, error) {
318- fset := token.NewFileSet()
319- file, err := parser.ParseFile(fset, keyboardDispatcherPath, nil, 0)
320- if err != nil {
321- return nil, err
322- }
323-
324- stubbedSet := map[string]struct{}{}
325-
326- ast.Inspect(file, func(n ast.Node) bool {
327- cl, ok := n.(*ast.CompositeLit)
328- if !ok {
329- return true
330- }
331- if _, ok := cl.Type.(*ast.MapType); !ok {
332- return true
333- }
334- for _, elt := range cl.Elts {
335- kv, ok := elt.(*ast.KeyValueExpr)
336- if !ok {
337- continue
338- }
339- keyName := selectorName(kv.Key)
340- if keyName == "" {
341- continue
342- }
343- fn, ok := kv.Value.(*ast.FuncLit)
344- if !ok {
345- continue
346- }
347- if funcCallsSelector(fn, "logNoop") {
348- stubbedSet[keyName] = struct{}{}
349- }
350- }
351- return true
352- })
353-
354- var stubbed []string
355- for k := range stubbedSet {
356- stubbed = append(stubbed, k)
357- }
358- sort.Strings(stubbed)
359- return stubbed, nil
360-}
361-
362-func selectorName(expr ast.Expr) string {
363- switch t := expr.(type) {
364- case *ast.SelectorExpr:
365- return t.Sel.Name
366- case *ast.Ident:
367- return t.Name
368- default:
369- return ""
370- }
371-}
372-
373-func funcCallsSelector(fn *ast.FuncLit, selName string) bool {
374- if fn == nil || fn.Body == nil {
375- return false
376- }
377- found := false
378- ast.Inspect(fn.Body, func(n ast.Node) bool {
379- call, ok := n.(*ast.CallExpr)
380- if !ok {
381- return true
382- }
383- sel, ok := call.Fun.(*ast.SelectorExpr)
384- if !ok {
385- return true
386- }
387- if sel.Sel.Name == selName {
388- found = true
389- return false
390- }
391- return true
392- })
393- return found
394-}
395-
396-func extractCobraUseStrings(cmdDir string) (map[string]struct{}, error) {
397- entries, err := os.ReadDir(cmdDir)
398- if err != nil {
399- return nil, err
400- }
401-
402- uses := map[string]struct{}{}
403- useRe := regexp.MustCompile(`\bUse:\s*"([^"]+)"`)
404-
405- for _, e := range entries {
406- if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
407- continue
408- }
409- b, err := os.ReadFile(filepath.Join(cmdDir, e.Name()))
410- if err != nil {
411- return nil, err
412- }
413- matches := useRe.FindAllSubmatch(b, -1)
414- for _, m := range matches {
415- use := string(m[1])
416- fields := strings.Fields(use)
417- if len(fields) == 0 {
418- continue
419- }
420- uses[fields[0]] = struct{}{}
421- }
422- }
423-
424- return uses, nil
425-}
426-
427-func verifyConfigDoc(report *Report, schemaKeys map[string]struct{}, configDocPath string, lines []string) {
428- docKeys := extractDocKeys(lines, schemaKeys)
429- for key, line := range docKeys {
430- if _, ok := schemaKeys[key]; ok {
431- continue
432- }
433- report.Add(levelWarning, rel(configDocPath), line, "config key documented but not present in schema", fmt.Sprintf("key: %s", key))
434- }
435-}
436-
437-func extractDocKeys(lines []string, schemaKeys map[string]struct{}) map[string]int {
438- keyRe := regexp.MustCompile("`([a-zA-Z0-9_.-]+)`")
439- tableRowRe := regexp.MustCompile(`^\|`)
440- inCode := false
441-
442- seen := map[string]int{}
443- for i, line := range lines {
444- trim := strings.TrimSpace(line)
445- if strings.HasPrefix(trim, "```") {
446- inCode = !inCode
447- continue
448- }
449- if inCode {
450- continue
451- }
452-
453- // Prefer extracting keys from the first column of Markdown tables.
454- // This avoids false-positives from enum values shown in other columns.
455- if tableRowRe.MatchString(trim) {
456- // Skip table separator rows.
457- if strings.Contains(trim, "---") {
458- continue
459- }
460- parts := strings.Split(line, "|")
461- if len(parts) >= 3 {
462- firstCell := strings.TrimSpace(parts[1])
463- matches := keyRe.FindAllStringSubmatch(firstCell, -1)
464- for _, m := range matches {
465- k := m[1]
466- if k == "" || k[0] < 'a' || k[0] > 'z' {
467- continue
468- }
469- if strings.ContainsAny(k, " /") {
470- continue
471- }
472- if _, already := seen[k]; !already {
473- seen[k] = i + 1
474- }
475- }
476- }
477- continue
478- }
479-
480- // Outside of tables, only accept dotted paths that look like config keys.
481- matches := keyRe.FindAllStringSubmatch(line, -1)
482- for _, m := range matches {
483- k := m[1]
484- if _, ok := schemaKeys[k]; ok {
485- if _, already := seen[k]; !already {
486- seen[k] = i + 1
487- }
488- continue
489- }
490- if !strings.Contains(k, ".") {
491- continue
492- }
493- if k == "" || k[0] < 'a' || k[0] > 'z' {
494- continue
495- }
496- if strings.ContainsAny(k, " /") {
497- continue
498- }
499- if _, already := seen[k]; !already {
500- seen[k] = i + 1
501- }
502- }
503- }
504- return seen
505-}
506-
507-func verifyReadmeShortcuts(report *Report, stubActionValues map[string]struct{}, readmePath string, lines []string) {
508- stubToDocPatterns := map[string][]string{
509- "rename_tab": {"Rename Tab"},
510- "toggle_fullscreen": {"Toggle Fullscreen"},
511- }
512- for action, patterns := range stubToDocPatterns {
513- if _, stubbed := stubActionValues[action]; !stubbed {
514- continue
515- }
516- for i, line := range lines {
517- for _, p := range patterns {
518- if strings.Contains(line, p) {
519- report.Add(levelError, rel(readmePath), i+1, "documented shortcut/feature appears stubbed in code", fmt.Sprintf("action: %s", action))
520- goto nextAction
521- }
522- }
523- }
524- nextAction:
525- }
526-}
527-
528-func verifyReadmeCommands(report *Report, cliUses map[string]struct{}, readmePath string, lines []string) {
529- cmdRe := regexp.MustCompile("`dumber\\s+([^`]+)`")
530-
531- for i, line := range lines {
532- matches := cmdRe.FindAllStringSubmatch(line, -1)
533- for _, m := range matches {
534- cmdLine := strings.TrimSpace(m[1])
535- fields := strings.Fields(cmdLine)
536- if len(fields) == 0 {
537- continue
538- }
539- cmd := fields[0]
540- // Ignore root flags like `dumber --dmenu`.
541- if strings.HasPrefix(cmd, "-") {
542- continue
543- }
544- if _, ok := cliUses[cmd]; !ok {
545- report.Add(levelWarning, rel(readmePath), i+1, "documented CLI command not found in cobra commands", fmt.Sprintf("command: dumber %s", cmd))
546- }
547- }
548- }
549-}
550-
551-func verifyReadmeBuildClaims(report *Report, readmePath string, lines []string) {
552- for i, line := range lines {
553- low := strings.ToLower(line)
554- if strings.Contains(low, "webkit_cgo") {
555- report.Add(levelWarning, rel(readmePath), i+1, "README references old CGO build tag", "reference: webkit_cgo")
556- }
557- if strings.Contains(low, "cgo") && strings.Contains(low, "enabled") {
558- report.Add(levelWarning, rel(readmePath), i+1, "README references CGO build requirements", "reference: CGO enabled")
559- }
560- if strings.Contains(low, "gotk4") {
561- report.Add(levelWarning, rel(readmePath), i+1, "README references gotk4 (refactor moved to puregotk)", "reference: gotk4")
562- }
563- }
564-}
565-
566-func writeReport(path string, report *Report) error {
567- if report == nil {
568- return errors.New("missing report")
569- }
570-
571- var buf bytes.Buffer
572- w := bufio.NewWriter(&buf)
573-
574- writeSeparator(w)
575- _, _ = fmt.Fprintln(w, "DUMBER DOCUMENTATION VERIFICATION REPORT")
576- writeSeparator(w)
577- _, _ = fmt.Fprintf(w, "Generated: %s\n\n", report.GeneratedAt.Format(time.RFC3339))
578-
579- _, _ = fmt.Fprintf(w, "Summary: %d errors, %d warnings, %d info\n\n", report.Count(levelError), report.Count(levelWarning), report.Count(levelInfo))
580-
581- issues := append([]Issue(nil), report.Issues...)
582- sort.SliceStable(issues, func(i, j int) bool {
583- if issues[i].Level != issues[j].Level {
584- return issues[i].Level < issues[j].Level
585- }
586- if issues[i].File != issues[j].File {
587- return issues[i].File < issues[j].File
588- }
589- return issues[i].Line < issues[j].Line
590- })
591-
592- for _, lvl := range []issueLevel{levelError, levelWarning, levelInfo} {
593- writeSeparator(w)
594- _, _ = fmt.Fprintf(w, "%sS\n", lvl)
595- writeSeparator(w)
596- idx := 0
597- for _, it := range issues {
598- if it.Level != lvl {
599- continue
600- }
601- idx++
602- loc := it.File
603- if it.Line > 0 {
604- loc = fmt.Sprintf("%s:%d", it.File, it.Line)
605- }
606- _, _ = fmt.Fprintf(w, "[%d] %s\n", idx, loc)
607- _, _ = fmt.Fprintf(w, " %s\n", it.Message)
608- if it.Detail != "" {
609- _, _ = fmt.Fprintf(w, " %s\n", it.Detail)
610- }
611- _, _ = fmt.Fprintln(w)
612- }
613- if idx == 0 {
614- _, _ = fmt.Fprintln(w, "(none)")
615- _, _ = fmt.Fprintln(w)
616- }
617- }
618-
619- if err := w.Flush(); err != nil {
620- return err
621- }
622-
623- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
624- return err
625- }
626- return os.WriteFile(path, buf.Bytes(), 0o644)
627-}
628-
629-func writeSeparator(w io.Writer) {
630- _, _ = fmt.Fprintln(w, strings.Repeat("=", 80))
631-}
632-
633-func printSummary(report *Report, reportPath string) {
634- fmt.Printf("check-docs: wrote %s\n", rel(reportPath))
635- fmt.Printf("check-docs: %d errors, %d warnings, %d info\n", report.Count(levelError), report.Count(levelWarning), report.Count(levelInfo))
636- if report.Count(levelError) > 0 {
637- fmt.Printf("check-docs: FAIL\n")
638- } else {
639- fmt.Printf("check-docs: OK\n")
640- }
641-}
642-
643-func rel(path string) string {
644- wd, err := os.Getwd()
645- if err != nil {
646- return path
647- }
648- rel, err := filepath.Rel(wd, path)
649- if err != nil {
650- return path
651- }
652- if strings.HasPrefix(rel, "..") {
653- return path
654- }
655- return rel
656-}
657-
658-var _ = errors.Is // keep go vet happy for future.
D dev/run-debug.fish +0 -15
@@ -1,15 +0,0 @@
1-#!/usr/bin/env fish
2-# dev/run-debug.fish - launch ./dist/dumber with verbose debug instrumentation
3-
4-# Enable core dumps so we get a crash backtrace if GLib aborts
5-ulimit -c unlimited
6-
7-# Go runtime crash dumps
8-set -gx GOTRACEBACK crash
9-
10-# Make GLib fatal on warnings/criticals and emit stack traces
11-set -gx G_DEBUG "fatal-criticals,fatal-warnings,stack-trace-on-fatal"
12-set -gx G_MESSAGES_DEBUG all
13-
14-# Execute the browser with any arguments forwarded
15-./dist/dumber $argv
@@ -1,15 +0,0 @@
1-#!/usr/bin/env fish
2-# dev/run-debug.fish - launch ./dist/dumber with verbose debug instrumentation
3-
4-# Enable core dumps so we get a crash backtrace if GLib aborts
5-ulimit -c unlimited
6-
7-# Go runtime crash dumps
8-set -gx GOTRACEBACK crash
9-
10-# Make GLib fatal on warnings/criticals and emit stack traces
11-set -gx G_DEBUG "fatal-criticals,fatal-warnings,stack-trace-on-fatal"
12-set -gx G_MESSAGES_DEBUG all
13-
14-# Execute the browser with any arguments forwarded
15-./dist/dumber $argv
R dev.bnema.Dumber.desktop → flatpak/dev.bnema.Dumber.desktop +1 -1 renamed, 86% similar
@@ -1,7 +1,7 @@
11 [Desktop Entry]
22 Name=Dumber
33 Comment=A minimal keyboard-driven browser for tiling WMs
4-Exec=dumber browse
4+Exec=env LANG=C.UTF-8 LC_ALL=C.UTF-8 dumber browse %U
55 Icon=dev.bnema.Dumber
66 Terminal=false
77 Type=Application
@@ -1,7 +1,7 @@
1 [Desktop Entry]1 [Desktop Entry]
2 Name=Dumber2 Name=Dumber
3 Comment=A minimal keyboard-driven browser for tiling WMs3 Comment=A minimal keyboard-driven browser for tiling WMs
4-Exec=dumber browse4+Exec=env LANG=C.UTF-8 LC_ALL=C.UTF-8 dumber browse %U
5 Icon=dev.bnema.Dumber5 Icon=dev.bnema.Dumber
6 Terminal=false6 Terminal=false
7 Type=Application7 Type=Application
R dev.bnema.Dumber.metainfo.xml → flatpak/dev.bnema.Dumber.metainfo.xml +0 -0 renamed, 100% similar

renamed, 100% similar

R dev.bnema.Dumber.yml → flatpak/dev.bnema.Dumber.yml +0 -0 renamed, 100% similar

renamed, 100% similar

M internal/cli/cmd/setup.go +18 -0
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/dumber/assets"
1010 "github.com/bnema/dumber/internal/application/usecase"
1111 "github.com/bnema/dumber/internal/infrastructure/desktop"
12+ "github.com/bnema/dumber/internal/infrastructure/env"
1213 )
1314
1415 var setupCmd = &cobra.Command{
@@ -62,6 +63,23 @@ func runSetupInstall(_ *cobra.Command, _ []string) error {
6263 }
6364
6465 theme := app.Theme
66+
67+ // Check if installed via package manager
68+ switch {
69+ case env.IsFlatpak():
70+ fmt.Printf("%s Desktop integration already handled by Flatpak\n",
71+ theme.SuccessStyle.Render("\u2713"))
72+ fmt.Println()
73+ fmt.Println(theme.Subtle.Render("Run 'dumber setup default' to set as default browser"))
74+ return nil
75+ case env.IsPacman():
76+ fmt.Printf("%s Desktop integration already handled by AUR package\n",
77+ theme.SuccessStyle.Render("\u2713"))
78+ fmt.Println()
79+ fmt.Println(theme.Subtle.Render("Run 'dumber setup default' to set as default browser"))
80+ return nil
81+ }
82+
6583 adapter := desktop.New()
6684 uc := usecase.NewInstallDesktopUseCase(adapter)
6785
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/dumber/assets"9 "github.com/bnema/dumber/assets"
10 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/infrastructure/desktop"11 "github.com/bnema/dumber/internal/infrastructure/desktop"
12+ "github.com/bnema/dumber/internal/infrastructure/env"
12 )13 )
13 14
14 var setupCmd = &cobra.Command{15 var setupCmd = &cobra.Command{
@@ -62,6 +63,23 @@ func runSetupInstall(_ *cobra.Command, _ []string) error {
62 }63 }
63 64
64 theme := app.Theme65 theme := app.Theme
66+
67+ // Check if installed via package manager
68+ switch {
69+ case env.IsFlatpak():
70+ fmt.Printf("%s Desktop integration already handled by Flatpak\n",
71+ theme.SuccessStyle.Render("\u2713"))
72+ fmt.Println()
73+ fmt.Println(theme.Subtle.Render("Run 'dumber setup default' to set as default browser"))
74+ return nil
75+ case env.IsPacman():
76+ fmt.Printf("%s Desktop integration already handled by AUR package\n",
77+ theme.SuccessStyle.Render("\u2713"))
78+ fmt.Println()
79+ fmt.Println(theme.Subtle.Render("Run 'dumber setup default' to set as default browser"))
80+ return nil
81+ }
82+
65 adapter := desktop.New()83 adapter := desktop.New()
66 uc := usecase.NewInstallDesktopUseCase(adapter)84 uc := usecase.NewInstallDesktopUseCase(adapter)
67 85
M internal/domain/entity/pane.go +34 -0
@@ -156,3 +156,37 @@ func (n *PaneNode) LeafCount() int {
156156 })
157157 return count
158158 }
159+
160+// VisibleAreaCount returns the number of visible pane areas in the tree.
161+// A stacked node counts as 1 visible area (only one pane visible at a time).
162+// A leaf node counts as 1 visible area.
163+// A split node's visible areas are the sum of its children's visible areas.
164+func (n *PaneNode) VisibleAreaCount() int {
165+ switch {
166+ case n.IsLeaf():
167+ return 1
168+ case n.IsStacked:
169+ // Stacked panes: only one is visible at a time
170+ return 1
171+ case n.IsSplit():
172+ // Split: sum of both children's visible areas
173+ count := 0
174+ if left := n.Left(); left != nil {
175+ count += left.VisibleAreaCount()
176+ }
177+ if right := n.Right(); right != nil {
178+ count += right.VisibleAreaCount()
179+ }
180+ return count
181+ default:
182+ // Fallback for other container types
183+ if len(n.Children) == 0 {
184+ return 1
185+ }
186+ count := 0
187+ for _, child := range n.Children {
188+ count += child.VisibleAreaCount()
189+ }
190+ return count
191+ }
192+}
@@ -156,3 +156,37 @@ func (n *PaneNode) LeafCount() int {
156 })156 })
157 return count157 return count
158 }158 }
159+
160+// VisibleAreaCount returns the number of visible pane areas in the tree.
161+// A stacked node counts as 1 visible area (only one pane visible at a time).
162+// A leaf node counts as 1 visible area.
163+// A split node's visible areas are the sum of its children's visible areas.
164+func (n *PaneNode) VisibleAreaCount() int {
165+ switch {
166+ case n.IsLeaf():
167+ return 1
168+ case n.IsStacked:
169+ // Stacked panes: only one is visible at a time
170+ return 1
171+ case n.IsSplit():
172+ // Split: sum of both children's visible areas
173+ count := 0
174+ if left := n.Left(); left != nil {
175+ count += left.VisibleAreaCount()
176+ }
177+ if right := n.Right(); right != nil {
178+ count += right.VisibleAreaCount()
179+ }
180+ return count
181+ default:
182+ // Fallback for other container types
183+ if len(n.Children) == 0 {
184+ return 1
185+ }
186+ count := 0
187+ for _, child := range n.Children {
188+ count += child.VisibleAreaCount()
189+ }
190+ return count
191+ }
192+}
A internal/domain/entity/pane_test.go +129 -0
@@ -0,0 +1,129 @@
1+package entity
2+
3+import "testing"
4+
5+func TestPaneNode_VisibleAreaCount(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ node *PaneNode
9+ expected int
10+ }{
11+ {
12+ name: "single leaf pane",
13+ node: &PaneNode{
14+ ID: "pane1",
15+ Pane: NewPane("pane1"),
16+ },
17+ expected: 1,
18+ },
19+ {
20+ name: "horizontal split with two leaves",
21+ node: &PaneNode{
22+ ID: "split1",
23+ SplitDir: SplitHorizontal,
24+ Children: []*PaneNode{
25+ {ID: "pane1", Pane: NewPane("pane1")},
26+ {ID: "pane2", Pane: NewPane("pane2")},
27+ },
28+ },
29+ expected: 2,
30+ },
31+ {
32+ name: "stacked panes count as one",
33+ node: &PaneNode{
34+ ID: "stack1",
35+ IsStacked: true,
36+ ActiveStackIndex: 0,
37+ Children: []*PaneNode{
38+ {ID: "pane1", Pane: NewPane("pane1")},
39+ {ID: "pane2", Pane: NewPane("pane2")},
40+ {ID: "pane3", Pane: NewPane("pane3")},
41+ },
42+ },
43+ expected: 1,
44+ },
45+ {
46+ name: "split with one stacked side",
47+ node: &PaneNode{
48+ ID: "split1",
49+ SplitDir: SplitHorizontal,
50+ Children: []*PaneNode{
51+ {ID: "pane1", Pane: NewPane("pane1")},
52+ {
53+ ID: "stack1",
54+ IsStacked: true,
55+ ActiveStackIndex: 0,
56+ Children: []*PaneNode{
57+ {ID: "pane2", Pane: NewPane("pane2")},
58+ {ID: "pane3", Pane: NewPane("pane3")},
59+ },
60+ },
61+ },
62+ },
63+ expected: 2,
64+ },
65+ {
66+ name: "nested splits",
67+ node: &PaneNode{
68+ ID: "split1",
69+ SplitDir: SplitHorizontal,
70+ Children: []*PaneNode{
71+ {ID: "pane1", Pane: NewPane("pane1")},
72+ {
73+ ID: "split2",
74+ SplitDir: SplitVertical,
75+ Children: []*PaneNode{
76+ {ID: "pane2", Pane: NewPane("pane2")},
77+ {ID: "pane3", Pane: NewPane("pane3")},
78+ },
79+ },
80+ },
81+ },
82+ expected: 3,
83+ },
84+ {
85+ name: "complex tree with stacks and splits",
86+ node: &PaneNode{
87+ ID: "split1",
88+ SplitDir: SplitHorizontal,
89+ Children: []*PaneNode{
90+ {
91+ ID: "stack1",
92+ IsStacked: true,
93+ ActiveStackIndex: 0,
94+ Children: []*PaneNode{
95+ {ID: "pane1", Pane: NewPane("pane1")},
96+ {ID: "pane2", Pane: NewPane("pane2")},
97+ },
98+ },
99+ {
100+ ID: "split2",
101+ SplitDir: SplitVertical,
102+ Children: []*PaneNode{
103+ {ID: "pane3", Pane: NewPane("pane3")},
104+ {
105+ ID: "stack2",
106+ IsStacked: true,
107+ ActiveStackIndex: 1,
108+ Children: []*PaneNode{
109+ {ID: "pane4", Pane: NewPane("pane4")},
110+ {ID: "pane5", Pane: NewPane("pane5")},
111+ },
112+ },
113+ },
114+ },
115+ },
116+ },
117+ expected: 3, // stack1(1) + pane3(1) + stack2(1)
118+ },
119+ }
120+
121+ for _, tt := range tests {
122+ t.Run(tt.name, func(t *testing.T) {
123+ got := tt.node.VisibleAreaCount()
124+ if got != tt.expected {
125+ t.Errorf("VisibleAreaCount() = %d, want %d", got, tt.expected)
126+ }
127+ })
128+ }
129+}
@@ -0,0 +1,129 @@
1+package entity
2+
3+import "testing"
4+
5+func TestPaneNode_VisibleAreaCount(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ node *PaneNode
9+ expected int
10+ }{
11+ {
12+ name: "single leaf pane",
13+ node: &PaneNode{
14+ ID: "pane1",
15+ Pane: NewPane("pane1"),
16+ },
17+ expected: 1,
18+ },
19+ {
20+ name: "horizontal split with two leaves",
21+ node: &PaneNode{
22+ ID: "split1",
23+ SplitDir: SplitHorizontal,
24+ Children: []*PaneNode{
25+ {ID: "pane1", Pane: NewPane("pane1")},
26+ {ID: "pane2", Pane: NewPane("pane2")},
27+ },
28+ },
29+ expected: 2,
30+ },
31+ {
32+ name: "stacked panes count as one",
33+ node: &PaneNode{
34+ ID: "stack1",
35+ IsStacked: true,
36+ ActiveStackIndex: 0,
37+ Children: []*PaneNode{
38+ {ID: "pane1", Pane: NewPane("pane1")},
39+ {ID: "pane2", Pane: NewPane("pane2")},
40+ {ID: "pane3", Pane: NewPane("pane3")},
41+ },
42+ },
43+ expected: 1,
44+ },
45+ {
46+ name: "split with one stacked side",
47+ node: &PaneNode{
48+ ID: "split1",
49+ SplitDir: SplitHorizontal,
50+ Children: []*PaneNode{
51+ {ID: "pane1", Pane: NewPane("pane1")},
52+ {
53+ ID: "stack1",
54+ IsStacked: true,
55+ ActiveStackIndex: 0,
56+ Children: []*PaneNode{
57+ {ID: "pane2", Pane: NewPane("pane2")},
58+ {ID: "pane3", Pane: NewPane("pane3")},
59+ },
60+ },
61+ },
62+ },
63+ expected: 2,
64+ },
65+ {
66+ name: "nested splits",
67+ node: &PaneNode{
68+ ID: "split1",
69+ SplitDir: SplitHorizontal,
70+ Children: []*PaneNode{
71+ {ID: "pane1", Pane: NewPane("pane1")},
72+ {
73+ ID: "split2",
74+ SplitDir: SplitVertical,
75+ Children: []*PaneNode{
76+ {ID: "pane2", Pane: NewPane("pane2")},
77+ {ID: "pane3", Pane: NewPane("pane3")},
78+ },
79+ },
80+ },
81+ },
82+ expected: 3,
83+ },
84+ {
85+ name: "complex tree with stacks and splits",
86+ node: &PaneNode{
87+ ID: "split1",
88+ SplitDir: SplitHorizontal,
89+ Children: []*PaneNode{
90+ {
91+ ID: "stack1",
92+ IsStacked: true,
93+ ActiveStackIndex: 0,
94+ Children: []*PaneNode{
95+ {ID: "pane1", Pane: NewPane("pane1")},
96+ {ID: "pane2", Pane: NewPane("pane2")},
97+ },
98+ },
99+ {
100+ ID: "split2",
101+ SplitDir: SplitVertical,
102+ Children: []*PaneNode{
103+ {ID: "pane3", Pane: NewPane("pane3")},
104+ {
105+ ID: "stack2",
106+ IsStacked: true,
107+ ActiveStackIndex: 1,
108+ Children: []*PaneNode{
109+ {ID: "pane4", Pane: NewPane("pane4")},
110+ {ID: "pane5", Pane: NewPane("pane5")},
111+ },
112+ },
113+ },
114+ },
115+ },
116+ },
117+ expected: 3, // stack1(1) + pane3(1) + stack2(1)
118+ },
119+ }
120+
121+ for _, tt := range tests {
122+ t.Run(tt.name, func(t *testing.T) {
123+ got := tt.node.VisibleAreaCount()
124+ if got != tt.expected {
125+ t.Errorf("VisibleAreaCount() = %d, want %d", got, tt.expected)
126+ }
127+ })
128+ }
129+}
M internal/domain/entity/workspace.go +9 -0
@@ -64,3 +64,12 @@ func (w *Workspace) AllPanes() []*Pane {
6464 })
6565 return panes
6666 }
67+
68+// VisibleAreaCount returns the number of visible pane areas.
69+// Stacked panes count as 1 (only one visible at a time).
70+func (w *Workspace) VisibleAreaCount() int {
71+ if w.Root == nil {
72+ return 0
73+ }
74+ return w.Root.VisibleAreaCount()
75+}
@@ -64,3 +64,12 @@ func (w *Workspace) AllPanes() []*Pane {
64 })64 })
65 return panes65 return panes
66 }66 }
67+
68+// VisibleAreaCount returns the number of visible pane areas.
69+// Stacked panes count as 1 (only one visible at a time).
70+func (w *Workspace) VisibleAreaCount() int {
71+ if w.Root == nil {
72+ return 0
73+ }
74+ return w.Root.VisibleAreaCount()
75+}
M internal/infrastructure/config/xdg.go +1 -0
@@ -185,6 +185,7 @@ func EnsureDirectories() error {
185185 dirs.ConfigHome,
186186 dirs.DataHome,
187187 dirs.StateHome,
188+ dirs.CacheHome,
188189 }
189190
190191 for _, dir := range directories {
@@ -185,6 +185,7 @@ func EnsureDirectories() error {
185 dirs.ConfigHome,185 dirs.ConfigHome,
186 dirs.DataHome,186 dirs.DataHome,
187 dirs.StateHome,187 dirs.StateHome,
188+ dirs.CacheHome,
188 }189 }
189 190
190 for _, dir := range directories {191 for _, dir := range directories {
M internal/infrastructure/desktop/adapter.go +43 -14
@@ -15,11 +15,12 @@ import (
1515 )
1616
1717 const (
18- appName = "dumber"
19- desktopFileName = "dumber.desktop"
20- iconFileName = "dumber.svg"
21- filePerm = 0644
22- dirPerm = 0755
18+ appName = "dumber"
19+ desktopFileName = "dumber.desktop"
20+ packageDesktopFileName = "dev.bnema.Dumber.desktop" // Used by AUR/Flatpak
21+ iconFileName = "dumber.svg"
22+ filePerm = 0644
23+ dirPerm = 0755
2324 )
2425
2526 // desktopFileTemplate is the freedesktop.org desktop entry format.
@@ -157,7 +158,7 @@ func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus
157158 out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
158159 if err == nil {
159160 currentDefault := strings.TrimSpace(string(out))
160- status.IsDefaultBrowser = currentDefault == desktopFileName
161+ status.IsDefaultBrowser = currentDefault == desktopFileName || currentDefault == packageDesktopFileName
161162 }
162163 }
163164
@@ -306,25 +307,53 @@ func (a *Adapter) SetAsDefaultBrowser(ctx context.Context) error {
306307 return fmt.Errorf("xdg-settings not found (install xdg-utils)")
307308 }
308309
309- // Check if desktop file is installed
310- desktopPath, err := getDesktopFilePath()
311- if err != nil {
312- return err
313- }
314- if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
310+ // Determine which desktop file to use
311+ // Priority: package manager desktop file (AUR/Flatpak) > user-installed
312+ desktopFile := a.findDesktopFile(ctx)
313+ if desktopFile == "" {
315314 return fmt.Errorf("desktop file not installed - run 'dumber setup install' first")
316315 }
317316
318317 // Set default browser
319- cmd := exec.CommandContext(ctx, a.xdgSettingsPath, "set", "default-web-browser", desktopFileName)
318+ cmd := exec.CommandContext(ctx, a.xdgSettingsPath, "set", "default-web-browser", desktopFile)
320319 if out, err := cmd.CombinedOutput(); err != nil {
321320 return fmt.Errorf("xdg-settings failed: %s", strings.TrimSpace(string(out)))
322321 }
323322
324- log.Info().Msg("dumber set as default browser")
323+ log.Info().Str("desktop_file", desktopFile).Msg("dumber set as default browser")
325324 return nil
326325 }
327326
327+// findDesktopFile returns the desktop file name to use for default browser setting.
328+// Checks system paths first (AUR/Flatpak), then user path (manual install).
329+func (a *Adapter) findDesktopFile(ctx context.Context) string {
330+ log := logging.FromContext(ctx)
331+
332+ // Check system-wide paths for package manager installs
333+ systemPaths := []string{
334+ "/usr/share/applications/" + packageDesktopFileName,
335+ "/app/share/applications/" + packageDesktopFileName, // Flatpak
336+ }
337+
338+ for _, path := range systemPaths {
339+ if _, err := os.Stat(path); err == nil {
340+ log.Debug().Str("path", path).Msg("found package desktop file")
341+ return packageDesktopFileName
342+ }
343+ }
344+
345+ // Check user path for manual installs
346+ userPath, err := getDesktopFilePath()
347+ if err == nil {
348+ if _, err := os.Stat(userPath); err == nil {
349+ log.Debug().Str("path", userPath).Msg("found user desktop file")
350+ return desktopFileName
351+ }
352+ }
353+
354+ return ""
355+}
356+
328357 // UnsetAsDefaultBrowser resets default browser if dumber is currently default.
329358 func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error {
330359 log := logging.FromContext(ctx)
@@ -15,11 +15,12 @@ import (
15 )15 )
16 16
17 const (17 const (
18- appName = "dumber"18+ appName = "dumber"
19- desktopFileName = "dumber.desktop"19+ desktopFileName = "dumber.desktop"
20- iconFileName = "dumber.svg"20+ packageDesktopFileName = "dev.bnema.Dumber.desktop" // Used by AUR/Flatpak
21- filePerm = 064421+ iconFileName = "dumber.svg"
22- dirPerm = 075522+ filePerm = 0644
23+ dirPerm = 0755
23 )24 )
24 25
25 // desktopFileTemplate is the freedesktop.org desktop entry format.26 // desktopFileTemplate is the freedesktop.org desktop entry format.
@@ -157,7 +158,7 @@ func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus
157 out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()158 out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
158 if err == nil {159 if err == nil {
159 currentDefault := strings.TrimSpace(string(out))160 currentDefault := strings.TrimSpace(string(out))
160- status.IsDefaultBrowser = currentDefault == desktopFileName161+ status.IsDefaultBrowser = currentDefault == desktopFileName || currentDefault == packageDesktopFileName
161 }162 }
162 }163 }
163 164
@@ -306,25 +307,53 @@ func (a *Adapter) SetAsDefaultBrowser(ctx context.Context) error {
306 return fmt.Errorf("xdg-settings not found (install xdg-utils)")307 return fmt.Errorf("xdg-settings not found (install xdg-utils)")
307 }308 }
308 309
309- // Check if desktop file is installed310+ // Determine which desktop file to use
310- desktopPath, err := getDesktopFilePath()311+ // Priority: package manager desktop file (AUR/Flatpak) > user-installed
311- if err != nil {312+ desktopFile := a.findDesktopFile(ctx)
312- return err313+ if desktopFile == "" {
313- }
314- if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
315 return fmt.Errorf("desktop file not installed - run 'dumber setup install' first")314 return fmt.Errorf("desktop file not installed - run 'dumber setup install' first")
316 }315 }
317 316
318 // Set default browser317 // Set default browser
319- cmd := exec.CommandContext(ctx, a.xdgSettingsPath, "set", "default-web-browser", desktopFileName)318+ cmd := exec.CommandContext(ctx, a.xdgSettingsPath, "set", "default-web-browser", desktopFile)
320 if out, err := cmd.CombinedOutput(); err != nil {319 if out, err := cmd.CombinedOutput(); err != nil {
321 return fmt.Errorf("xdg-settings failed: %s", strings.TrimSpace(string(out)))320 return fmt.Errorf("xdg-settings failed: %s", strings.TrimSpace(string(out)))
322 }321 }
323 322
324- log.Info().Msg("dumber set as default browser")323+ log.Info().Str("desktop_file", desktopFile).Msg("dumber set as default browser")
325 return nil324 return nil
326 }325 }
327 326
327+// findDesktopFile returns the desktop file name to use for default browser setting.
328+// Checks system paths first (AUR/Flatpak), then user path (manual install).
329+func (a *Adapter) findDesktopFile(ctx context.Context) string {
330+ log := logging.FromContext(ctx)
331+
332+ // Check system-wide paths for package manager installs
333+ systemPaths := []string{
334+ "/usr/share/applications/" + packageDesktopFileName,
335+ "/app/share/applications/" + packageDesktopFileName, // Flatpak
336+ }
337+
338+ for _, path := range systemPaths {
339+ if _, err := os.Stat(path); err == nil {
340+ log.Debug().Str("path", path).Msg("found package desktop file")
341+ return packageDesktopFileName
342+ }
343+ }
344+
345+ // Check user path for manual installs
346+ userPath, err := getDesktopFilePath()
347+ if err == nil {
348+ if _, err := os.Stat(userPath); err == nil {
349+ log.Debug().Str("path", userPath).Msg("found user desktop file")
350+ return desktopFileName
351+ }
352+ }
353+
354+ return ""
355+}
356+
328 // UnsetAsDefaultBrowser resets default browser if dumber is currently default.357 // UnsetAsDefaultBrowser resets default browser if dumber is currently default.
329 func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error {358 func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error {
330 log := logging.FromContext(ctx)359 log := logging.FromContext(ctx)
M internal/infrastructure/favicon/cache.go +9 -0
@@ -218,6 +218,15 @@ func (c *Cache) Close() {
218218 })
219219 }
220220
221+// EnsureDir ensures the cache directory exists on disk.
222+// Call this before using DiskPathPNG with external save functions.
223+func (c *Cache) EnsureDir() error {
224+ if c.diskDir == "" {
225+ return nil
226+ }
227+ return os.MkdirAll(c.diskDir, diskCacheDirPerm)
228+}
229+
221230 // Clear removes all entries from the in-memory cache.
222231 func (c *Cache) Clear() {
223232 c.mu.Lock()
@@ -218,6 +218,15 @@ func (c *Cache) Close() {
218 })218 })
219 }219 }
220 220
221+// EnsureDir ensures the cache directory exists on disk.
222+// Call this before using DiskPathPNG with external save functions.
223+func (c *Cache) EnsureDir() error {
224+ if c.diskDir == "" {
225+ return nil
226+ }
227+ return os.MkdirAll(c.diskDir, diskCacheDirPerm)
228+}
229+
221 // Clear removes all entries from the in-memory cache.230 // Clear removes all entries from the in-memory cache.
222 func (c *Cache) Clear() {231 func (c *Cache) Clear() {
223 c.mu.Lock()232 c.mu.Lock()
M internal/infrastructure/favicon/service.go +6 -0
@@ -185,3 +185,9 @@ func (s *Service) EnsureInternalFaviconPNG(pngData []byte, size int) string {
185185 s.cache.WritePNGSized(InternalDomain, pngData, size)
186186 return s.DiskPathPNGSized(InternalDomain, size)
187187 }
188+
189+// EnsureCacheDir ensures the favicon cache directory exists.
190+// Call this before using DiskPathPNG with external save functions like GTK's SaveToPng.
191+func (s *Service) EnsureCacheDir() error {
192+ return s.cache.EnsureDir()
193+}
@@ -185,3 +185,9 @@ func (s *Service) EnsureInternalFaviconPNG(pngData []byte, size int) string {
185 s.cache.WritePNGSized(InternalDomain, pngData, size)185 s.cache.WritePNGSized(InternalDomain, pngData, size)
186 return s.DiskPathPNGSized(InternalDomain, size)186 return s.DiskPathPNGSized(InternalDomain, size)
187 }187 }
188+
189+// EnsureCacheDir ensures the favicon cache directory exists.
190+// Call this before using DiskPathPNG with external save functions like GTK's SaveToPng.
191+func (s *Service) EnsureCacheDir() error {
192+ return s.cache.EnsureDir()
193+}
M internal/infrastructure/updater/github.go +15 -0
@@ -23,6 +23,7 @@ import (
2323 "time"
2424
2525 "github.com/bnema/dumber/internal/domain/entity"
26+ "github.com/bnema/dumber/internal/infrastructure/env"
2627 "github.com/bnema/dumber/internal/logging"
2728 )
2829
@@ -93,6 +94,20 @@ func (g *GitHubChecker) CheckForUpdate(ctx context.Context, currentVersion strin
9394 }, nil
9495 }
9596
97+ // Skip check for package manager installs (Flatpak, AUR).
98+ // These should use their respective package managers for updates.
99+ if env.IsFlatpak() || env.IsPacman() {
100+ log.Debug().
101+ Bool("flatpak", env.IsFlatpak()).
102+ Bool("pacman", env.IsPacman()).
103+ Msg("skipping update check for package manager install")
104+ return &entity.UpdateInfo{
105+ CurrentVersion: currentVersion,
106+ LatestVersion: currentVersion,
107+ IsNewer: false,
108+ }, nil
109+ }
110+
96111 req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPIURL, http.NoBody)
97112 if err != nil {
98113 return nil, fmt.Errorf("failed to create request: %w", err)
@@ -23,6 +23,7 @@ import (
23 "time"23 "time"
24 24
25 "github.com/bnema/dumber/internal/domain/entity"25 "github.com/bnema/dumber/internal/domain/entity"
26+ "github.com/bnema/dumber/internal/infrastructure/env"
26 "github.com/bnema/dumber/internal/logging"27 "github.com/bnema/dumber/internal/logging"
27 )28 )
28 29
@@ -93,6 +94,20 @@ func (g *GitHubChecker) CheckForUpdate(ctx context.Context, currentVersion strin
93 }, nil94 }, nil
94 }95 }
95 96
97+ // Skip check for package manager installs (Flatpak, AUR).
98+ // These should use their respective package managers for updates.
99+ if env.IsFlatpak() || env.IsPacman() {
100+ log.Debug().
101+ Bool("flatpak", env.IsFlatpak()).
102+ Bool("pacman", env.IsPacman()).
103+ Msg("skipping update check for package manager install")
104+ return &entity.UpdateInfo{
105+ CurrentVersion: currentVersion,
106+ LatestVersion: currentVersion,
107+ IsNewer: false,
108+ }, nil
109+ }
110+
96 req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPIURL, http.NoBody)111 req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPIURL, http.NoBody)
97 if err != nil {112 if err != nil {
98 return nil, fmt.Errorf("failed to create request: %w", err)113 return nil, fmt.Errorf("failed to create request: %w", err)
M internal/infrastructure/webkit/webview.go +15 -0
@@ -950,6 +950,21 @@ func (wv *WebView) ShowDevTools() error {
950950 return nil
951951 }
952952
953+// Print opens the print dialog for the current page.
954+func (wv *WebView) Print() error {
955+ if wv.destroyed.Load() {
956+ return fmt.Errorf("webview %d is destroyed", wv.id)
957+ }
958+ printOp := webkit.NewPrintOperation(wv.inner)
959+ if printOp == nil {
960+ return fmt.Errorf("failed to create print operation for webview %d", wv.id)
961+ }
962+ // RunDialog with nil parent window - GTK will use the widget's toplevel
963+ printOp.RunDialog(nil)
964+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("print dialog opened")
965+ return nil
966+}
967+
953968 // IsDestroyed returns true if the WebView has been destroyed.
954969 func (wv *WebView) IsDestroyed() bool {
955970 return wv.destroyed.Load()
@@ -950,6 +950,21 @@ func (wv *WebView) ShowDevTools() error {
950 return nil950 return nil
951 }951 }
952 952
953+// Print opens the print dialog for the current page.
954+func (wv *WebView) Print() error {
955+ if wv.destroyed.Load() {
956+ return fmt.Errorf("webview %d is destroyed", wv.id)
957+ }
958+ printOp := webkit.NewPrintOperation(wv.inner)
959+ if printOp == nil {
960+ return fmt.Errorf("failed to create print operation for webview %d", wv.id)
961+ }
962+ // RunDialog with nil parent window - GTK will use the widget's toplevel
963+ printOp.RunDialog(nil)
964+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("print dialog opened")
965+ return nil
966+}
967+
953 // IsDestroyed returns true if the WebView has been destroyed.968 // IsDestroyed returns true if the WebView has been destroyed.
954 func (wv *WebView) IsDestroyed() bool {969 func (wv *WebView) IsDestroyed() bool {
955 return wv.destroyed.Load()970 return wv.destroyed.Load()
M internal/ui/adapter/favicon.go +45 -45
@@ -166,34 +166,11 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
166166 return
167167 }
168168
169- log := logging.FromContext(ctx)
170-
171169 // Store in texture cache
172170 a.setTexture(domain, texture)
173171
174- // Export as PNG for CLI tools (rofi/fuzzel) - do this on main thread
175- // since GTK texture operations need to happen there
176- pngPath := a.service.DiskPathPNG(domain)
177- if pngPath != "" && !a.service.HasPNGOnDisk(domain) {
178- if ok := texture.SaveToPng(pngPath); !ok {
179- log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
180- } else {
181- log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
182-
183- // Create normalized sized copy for dmenu/fuzzel (async)
184- // The original PNG is now on disk, so this should succeed
185- go func() {
186- if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
187- log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG after save")
188- } else {
189- log.Debug().Str("domain", domain).Msg("created sized PNG after save")
190- }
191- }()
192- }
193- }
194-
195- // Ensure disk cache is populated (async, in background)
196- go a.service.EnsureDiskCache(ctx, domain)
172+ // Export as PNG and ensure disk cache
173+ a.saveFaviconToDisk(ctx, domain, texture)
197174 }
198175
199176 // StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL.
@@ -205,34 +182,57 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
205182
206183 // Also store under original URL domain if different
207184 if originURL != "" && originURL != currentURL {
208- log := logging.FromContext(ctx)
209185 originDomain := domainurl.ExtractDomain(originURL)
210186 currentDomain := domainurl.ExtractDomain(currentURL)
211187 if originDomain != "" && originDomain != currentDomain {
212188 a.setTexture(originDomain, texture)
189+ a.saveFaviconToDisk(ctx, originDomain, texture)
190+ }
191+ }
192+}
213193
214- // Export PNG for origin domain too
215- pngPath := a.service.DiskPathPNG(originDomain)
216- if pngPath != "" && !a.service.HasPNGOnDisk(originDomain) {
217- if ok := texture.SaveToPng(pngPath); !ok {
218- log.Warn().Str("domain", originDomain).Str("path", pngPath).Msg("failed to save origin favicon PNG")
219- } else {
220- log.Debug().Str("domain", originDomain).Str("path", pngPath).Msg("saved origin favicon PNG")
221-
222- // Create normalized sized copy for dmenu/fuzzel (async)
223- go func() {
224- if err := a.service.EnsureSizedPNG(ctx, originDomain, favicon.NormalizedIconSize); err != nil {
225- log.Warn().Err(err).Str("domain", originDomain).Msg("failed to create sized PNG for origin")
226- } else {
227- log.Debug().Str("domain", originDomain).Msg("created sized PNG for origin")
228- }
229- }()
230- }
231- }
194+// saveFaviconToDisk exports a favicon texture to PNG and ensures disk cache is populated.
195+// Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.
196+func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
197+ log := logging.FromContext(ctx)
198+
199+ // Check if PNG save is needed before scheduling idle callback
200+ pngPath := a.service.DiskPathPNG(domain)
201+ needsPNGSave := pngPath != "" && !a.service.HasPNGOnDisk(domain)
232202
233- go a.service.EnsureDiskCache(ctx, originDomain)
203+ if needsPNGSave {
204+ // Ensure cache directory exists (cheap check, do it now)
205+ if err := a.service.EnsureCacheDir(); err != nil {
206+ log.Warn().Err(err).Str("domain", domain).Msg("failed to create favicon cache dir")
207+ needsPNGSave = false
234208 }
235209 }
210+
211+ if needsPNGSave {
212+ // Schedule PNG save for when GTK main loop is idle (non-blocking)
213+ // texture.SaveToPng must run on main thread but we defer it to avoid blocking
214+ cb := glib.SourceFunc(func(_ uintptr) bool {
215+ if ok := texture.SaveToPng(pngPath); !ok {
216+ log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
217+ } else {
218+ log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
219+
220+ // Create normalized sized copy for dmenu/fuzzel (async)
221+ go func() {
222+ if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
223+ log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG")
224+ } else {
225+ log.Debug().Str("domain", domain).Msg("created sized PNG")
226+ }
227+ }()
228+ }
229+ return false // don't repeat
230+ })
231+ glib.IdleAdd(&cb, 0)
232+ }
233+
234+ // Ensure disk cache is populated (async, in background)
235+ go a.service.EnsureDiskCache(ctx, domain)
236236 }
237237
238238 // PreloadFromCache attempts to load a favicon from cache without fetching.
@@ -166,34 +166,11 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
166 return166 return
167 }167 }
168 168
169- log := logging.FromContext(ctx)
170-
171 // Store in texture cache169 // Store in texture cache
172 a.setTexture(domain, texture)170 a.setTexture(domain, texture)
173 171
174- // Export as PNG for CLI tools (rofi/fuzzel) - do this on main thread172+ // Export as PNG and ensure disk cache
175- // since GTK texture operations need to happen there173+ a.saveFaviconToDisk(ctx, domain, texture)
176- pngPath := a.service.DiskPathPNG(domain)
177- if pngPath != "" && !a.service.HasPNGOnDisk(domain) {
178- if ok := texture.SaveToPng(pngPath); !ok {
179- log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
180- } else {
181- log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
182-
183- // Create normalized sized copy for dmenu/fuzzel (async)
184- // The original PNG is now on disk, so this should succeed
185- go func() {
186- if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
187- log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG after save")
188- } else {
189- log.Debug().Str("domain", domain).Msg("created sized PNG after save")
190- }
191- }()
192- }
193- }
194-
195- // Ensure disk cache is populated (async, in background)
196- go a.service.EnsureDiskCache(ctx, domain)
197 }174 }
198 175
199 // StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL.176 // StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL.
@@ -205,34 +182,57 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
205 182
206 // Also store under original URL domain if different183 // Also store under original URL domain if different
207 if originURL != "" && originURL != currentURL {184 if originURL != "" && originURL != currentURL {
208- log := logging.FromContext(ctx)
209 originDomain := domainurl.ExtractDomain(originURL)185 originDomain := domainurl.ExtractDomain(originURL)
210 currentDomain := domainurl.ExtractDomain(currentURL)186 currentDomain := domainurl.ExtractDomain(currentURL)
211 if originDomain != "" && originDomain != currentDomain {187 if originDomain != "" && originDomain != currentDomain {
212 a.setTexture(originDomain, texture)188 a.setTexture(originDomain, texture)
189+ a.saveFaviconToDisk(ctx, originDomain, texture)
190+ }
191+ }
192+}
213 193
214- // Export PNG for origin domain too194+// saveFaviconToDisk exports a favicon texture to PNG and ensures disk cache is populated.
215- pngPath := a.service.DiskPathPNG(originDomain)195+// Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.
216- if pngPath != "" && !a.service.HasPNGOnDisk(originDomain) {196+func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
217- if ok := texture.SaveToPng(pngPath); !ok {197+ log := logging.FromContext(ctx)
218- log.Warn().Str("domain", originDomain).Str("path", pngPath).Msg("failed to save origin favicon PNG")198+
219- } else {199+ // Check if PNG save is needed before scheduling idle callback
220- log.Debug().Str("domain", originDomain).Str("path", pngPath).Msg("saved origin favicon PNG")200+ pngPath := a.service.DiskPathPNG(domain)
221-201+ needsPNGSave := pngPath != "" && !a.service.HasPNGOnDisk(domain)
222- // Create normalized sized copy for dmenu/fuzzel (async)
223- go func() {
224- if err := a.service.EnsureSizedPNG(ctx, originDomain, favicon.NormalizedIconSize); err != nil {
225- log.Warn().Err(err).Str("domain", originDomain).Msg("failed to create sized PNG for origin")
226- } else {
227- log.Debug().Str("domain", originDomain).Msg("created sized PNG for origin")
228- }
229- }()
230- }
231- }
232 202
233- go a.service.EnsureDiskCache(ctx, originDomain)203+ if needsPNGSave {
204+ // Ensure cache directory exists (cheap check, do it now)
205+ if err := a.service.EnsureCacheDir(); err != nil {
206+ log.Warn().Err(err).Str("domain", domain).Msg("failed to create favicon cache dir")
207+ needsPNGSave = false
234 }208 }
235 }209 }
210+
211+ if needsPNGSave {
212+ // Schedule PNG save for when GTK main loop is idle (non-blocking)
213+ // texture.SaveToPng must run on main thread but we defer it to avoid blocking
214+ cb := glib.SourceFunc(func(_ uintptr) bool {
215+ if ok := texture.SaveToPng(pngPath); !ok {
216+ log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
217+ } else {
218+ log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
219+
220+ // Create normalized sized copy for dmenu/fuzzel (async)
221+ go func() {
222+ if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
223+ log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG")
224+ } else {
225+ log.Debug().Str("domain", domain).Msg("created sized PNG")
226+ }
227+ }()
228+ }
229+ return false // don't repeat
230+ })
231+ glib.IdleAdd(&cb, 0)
232+ }
233+
234+ // Ensure disk cache is populated (async, in background)
235+ go a.service.EnsureDiskCache(ctx, domain)
236 }236 }
237 237
238 // PreloadFromCache attempts to load a favicon from cache without fetching.238 // PreloadFromCache attempts to load a favicon from cache without fetching.
M internal/ui/component/workspace_view.go +8 -2
@@ -297,17 +297,23 @@ func (wv *WorkspaceView) PaneCount() int {
297297 return len(wv.paneViews)
298298 }
299299
300-// singlePaneClass is used to hide the active pane border when only one pane exists.
300+// singlePaneClass is used to hide the active pane border when only one visible area exists.
301301 const singlePaneClass = "single-pane"
302302
303303 // updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container.
304+// Uses VisibleAreaCount from the domain model: stacked panes count as 1 visible area.
304305 // Must be called with the lock held.
305306 func (wv *WorkspaceView) updateSinglePaneModeInternal() {
306307 if wv.container == nil {
307308 return
308309 }
309310
310- if len(wv.paneViews) <= 1 {
311+ visibleAreas := 0
312+ if wv.workspace != nil {
313+ visibleAreas = wv.workspace.VisibleAreaCount()
314+ }
315+
316+ if visibleAreas <= 1 {
311317 wv.container.AddCssClass(singlePaneClass)
312318 } else {
313319 wv.container.RemoveCssClass(singlePaneClass)
@@ -297,17 +297,23 @@ func (wv *WorkspaceView) PaneCount() int {
297 return len(wv.paneViews)297 return len(wv.paneViews)
298 }298 }
299 299
300-// singlePaneClass is used to hide the active pane border when only one pane exists.300+// singlePaneClass is used to hide the active pane border when only one visible area exists.
301 const singlePaneClass = "single-pane"301 const singlePaneClass = "single-pane"
302 302
303 // updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container.303 // updateSinglePaneModeInternal adds/removes the single-pane CSS class on the container.
304+// Uses VisibleAreaCount from the domain model: stacked panes count as 1 visible area.
304 // Must be called with the lock held.305 // Must be called with the lock held.
305 func (wv *WorkspaceView) updateSinglePaneModeInternal() {306 func (wv *WorkspaceView) updateSinglePaneModeInternal() {
306 if wv.container == nil {307 if wv.container == nil {
307 return308 return
308 }309 }
309 310
310- if len(wv.paneViews) <= 1 {311+ visibleAreas := 0
312+ if wv.workspace != nil {
313+ visibleAreas = wv.workspace.VisibleAreaCount()
314+ }
315+
316+ if visibleAreas <= 1 {
311 wv.container.AddCssClass(singlePaneClass)317 wv.container.AddCssClass(singlePaneClass)
312 } else {318 } else {
313 wv.container.RemoveCssClass(singlePaneClass)319 wv.container.RemoveCssClass(singlePaneClass)
M internal/ui/coordinator/navigation.go +20 -0
@@ -200,6 +200,26 @@ func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error {
200200 return fmt.Errorf("webview does not support devtools")
201201 }
202202
203+// PrintPage opens the print dialog for the active WebView.
204+func (c *NavigationCoordinator) PrintPage(ctx context.Context) error {
205+ log := logging.FromContext(ctx)
206+
207+ wv := c.contentCoord.ActiveWebView(ctx)
208+ if wv == nil {
209+ log.Warn().Msg("no active webview for print")
210+ return fmt.Errorf("no active webview")
211+ }
212+
213+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening print dialog")
214+
215+ // Type assert to access Print (not in port.WebView interface)
216+ if webkitWV, ok := interface{}(wv).(*webkit.WebView); ok {
217+ return webkitWV.Print()
218+ }
219+
220+ return fmt.Errorf("webview does not support printing")
221+}
222+
203223 // UpdateHistoryTitle updates the title of a history entry after page load.
204224 func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) {
205225 log := logging.FromContext(ctx)
@@ -200,6 +200,26 @@ func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error {
200 return fmt.Errorf("webview does not support devtools")200 return fmt.Errorf("webview does not support devtools")
201 }201 }
202 202
203+// PrintPage opens the print dialog for the active WebView.
204+func (c *NavigationCoordinator) PrintPage(ctx context.Context) error {
205+ log := logging.FromContext(ctx)
206+
207+ wv := c.contentCoord.ActiveWebView(ctx)
208+ if wv == nil {
209+ log.Warn().Msg("no active webview for print")
210+ return fmt.Errorf("no active webview")
211+ }
212+
213+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening print dialog")
214+
215+ // Type assert to access Print (not in port.WebView interface)
216+ if webkitWV, ok := interface{}(wv).(*webkit.WebView); ok {
217+ return webkitWV.Print()
218+ }
219+
220+ return fmt.Errorf("webview does not support printing")
221+}
222+
203 // UpdateHistoryTitle updates the title of a history entry after page load.223 // UpdateHistoryTitle updates the title of a history entry after page load.
204 func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) {224 func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) {
205 log := logging.FromContext(ctx)225 log := logging.FromContext(ctx)
M internal/ui/dispatcher/keyboard.go +1 -0
@@ -179,6 +179,7 @@ func (d *KeyboardDispatcher) initActionHandlers() {
179179 input.ActionGoForward: d.navCoord.GoForward,
180180 input.ActionReload: d.navCoord.Reload,
181181 input.ActionHardReload: d.navCoord.HardReload,
182+ input.ActionPrintPage: d.navCoord.PrintPage,
182183 // Zoom actions
183184 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
184185 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
@@ -179,6 +179,7 @@ func (d *KeyboardDispatcher) initActionHandlers() {
179 input.ActionGoForward: d.navCoord.GoForward,179 input.ActionGoForward: d.navCoord.GoForward,
180 input.ActionReload: d.navCoord.Reload,180 input.ActionReload: d.navCoord.Reload,
181 input.ActionHardReload: d.navCoord.HardReload,181 input.ActionHardReload: d.navCoord.HardReload,
182+ input.ActionPrintPage: d.navCoord.PrintPage,
182 // Zoom actions183 // Zoom actions
183 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },184 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
184 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },185 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
M internal/ui/input/shortcuts.go +6 -2
@@ -170,6 +170,7 @@ const (
170170 ActionReload Action = "reload"
171171 ActionHardReload Action = "hard_reload"
172172 ActionStop Action = "stop"
173+ ActionPrintPage Action = "print_page"
173174
174175 // Zoom
175176 ActionZoomIn Action = "zoom_in"
@@ -360,8 +361,8 @@ func (s *ShortcutSet) registerStandardShortcuts() {
360361 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
361362 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
362363 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
363- s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionGoBack
364- s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionGoForward
364+ s.Global[KeyBinding{uint(gdk.KEY_Left), ModCtrl}] = ActionGoBack
365+ s.Global[KeyBinding{uint(gdk.KEY_Right), ModCtrl}] = ActionGoForward
365366 s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn
366367 s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed)
367368 s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut
@@ -369,6 +370,7 @@ func (s *ShortcutSet) registerStandardShortcuts() {
369370 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
370371 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
371372 s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL
373+ s.Global[KeyBinding{uint('p'), ModCtrl | ModShift}] = ActionPrintPage
372374 // Session management - direct shortcut to open session manager
373375 s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
374376 }
@@ -379,6 +381,8 @@ func (s *ShortcutSet) registerPaneNavigationShortcuts() {
379381 s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp
380382 s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown
381383
384+ s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionFocusLeft
385+ s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionFocusRight
382386 s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp
383387 s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown
384388 }
@@ -170,6 +170,7 @@ const (
170 ActionReload Action = "reload"170 ActionReload Action = "reload"
171 ActionHardReload Action = "hard_reload"171 ActionHardReload Action = "hard_reload"
172 ActionStop Action = "stop"172 ActionStop Action = "stop"
173+ ActionPrintPage Action = "print_page"
173 174
174 // Zoom175 // Zoom
175 ActionZoomIn Action = "zoom_in"176 ActionZoomIn Action = "zoom_in"
@@ -360,8 +361,8 @@ func (s *ShortcutSet) registerStandardShortcuts() {
360 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload361 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
361 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload362 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
362 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools363 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
363- s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionGoBack364+ s.Global[KeyBinding{uint(gdk.KEY_Left), ModCtrl}] = ActionGoBack
364- s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionGoForward365+ s.Global[KeyBinding{uint(gdk.KEY_Right), ModCtrl}] = ActionGoForward
365 s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn366 s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn
366 s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed)367 s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed)
367 s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut368 s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut
@@ -369,6 +370,7 @@ func (s *ShortcutSet) registerStandardShortcuts() {
369 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit370 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
370 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen371 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
371 s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL372 s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL
373+ s.Global[KeyBinding{uint('p'), ModCtrl | ModShift}] = ActionPrintPage
372 // Session management - direct shortcut to open session manager374 // Session management - direct shortcut to open session manager
373 s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager375 s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
374 }376 }
@@ -379,6 +381,8 @@ func (s *ShortcutSet) registerPaneNavigationShortcuts() {
379 s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp381 s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp
380 s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown382 s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown
381 383
384+ s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionFocusLeft
385+ s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionFocusRight
382 s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp386 s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp
383 s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown387 s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown
384 }388 }

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