detached 26237b4

Merge pull request #117 from bnema/next

Release v0.25.0
commit
26237b46de3417249ebd9497616121317defb8b7
author
bnema · · 2026-01-08 17:49 UTC
committer
GitHub · · 2026-01-08 17:49 UTC
merge of
a261e52 + 987d6fc · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
48 files · +1552 -925
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 +22 -0
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+### Added
8+- **File download handling**: Downloads from websites now save to the user's download directory with toast notifications for start/complete/failed states. Configurable via `downloads.path` (defaults to `$XDG_DOWNLOAD_DIR` or `~/Downloads`).
9+- **Print shortcut**: `Ctrl+Shift+P` opens the print dialog for the current page. Fixes #99.
10+
11+### Changed
12+- **Flatpak files location**: Moved flatpak-related files to `flatpak/` directory. Fixes #101.
13+- **Navigation shortcuts**: `Ctrl+Left/Right` now triggers back/forward navigation, `Alt+Left/Right` moves focus between panes. Fixes #98.
14+- **Update notification**: Simplified update toaster message for AUR/Flatpak users - no longer shows "manual install required". Fixes #109.
15+
16+### Fixed
17+- **SPA mouse navigation**: Fixed unreliable back/forward mouse button navigation in Single Page Applications. WebKit's `BackForwardList` doesn't track JavaScript History API (`pushState`/`replaceState`) navigation. Now uses JavaScript `history.back()`/`history.forward()` as fallback when WebKit native navigation is unavailable.
18+- **Accent picker freeze**: Fixed UI freeze when selecting an accent by pressing Enter or Escape. The deadlock was caused by the callback (which calls `Hide()`) being invoked while holding the mutex that `Hide()` also needs to acquire.
19+- **Stacked pane title mismatch**: Fixed title bar showing wrong page title after closing panes in a stack. Refactored stacked pane management to use proper use cases, ensuring domain model stays in sync with UI.
20+- **Flatpak favicons**: Fixed favicons not displaying in omnibox for Flatpak installs by ensuring cache directory exists before saving. Fixes #105.
21+- **Flatpak desktop launcher**: Fixed .desktop file not launching application from app launchers by adding locale environment variables. Fixes #104.
22+- **Flatpak update notifications**: Skip GitHub update checks for Flatpak and AUR installs (use package manager instead). Fixes #107.
23+- **Active border with stacked panes**: Hide active pane border when only one pane area is visible (stacked panes count as one). Fixes #108.
24+- **Setup command for AUR**: `dumber setup` now detects AUR and Flatpak installations and locates the correct .desktop file. Fixes #102.
25+
26+### Removed
27+- **dev/ directory**: Removed development scripts directory. Fixes #103.
28+
729 ## [0.24.0] - 2026-01-06
830
931 ### Added
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
7+### Added
8+- **File download handling**: Downloads from websites now save to the user's download directory with toast notifications for start/complete/failed states. Configurable via `downloads.path` (defaults to `$XDG_DOWNLOAD_DIR` or `~/Downloads`).
9+- **Print shortcut**: `Ctrl+Shift+P` opens the print dialog for the current page. Fixes #99.
10+
11+### Changed
12+- **Flatpak files location**: Moved flatpak-related files to `flatpak/` directory. Fixes #101.
13+- **Navigation shortcuts**: `Ctrl+Left/Right` now triggers back/forward navigation, `Alt+Left/Right` moves focus between panes. Fixes #98.
14+- **Update notification**: Simplified update toaster message for AUR/Flatpak users - no longer shows "manual install required". Fixes #109.
15+
16+### Fixed
17+- **SPA mouse navigation**: Fixed unreliable back/forward mouse button navigation in Single Page Applications. WebKit's `BackForwardList` doesn't track JavaScript History API (`pushState`/`replaceState`) navigation. Now uses JavaScript `history.back()`/`history.forward()` as fallback when WebKit native navigation is unavailable.
18+- **Accent picker freeze**: Fixed UI freeze when selecting an accent by pressing Enter or Escape. The deadlock was caused by the callback (which calls `Hide()`) being invoked while holding the mutex that `Hide()` also needs to acquire.
19+- **Stacked pane title mismatch**: Fixed title bar showing wrong page title after closing panes in a stack. Refactored stacked pane management to use proper use cases, ensuring domain model stays in sync with UI.
20+- **Flatpak favicons**: Fixed favicons not displaying in omnibox for Flatpak installs by ensuring cache directory exists before saving. Fixes #105.
21+- **Flatpak desktop launcher**: Fixed .desktop file not launching application from app launchers by adding locale environment variables. Fixes #104.
22+- **Flatpak update notifications**: Skip GitHub update checks for Flatpak and AUR installs (use package manager instead). Fixes #107.
23+- **Active border with stacked panes**: Hide active pane border when only one pane area is visible (stacked panes count as one). Fixes #108.
24+- **Setup command for AUR**: `dumber setup` now detects AUR and Flatpak installations and locates the correct .desktop file. Fixes #102.
25+
26+### Removed
27+- **dev/ directory**: Removed development scripts directory. Fixes #103.
28+
7 ## [0.24.0] - 2026-01-0629 ## [0.24.0] - 2026-01-06
8 30
9 ### Added31 ### 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 }
M cmd/dumber/main.go +2 -0
@@ -25,6 +25,7 @@ import (
2525 "github.com/bnema/dumber/internal/infrastructure/idle"
2626 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
2727 "github.com/bnema/dumber/internal/infrastructure/updater"
28+ "github.com/bnema/dumber/internal/infrastructure/xdg"
2829 "github.com/bnema/dumber/internal/logging"
2930 "github.com/bnema/dumber/internal/ui"
3031 "github.com/bnema/dumber/internal/ui/theme"
@@ -428,6 +429,7 @@ func buildUIDependencies(
428429 Theme: themeManager,
429430 ColorResolver: colorResolver,
430431 AdwaitaDetector: adwaitaDetector,
432+ XDG: xdg.New(),
431433 WebContext: stack.Context,
432434 Pool: stack.Pool,
433435 Settings: stack.Settings,
@@ -25,6 +25,7 @@ import (
25 "github.com/bnema/dumber/internal/infrastructure/idle"25 "github.com/bnema/dumber/internal/infrastructure/idle"
26 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"26 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
27 "github.com/bnema/dumber/internal/infrastructure/updater"27 "github.com/bnema/dumber/internal/infrastructure/updater"
28+ "github.com/bnema/dumber/internal/infrastructure/xdg"
28 "github.com/bnema/dumber/internal/logging"29 "github.com/bnema/dumber/internal/logging"
29 "github.com/bnema/dumber/internal/ui"30 "github.com/bnema/dumber/internal/ui"
30 "github.com/bnema/dumber/internal/ui/theme"31 "github.com/bnema/dumber/internal/ui/theme"
@@ -428,6 +429,7 @@ func buildUIDependencies(
428 Theme: themeManager,429 Theme: themeManager,
429 ColorResolver: colorResolver,430 ColorResolver: colorResolver,
430 AdwaitaDetector: adwaitaDetector,431 AdwaitaDetector: adwaitaDetector,
432+ XDG: xdg.New(),
431 WebContext: stack.Context,433 WebContext: stack.Context,
432 Pool: stack.Pool,434 Pool: stack.Pool,
433 Settings: stack.Settings,435 Settings: stack.Settings,
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
M docs/CONFIG.md +17 -0
@@ -597,6 +597,23 @@ webview_pool_prewarm_count = 6
597597
598598 > **Important:** Individual tuning fields are ignored unless `profile = "custom"`. Setting individual fields with any other profile will produce a validation warning.
599599
600+## Downloads
601+
602+| Key | Type | Default | Description |
603+|-----|------|---------|-------------|
604+| `downloads.path` | string | `""` | Download directory path (empty = `$XDG_DOWNLOAD_DIR` or `~/Downloads`) |
605+
606+Downloads are saved to the configured directory with toast notifications for download started, completed, and failed events.
607+
608+**Example:**
609+```toml
610+[downloads]
611+path = "" # Use system default ($XDG_DOWNLOAD_DIR or ~/Downloads)
612+
613+# Or specify a custom directory:
614+# path = "/home/user/my-downloads"
615+```
616+
600617 ## Environment Variables
601618
602619 All config values can be overridden via environment variables with the prefix `DUMBER_`:
@@ -597,6 +597,23 @@ webview_pool_prewarm_count = 6
597 597
598 > **Important:** Individual tuning fields are ignored unless `profile = "custom"`. Setting individual fields with any other profile will produce a validation warning.598 > **Important:** Individual tuning fields are ignored unless `profile = "custom"`. Setting individual fields with any other profile will produce a validation warning.
599 599
600+## Downloads
601+
602+| Key | Type | Default | Description |
603+|-----|------|---------|-------------|
604+| `downloads.path` | string | `""` | Download directory path (empty = `$XDG_DOWNLOAD_DIR` or `~/Downloads`) |
605+
606+Downloads are saved to the configured directory with toast notifications for download started, completed, and failed events.
607+
608+**Example:**
609+```toml
610+[downloads]
611+path = "" # Use system default ($XDG_DOWNLOAD_DIR or ~/Downloads)
612+
613+# Or specify a custom directory:
614+# path = "/home/user/my-downloads"
615+```
616+
600 ## Environment Variables617 ## Environment Variables
601 618
602 All config values can be overridden via environment variables with the prefix `DUMBER_`:619 All config values can be overridden via environment variables with the prefix `DUMBER_`:
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

A internal/application/port/download.go +28 -0
@@ -0,0 +1,28 @@
1+package port
2+
3+import "context"
4+
5+// DownloadEventType represents the type of download event.
6+type DownloadEventType int
7+
8+const (
9+ // DownloadEventStarted indicates a download has begun.
10+ DownloadEventStarted DownloadEventType = iota
11+ // DownloadEventFinished indicates a download completed successfully.
12+ DownloadEventFinished
13+ // DownloadEventFailed indicates a download failed.
14+ DownloadEventFailed
15+)
16+
17+// DownloadEvent contains information about a download event.
18+type DownloadEvent struct {
19+ Type DownloadEventType
20+ Filename string
21+ Destination string
22+ Error error // Set when Type is DownloadEventFailed
23+}
24+
25+// DownloadEventHandler receives download event notifications.
26+type DownloadEventHandler interface {
27+ OnDownloadEvent(ctx context.Context, event DownloadEvent)
28+}
@@ -0,0 +1,28 @@
1+package port
2+
3+import "context"
4+
5+// DownloadEventType represents the type of download event.
6+type DownloadEventType int
7+
8+const (
9+ // DownloadEventStarted indicates a download has begun.
10+ DownloadEventStarted DownloadEventType = iota
11+ // DownloadEventFinished indicates a download completed successfully.
12+ DownloadEventFinished
13+ // DownloadEventFailed indicates a download failed.
14+ DownloadEventFailed
15+)
16+
17+// DownloadEvent contains information about a download event.
18+type DownloadEvent struct {
19+ Type DownloadEventType
20+ Filename string
21+ Destination string
22+ Error error // Set when Type is DownloadEventFailed
23+}
24+
25+// DownloadEventHandler receives download event notifications.
26+type DownloadEventHandler interface {
27+ OnDownloadEvent(ctx context.Context, event DownloadEvent)
28+}
M internal/application/port/webview.go +2 -2
@@ -143,11 +143,11 @@ type WebView interface {
143143 Stop(ctx context.Context) error
144144
145145 // GoBack navigates back in history.
146- // Returns error if back navigation is not possible.
146+ // Uses WebKit native navigation if available, falls back to JavaScript history.back() for SPA compatibility.
147147 GoBack(ctx context.Context) error
148148
149149 // GoForward navigates forward in history.
150- // Returns error if forward navigation is not possible.
150+ // Uses WebKit native navigation if available, falls back to JavaScript history.forward() for SPA compatibility.
151151 GoForward(ctx context.Context) error
152152
153153 // --- State Queries ---
@@ -143,11 +143,11 @@ type WebView interface {
143 Stop(ctx context.Context) error143 Stop(ctx context.Context) error
144 144
145 // GoBack navigates back in history.145 // GoBack navigates back in history.
146- // Returns error if back navigation is not possible.146+ // Uses WebKit native navigation if available, falls back to JavaScript history.back() for SPA compatibility.
147 GoBack(ctx context.Context) error147 GoBack(ctx context.Context) error
148 148
149 // GoForward navigates forward in history.149 // GoForward navigates forward in history.
150- // Returns error if forward navigation is not possible.150+ // Uses WebKit native navigation if available, falls back to JavaScript history.forward() for SPA compatibility.
151 GoForward(ctx context.Context) error151 GoForward(ctx context.Context) error
152 152
153 // --- State Queries ---153 // --- State Queries ---
M internal/application/port/xdg.go +4 -0
@@ -14,4 +14,8 @@ type XDGPaths interface {
1414 // ManDir returns the user man page directory (man1 section).
1515 // Typically $XDG_DATA_HOME/man/man1 or ~/.local/share/man/man1.
1616 ManDir() (string, error)
17+
18+ // DownloadDir returns the user's download directory.
19+ // Typically $XDG_DOWNLOAD_DIR or ~/Downloads.
20+ DownloadDir() (string, error)
1721 }
@@ -14,4 +14,8 @@ type XDGPaths interface {
14 // ManDir returns the user man page directory (man1 section).14 // ManDir returns the user man page directory (man1 section).
15 // Typically $XDG_DATA_HOME/man/man1 or ~/.local/share/man/man1.15 // Typically $XDG_DATA_HOME/man/man1 or ~/.local/share/man/man1.
16 ManDir() (string, error)16 ManDir() (string, error)
17+
18+ // DownloadDir returns the user's download directory.
19+ // Typically $XDG_DOWNLOAD_DIR or ~/Downloads.
20+ DownloadDir() (string, error)
17 }21 }
M internal/application/usecase/manage_panes.go +95 -1
@@ -455,6 +455,7 @@ func clampFloat64(v, minVal, maxVal float64) float64 {
455455
456456 // Close removes a pane and promotes its sibling.
457457 // Returns the sibling node that was promoted, or nil if this was the last pane.
458+// For stacked panes, removes the pane from the stack via RemoveFromStack.
458459 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
459460 log := logging.FromContext(ctx)
460461 if uc == nil {
@@ -482,7 +483,12 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p
482483 return nil, nil
483484 }
484485
485- // Find sibling
486+ // Handle stacked panes differently - they can have more than 2 children
487+ if parent.IsStacked {
488+ return uc.closeStackedPane(ctx, ws, paneNode, parent)
489+ }
490+
491+ // Binary split: find sibling (the other child of parent)
486492 var sibling *entity.PaneNode
487493 for _, child := range parent.Children {
488494 if child != paneNode {
@@ -539,6 +545,94 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p
539545 return sibling, nil
540546 }
541547
548+// closeStackedPane handles closing a pane within a stack.
549+// If multiple panes remain, removes the pane from the stack.
550+// If only one pane would remain, dissolves the stack.
551+func (uc *ManagePanesUseCase) closeStackedPane(
552+ ctx context.Context,
553+ ws *entity.Workspace,
554+ paneNode *entity.PaneNode,
555+ stackNode *entity.PaneNode,
556+) (*entity.PaneNode, error) {
557+ log := logging.FromContext(ctx)
558+
559+ paneID := paneNode.Pane.ID
560+
561+ // If more than 2 panes in stack, just remove this one
562+ if len(stackNode.Children) > 2 {
563+ if err := uc.RemoveFromStack(ctx, stackNode, paneID); err != nil {
564+ return nil, fmt.Errorf("failed to remove pane from stack: %w", err)
565+ }
566+
567+ // Update active pane if we closed the active one
568+ if ws.ActivePaneID == paneID {
569+ // Use the pane at the current active index
570+ if stackNode.ActiveStackIndex >= 0 && stackNode.ActiveStackIndex < len(stackNode.Children) {
571+ activeChild := stackNode.Children[stackNode.ActiveStackIndex]
572+ if activeChild.Pane != nil {
573+ ws.ActivePaneID = activeChild.Pane.ID
574+ }
575+ }
576+ }
577+
578+ log.Info().
579+ Str("pane_id", string(paneID)).
580+ Int("remaining", len(stackNode.Children)).
581+ Msg("pane removed from stack")
582+
583+ return stackNode, nil
584+ }
585+
586+ // If exactly 2 panes, remove this one and dissolve the stack
587+ if len(stackNode.Children) == 2 {
588+ // Find the remaining pane
589+ var remaining *entity.PaneNode
590+ for _, child := range stackNode.Children {
591+ if child.Pane != nil && child.Pane.ID != paneID {
592+ remaining = child
593+ break
594+ }
595+ }
596+
597+ if remaining == nil {
598+ return nil, fmt.Errorf("no remaining pane found in stack")
599+ }
600+
601+ // Dissolve the stack: promote remaining pane to stack's position
602+ grandparent := stackNode.Parent
603+
604+ if grandparent == nil {
605+ // Stack was root, remaining becomes new root
606+ ws.Root = remaining
607+ remaining.Parent = nil
608+ } else {
609+ // Replace stack with remaining in grandparent
610+ for i, child := range grandparent.Children {
611+ if child == stackNode {
612+ grandparent.Children[i] = remaining
613+ break
614+ }
615+ }
616+ remaining.Parent = grandparent
617+ }
618+
619+ // Update active pane if we closed the active one
620+ if ws.ActivePaneID == paneID && remaining.Pane != nil {
621+ ws.ActivePaneID = remaining.Pane.ID
622+ }
623+
624+ log.Info().
625+ Str("closed_pane_id", string(paneID)).
626+ Str("remaining_pane_id", remaining.ID).
627+ Msg("stack dissolved, remaining pane promoted")
628+
629+ return remaining, nil
630+ }
631+
632+ // Should not happen: stack with less than 2 children
633+ return nil, fmt.Errorf("invalid stack: has %d children", len(stackNode.Children))
634+}
635+
542636 // Focus sets the active pane in the workspace.
543637 // Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane.
544638 func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
@@ -455,6 +455,7 @@ func clampFloat64(v, minVal, maxVal float64) float64 {
455 455
456 // Close removes a pane and promotes its sibling.456 // Close removes a pane and promotes its sibling.
457 // Returns the sibling node that was promoted, or nil if this was the last pane.457 // Returns the sibling node that was promoted, or nil if this was the last pane.
458+// For stacked panes, removes the pane from the stack via RemoveFromStack.
458 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {459 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
459 log := logging.FromContext(ctx)460 log := logging.FromContext(ctx)
460 if uc == nil {461 if uc == nil {
@@ -482,7 +483,12 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p
482 return nil, nil483 return nil, nil
483 }484 }
484 485
485- // Find sibling486+ // Handle stacked panes differently - they can have more than 2 children
487+ if parent.IsStacked {
488+ return uc.closeStackedPane(ctx, ws, paneNode, parent)
489+ }
490+
491+ // Binary split: find sibling (the other child of parent)
486 var sibling *entity.PaneNode492 var sibling *entity.PaneNode
487 for _, child := range parent.Children {493 for _, child := range parent.Children {
488 if child != paneNode {494 if child != paneNode {
@@ -539,6 +545,94 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p
539 return sibling, nil545 return sibling, nil
540 }546 }
541 547
548+// closeStackedPane handles closing a pane within a stack.
549+// If multiple panes remain, removes the pane from the stack.
550+// If only one pane would remain, dissolves the stack.
551+func (uc *ManagePanesUseCase) closeStackedPane(
552+ ctx context.Context,
553+ ws *entity.Workspace,
554+ paneNode *entity.PaneNode,
555+ stackNode *entity.PaneNode,
556+) (*entity.PaneNode, error) {
557+ log := logging.FromContext(ctx)
558+
559+ paneID := paneNode.Pane.ID
560+
561+ // If more than 2 panes in stack, just remove this one
562+ if len(stackNode.Children) > 2 {
563+ if err := uc.RemoveFromStack(ctx, stackNode, paneID); err != nil {
564+ return nil, fmt.Errorf("failed to remove pane from stack: %w", err)
565+ }
566+
567+ // Update active pane if we closed the active one
568+ if ws.ActivePaneID == paneID {
569+ // Use the pane at the current active index
570+ if stackNode.ActiveStackIndex >= 0 && stackNode.ActiveStackIndex < len(stackNode.Children) {
571+ activeChild := stackNode.Children[stackNode.ActiveStackIndex]
572+ if activeChild.Pane != nil {
573+ ws.ActivePaneID = activeChild.Pane.ID
574+ }
575+ }
576+ }
577+
578+ log.Info().
579+ Str("pane_id", string(paneID)).
580+ Int("remaining", len(stackNode.Children)).
581+ Msg("pane removed from stack")
582+
583+ return stackNode, nil
584+ }
585+
586+ // If exactly 2 panes, remove this one and dissolve the stack
587+ if len(stackNode.Children) == 2 {
588+ // Find the remaining pane
589+ var remaining *entity.PaneNode
590+ for _, child := range stackNode.Children {
591+ if child.Pane != nil && child.Pane.ID != paneID {
592+ remaining = child
593+ break
594+ }
595+ }
596+
597+ if remaining == nil {
598+ return nil, fmt.Errorf("no remaining pane found in stack")
599+ }
600+
601+ // Dissolve the stack: promote remaining pane to stack's position
602+ grandparent := stackNode.Parent
603+
604+ if grandparent == nil {
605+ // Stack was root, remaining becomes new root
606+ ws.Root = remaining
607+ remaining.Parent = nil
608+ } else {
609+ // Replace stack with remaining in grandparent
610+ for i, child := range grandparent.Children {
611+ if child == stackNode {
612+ grandparent.Children[i] = remaining
613+ break
614+ }
615+ }
616+ remaining.Parent = grandparent
617+ }
618+
619+ // Update active pane if we closed the active one
620+ if ws.ActivePaneID == paneID && remaining.Pane != nil {
621+ ws.ActivePaneID = remaining.Pane.ID
622+ }
623+
624+ log.Info().
625+ Str("closed_pane_id", string(paneID)).
626+ Str("remaining_pane_id", remaining.ID).
627+ Msg("stack dissolved, remaining pane promoted")
628+
629+ return remaining, nil
630+ }
631+
632+ // Should not happen: stack with less than 2 children
633+ return nil, fmt.Errorf("invalid stack: has %d children", len(stackNode.Children))
634+}
635+
542 // Focus sets the active pane in the workspace.636 // Focus sets the active pane in the workspace.
543 // Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane.637 // Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane.
544 func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {638 func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
A internal/application/usecase/manage_panes_close_stack_test.go +251 -0
@@ -0,0 +1,251 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+func TestManagePanesUseCase_CloseStackedPane_RemovesFromStackWithMoreThanTwo(t *testing.T) {
11+ uc := NewManagePanesUseCase(func() string { return "id" })
12+ ctx := context.Background()
13+
14+ // Create a stack with 3 panes
15+ a := leaf("a")
16+ b := leaf("b")
17+ c := leaf("c")
18+ stackNode := stack(a, b, c)
19+ stackNode.ActiveStackIndex = 1 // b is active
20+
21+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
22+
23+ // Close pane b (the middle one)
24+ result, err := uc.Close(ctx, ws, b)
25+ if err != nil {
26+ t.Fatalf("unexpected error: %v", err)
27+ }
28+
29+ // Stack should remain with 2 panes
30+ if !ws.Root.IsStacked {
31+ t.Fatalf("root should still be a stack")
32+ }
33+ if len(ws.Root.Children) != 2 {
34+ t.Fatalf("stack should have 2 children, got %d", len(ws.Root.Children))
35+ }
36+ if got := panesInOrder(ws.Root); got != "a,c" {
37+ t.Fatalf("remaining panes=%s, want a,c", got)
38+ }
39+
40+ // Result should be the stack node
41+ if result != stackNode {
42+ t.Fatalf("expected stack node to be returned")
43+ }
44+}
45+
46+func TestManagePanesUseCase_CloseStackedPane_DissolvesStackWithTwo(t *testing.T) {
47+ uc := NewManagePanesUseCase(func() string { return "id" })
48+ ctx := context.Background()
49+
50+ // Create a stack with 2 panes
51+ a := leaf("a")
52+ b := leaf("b")
53+ stackNode := stack(a, b)
54+ stackNode.ActiveStackIndex = 1 // b is active
55+
56+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
57+
58+ // Close pane b
59+ result, err := uc.Close(ctx, ws, b)
60+ if err != nil {
61+ t.Fatalf("unexpected error: %v", err)
62+ }
63+
64+ // Stack should be dissolved, remaining pane becomes root
65+ if ws.Root.IsStacked {
66+ t.Fatalf("root should no longer be a stack")
67+ }
68+ if !ws.Root.IsLeaf() {
69+ t.Fatalf("root should be a leaf")
70+ }
71+ if ws.Root.Pane.ID != "a" {
72+ t.Fatalf("remaining pane should be a, got %s", ws.Root.Pane.ID)
73+ }
74+
75+ // Result should be the remaining pane
76+ if result.Pane.ID != "a" {
77+ t.Fatalf("expected remaining pane a to be returned")
78+ }
79+
80+ // Active pane should update to remaining pane
81+ if ws.ActivePaneID != "a" {
82+ t.Fatalf("active pane should be a, got %s", ws.ActivePaneID)
83+ }
84+}
85+
86+func TestManagePanesUseCase_CloseStackedPane_UpdatesActivePaneWhenClosingActive(t *testing.T) {
87+ uc := NewManagePanesUseCase(func() string { return "id" })
88+ ctx := context.Background()
89+
90+ // Create a stack with 3 panes, middle one is active
91+ a := leaf("a")
92+ b := leaf("b")
93+ c := leaf("c")
94+ stackNode := stack(a, b, c)
95+ stackNode.ActiveStackIndex = 1 // b is active
96+
97+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
98+
99+ // Close pane b (the active one)
100+ _, err := uc.Close(ctx, ws, b)
101+ if err != nil {
102+ t.Fatalf("unexpected error: %v", err)
103+ }
104+
105+ // Active pane should be updated (to the pane at the new active index)
106+ if ws.ActivePaneID == "b" {
107+ t.Fatalf("active pane should not be the closed pane b")
108+ }
109+ // Should be either a or c depending on new ActiveStackIndex
110+ if ws.ActivePaneID != "a" && ws.ActivePaneID != "c" {
111+ t.Fatalf("active pane should be a or c, got %s", ws.ActivePaneID)
112+ }
113+}
114+
115+func TestManagePanesUseCase_CloseStackedPane_PreservesActivePaneWhenClosingNonActive(t *testing.T) {
116+ uc := NewManagePanesUseCase(func() string { return "id" })
117+ ctx := context.Background()
118+
119+ // Create a stack with 3 panes, first one is active
120+ a := leaf("a")
121+ b := leaf("b")
122+ c := leaf("c")
123+ stackNode := stack(a, b, c)
124+ stackNode.ActiveStackIndex = 0 // a is active
125+
126+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: a.Pane.ID}
127+
128+ // Close pane c (not the active one)
129+ _, err := uc.Close(ctx, ws, c)
130+ if err != nil {
131+ t.Fatalf("unexpected error: %v", err)
132+ }
133+
134+ // Active pane should remain a
135+ if ws.ActivePaneID != "a" {
136+ t.Fatalf("active pane should still be a, got %s", ws.ActivePaneID)
137+ }
138+}
139+
140+func TestManagePanesUseCase_CloseStackedPane_DissolvesNestedStack(t *testing.T) {
141+ uc := NewManagePanesUseCase(func() string { return "id" })
142+ ctx := context.Background()
143+
144+ // Create a split with a stack on the left
145+ a := leaf("a")
146+ b := leaf("b")
147+ stackNode := stack(a, b)
148+ stackNode.ActiveStackIndex = 0
149+
150+ c := leaf("c")
151+ root := split(entity.SplitHorizontal, stackNode, c)
152+
153+ ws := &entity.Workspace{Root: root, ActivePaneID: a.Pane.ID}
154+
155+ // Close pane a from the stack
156+ _, err := uc.Close(ctx, ws, a)
157+ if err != nil {
158+ t.Fatalf("unexpected error: %v", err)
159+ }
160+
161+ // Root should still be a split
162+ if !ws.Root.IsSplit() {
163+ t.Fatalf("root should still be a split")
164+ }
165+
166+ // Left child should now be the remaining pane b (not a stack)
167+ left := ws.Root.Left()
168+ if left.IsStacked {
169+ t.Fatalf("left child should no longer be a stack")
170+ }
171+ if !left.IsLeaf() || left.Pane.ID != "b" {
172+ t.Fatalf("left child should be leaf b, got %v", left)
173+ }
174+
175+ // Right child should still be c
176+ right := ws.Root.Right()
177+ if !right.IsLeaf() || right.Pane.ID != "c" {
178+ t.Fatalf("right child should be leaf c")
179+ }
180+
181+ // Active pane should update to b
182+ if ws.ActivePaneID != "b" {
183+ t.Fatalf("active pane should be b, got %s", ws.ActivePaneID)
184+ }
185+}
186+
187+func TestManagePanesUseCase_CloseStackedPane_ClosingFirstPaneUpdatesIndex(t *testing.T) {
188+ uc := NewManagePanesUseCase(func() string { return "id" })
189+ ctx := context.Background()
190+
191+ // Create a stack with 3 panes, first one is active
192+ a := leaf("a")
193+ b := leaf("b")
194+ c := leaf("c")
195+ stackNode := stack(a, b, c)
196+ stackNode.ActiveStackIndex = 0 // a is active
197+
198+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: a.Pane.ID}
199+
200+ // Close pane a (the first and active one)
201+ _, err := uc.Close(ctx, ws, a)
202+ if err != nil {
203+ t.Fatalf("unexpected error: %v", err)
204+ }
205+
206+ // Stack should have 2 panes remaining
207+ if len(ws.Root.Children) != 2 {
208+ t.Fatalf("stack should have 2 children, got %d", len(ws.Root.Children))
209+ }
210+ if got := panesInOrder(ws.Root); got != "b,c" {
211+ t.Fatalf("remaining panes=%s, want b,c", got)
212+ }
213+
214+ // Active pane should be updated
215+ if ws.ActivePaneID == "a" {
216+ t.Fatalf("active pane should not be the closed pane a")
217+ }
218+}
219+
220+func TestManagePanesUseCase_CloseStackedPane_ClosingLastPaneUpdatesIndex(t *testing.T) {
221+ uc := NewManagePanesUseCase(func() string { return "id" })
222+ ctx := context.Background()
223+
224+ // Create a stack with 3 panes, last one is active
225+ a := leaf("a")
226+ b := leaf("b")
227+ c := leaf("c")
228+ stackNode := stack(a, b, c)
229+ stackNode.ActiveStackIndex = 2 // c is active
230+
231+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: c.Pane.ID}
232+
233+ // Close pane c (the last and active one)
234+ _, err := uc.Close(ctx, ws, c)
235+ if err != nil {
236+ t.Fatalf("unexpected error: %v", err)
237+ }
238+
239+ // Stack should have 2 panes remaining
240+ if len(ws.Root.Children) != 2 {
241+ t.Fatalf("stack should have 2 children, got %d", len(ws.Root.Children))
242+ }
243+ if got := panesInOrder(ws.Root); got != "a,b" {
244+ t.Fatalf("remaining panes=%s, want a,b", got)
245+ }
246+
247+ // Active pane should be updated to the new last pane
248+ if ws.ActivePaneID == "c" {
249+ t.Fatalf("active pane should not be the closed pane c")
250+ }
251+}
@@ -0,0 +1,251 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+func TestManagePanesUseCase_CloseStackedPane_RemovesFromStackWithMoreThanTwo(t *testing.T) {
11+ uc := NewManagePanesUseCase(func() string { return "id" })
12+ ctx := context.Background()
13+
14+ // Create a stack with 3 panes
15+ a := leaf("a")
16+ b := leaf("b")
17+ c := leaf("c")
18+ stackNode := stack(a, b, c)
19+ stackNode.ActiveStackIndex = 1 // b is active
20+
21+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
22+
23+ // Close pane b (the middle one)
24+ result, err := uc.Close(ctx, ws, b)
25+ if err != nil {
26+ t.Fatalf("unexpected error: %v", err)
27+ }
28+
29+ // Stack should remain with 2 panes
30+ if !ws.Root.IsStacked {
31+ t.Fatalf("root should still be a stack")
32+ }
33+ if len(ws.Root.Children) != 2 {
34+ t.Fatalf("stack should have 2 children, got %d", len(ws.Root.Children))
35+ }
36+ if got := panesInOrder(ws.Root); got != "a,c" {
37+ t.Fatalf("remaining panes=%s, want a,c", got)
38+ }
39+
40+ // Result should be the stack node
41+ if result != stackNode {
42+ t.Fatalf("expected stack node to be returned")
43+ }
44+}
45+
46+func TestManagePanesUseCase_CloseStackedPane_DissolvesStackWithTwo(t *testing.T) {
47+ uc := NewManagePanesUseCase(func() string { return "id" })
48+ ctx := context.Background()
49+
50+ // Create a stack with 2 panes
51+ a := leaf("a")
52+ b := leaf("b")
53+ stackNode := stack(a, b)
54+ stackNode.ActiveStackIndex = 1 // b is active
55+
56+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
57+
58+ // Close pane b
59+ result, err := uc.Close(ctx, ws, b)
60+ if err != nil {
61+ t.Fatalf("unexpected error: %v", err)
62+ }
63+
64+ // Stack should be dissolved, remaining pane becomes root
65+ if ws.Root.IsStacked {
66+ t.Fatalf("root should no longer be a stack")
67+ }
68+ if !ws.Root.IsLeaf() {
69+ t.Fatalf("root should be a leaf")
70+ }
71+ if ws.Root.Pane.ID != "a" {
72+ t.Fatalf("remaining pane should be a, got %s", ws.Root.Pane.ID)
73+ }
74+
75+ // Result should be the remaining pane
76+ if result.Pane.ID != "a" {
77+ t.Fatalf("expected remaining pane a to be returned")
78+ }
79+
80+ // Active pane should update to remaining pane
81+ if ws.ActivePaneID != "a" {
82+ t.Fatalf("active pane should be a, got %s", ws.ActivePaneID)
83+ }
84+}
85+
86+func TestManagePanesUseCase_CloseStackedPane_UpdatesActivePaneWhenClosingActive(t *testing.T) {
87+ uc := NewManagePanesUseCase(func() string { return "id" })
88+ ctx := context.Background()
89+
90+ // Create a stack with 3 panes, middle one is active
91+ a := leaf("a")
92+ b := leaf("b")
93+ c := leaf("c")
94+ stackNode := stack(a, b, c)
95+ stackNode.ActiveStackIndex = 1 // b is active
96+
97+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
98+
99+ // Close pane b (the active one)
100+ _, err := uc.Close(ctx, ws, b)
101+ if err != nil {
102+ t.Fatalf("unexpected error: %v", err)
103+ }
104+
105+ // Active pane should be updated (to the pane at the new active index)
106+ if ws.ActivePaneID == "b" {
107+ t.Fatalf("active pane should not be the closed pane b")
108+ }
109+ // Should be either a or c depending on new ActiveStackIndex
110+ if ws.ActivePaneID != "a" && ws.ActivePaneID != "c" {
111+ t.Fatalf("active pane should be a or c, got %s", ws.ActivePaneID)
112+ }
113+}
114+
115+func TestManagePanesUseCase_CloseStackedPane_PreservesActivePaneWhenClosingNonActive(t *testing.T) {
116+ uc := NewManagePanesUseCase(func() string { return "id" })
117+ ctx := context.Background()
118+
119+ // Create a stack with 3 panes, first one is active
120+ a := leaf("a")
121+ b := leaf("b")
122+ c := leaf("c")
123+ stackNode := stack(a, b, c)
124+ stackNode.ActiveStackIndex = 0 // a is active
125+
126+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: a.Pane.ID}
127+
128+ // Close pane c (not the active one)
129+ _, err := uc.Close(ctx, ws, c)
130+ if err != nil {
131+ t.Fatalf("unexpected error: %v", err)
132+ }
133+
134+ // Active pane should remain a
135+ if ws.ActivePaneID != "a" {
136+ t.Fatalf("active pane should still be a, got %s", ws.ActivePaneID)
137+ }
138+}
139+
140+func TestManagePanesUseCase_CloseStackedPane_DissolvesNestedStack(t *testing.T) {
141+ uc := NewManagePanesUseCase(func() string { return "id" })
142+ ctx := context.Background()
143+
144+ // Create a split with a stack on the left
145+ a := leaf("a")
146+ b := leaf("b")
147+ stackNode := stack(a, b)
148+ stackNode.ActiveStackIndex = 0
149+
150+ c := leaf("c")
151+ root := split(entity.SplitHorizontal, stackNode, c)
152+
153+ ws := &entity.Workspace{Root: root, ActivePaneID: a.Pane.ID}
154+
155+ // Close pane a from the stack
156+ _, err := uc.Close(ctx, ws, a)
157+ if err != nil {
158+ t.Fatalf("unexpected error: %v", err)
159+ }
160+
161+ // Root should still be a split
162+ if !ws.Root.IsSplit() {
163+ t.Fatalf("root should still be a split")
164+ }
165+
166+ // Left child should now be the remaining pane b (not a stack)
167+ left := ws.Root.Left()
168+ if left.IsStacked {
169+ t.Fatalf("left child should no longer be a stack")
170+ }
171+ if !left.IsLeaf() || left.Pane.ID != "b" {
172+ t.Fatalf("left child should be leaf b, got %v", left)
173+ }
174+
175+ // Right child should still be c
176+ right := ws.Root.Right()
177+ if !right.IsLeaf() || right.Pane.ID != "c" {
178+ t.Fatalf("right child should be leaf c")
179+ }
180+
181+ // Active pane should update to b
182+ if ws.ActivePaneID != "b" {
183+ t.Fatalf("active pane should be b, got %s", ws.ActivePaneID)
184+ }
185+}
186+
187+func TestManagePanesUseCase_CloseStackedPane_ClosingFirstPaneUpdatesIndex(t *testing.T) {
188+ uc := NewManagePanesUseCase(func() string { return "id" })
189+ ctx := context.Background()
190+
191+ // Create a stack with 3 panes, first one is active
192+ a := leaf("a")
193+ b := leaf("b")
194+ c := leaf("c")
195+ stackNode := stack(a, b, c)
196+ stackNode.ActiveStackIndex = 0 // a is active
197+
198+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: a.Pane.ID}
199+
200+ // Close pane a (the first and active one)
201+ _, err := uc.Close(ctx, ws, a)
202+ if err != nil {
203+ t.Fatalf("unexpected error: %v", err)
204+ }
205+
206+ // Stack should have 2 panes remaining
207+ if len(ws.Root.Children) != 2 {
208+ t.Fatalf("stack should have 2 children, got %d", len(ws.Root.Children))
209+ }
210+ if got := panesInOrder(ws.Root); got != "b,c" {
211+ t.Fatalf("remaining panes=%s, want b,c", got)
212+ }
213+
214+ // Active pane should be updated
215+ if ws.ActivePaneID == "a" {
216+ t.Fatalf("active pane should not be the closed pane a")
217+ }
218+}
219+
220+func TestManagePanesUseCase_CloseStackedPane_ClosingLastPaneUpdatesIndex(t *testing.T) {
221+ uc := NewManagePanesUseCase(func() string { return "id" })
222+ ctx := context.Background()
223+
224+ // Create a stack with 3 panes, last one is active
225+ a := leaf("a")
226+ b := leaf("b")
227+ c := leaf("c")
228+ stackNode := stack(a, b, c)
229+ stackNode.ActiveStackIndex = 2 // c is active
230+
231+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: c.Pane.ID}
232+
233+ // Close pane c (the last and active one)
234+ _, err := uc.Close(ctx, ws, c)
235+ if err != nil {
236+ t.Fatalf("unexpected error: %v", err)
237+ }
238+
239+ // Stack should have 2 panes remaining
240+ if len(ws.Root.Children) != 2 {
241+ t.Fatalf("stack should have 2 children, got %d", len(ws.Root.Children))
242+ }
243+ if got := panesInOrder(ws.Root); got != "a,b" {
244+ t.Fatalf("remaining panes=%s, want a,b", got)
245+ }
246+
247+ // Active pane should be updated to the new last pane
248+ if ws.ActivePaneID == "c" {
249+ t.Fatalf("active pane should not be the closed pane c")
250+ }
251+}
M internal/application/usecase/navigate.go +0 -26
@@ -240,32 +240,6 @@ func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, byp
240240 return webview.Reload(ctx)
241241 }
242242
243-// GoBack navigates back in history.
244-func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) error {
245- log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
246-
247- if !webview.CanGoBack() {
248- log.Debug().Msg("cannot go back - no history")
249- return nil
250- }
251-
252- log.Debug().Msg("navigating back")
253- return webview.GoBack(ctx)
254-}
255-
256-// GoForward navigates forward in history.
257-func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) error {
258- log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
259-
260- if !webview.CanGoForward() {
261- log.Debug().Msg("cannot go forward - no history")
262- return nil
263- }
264-
265- log.Debug().Msg("navigating forward")
266- return webview.GoForward(ctx)
267-}
268-
269243 // Stop stops the current page load.
270244 func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {
271245 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
@@ -240,32 +240,6 @@ func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, byp
240 return webview.Reload(ctx)240 return webview.Reload(ctx)
241 }241 }
242 242
243-// GoBack navigates back in history.
244-func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) error {
245- log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
246-
247- if !webview.CanGoBack() {
248- log.Debug().Msg("cannot go back - no history")
249- return nil
250- }
251-
252- log.Debug().Msg("navigating back")
253- return webview.GoBack(ctx)
254-}
255-
256-// GoForward navigates forward in history.
257-func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) error {
258- log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
259-
260- if !webview.CanGoForward() {
261- log.Debug().Msg("cannot go forward - no history")
262- return nil
263- }
264-
265- log.Debug().Msg("navigating forward")
266- return webview.GoForward(ctx)
267-}
268-
269 // Stop stops the current page load.243 // Stop stops the current page load.
270 func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {244 func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error {
271 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()245 log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger()
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/cli/styles/config_schema.go +2 -0
@@ -47,9 +47,11 @@ func (r *ConfigSchemaRenderer) Render(keys []entity.ConfigKeyInfo) string {
4747 "Session",
4848 "Omnibox",
4949 "Content Filtering",
50+ "Clipboard",
5051 "Rendering",
5152 "Media",
5253 "Update",
54+ "Downloads",
5355 "Debug",
5456 "Performance",
5557 "Runtime",
@@ -47,9 +47,11 @@ func (r *ConfigSchemaRenderer) Render(keys []entity.ConfigKeyInfo) string {
47 "Session",47 "Session",
48 "Omnibox",48 "Omnibox",
49 "Content Filtering",49 "Content Filtering",
50+ "Clipboard",
50 "Rendering",51 "Rendering",
51 "Media",52 "Media",
52 "Update",53 "Update",
54+ "Downloads",
53 "Debug",55 "Debug",
54 "Performance",56 "Performance",
55 "Runtime",57 "Runtime",
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/defaults.go +3 -0
@@ -311,6 +311,9 @@ func DefaultConfig() *Config {
311311 AutoDownload: false, // Conservative: don't auto-download by default
312312 NotifyOnNewSettings: true, // Show toast when new config settings available
313313 },
314+ Downloads: DownloadsConfig{
315+ Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads
316+ },
314317 }
315318 }
316319
@@ -311,6 +311,9 @@ func DefaultConfig() *Config {
311 AutoDownload: false, // Conservative: don't auto-download by default311 AutoDownload: false, // Conservative: don't auto-download by default
312 NotifyOnNewSettings: true, // Show toast when new config settings available312 NotifyOnNewSettings: true, // Show toast when new config settings available
313 },313 },
314+ Downloads: DownloadsConfig{
315+ Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads
316+ },
314 }317 }
315 }318 }
316 319
M internal/infrastructure/config/loader.go +5 -0
@@ -365,6 +365,7 @@ func (m *Manager) setDefaults() {
365365 m.setSessionDefaults(defaults)
366366 m.setUpdateDefaults(defaults)
367367 m.setPerformanceDefaults(defaults)
368+ m.setDownloadsDefaults(defaults)
368369 }
369370
370371 func (m *Manager) setHistoryDefaults(defaults *Config) {
@@ -533,6 +534,10 @@ func (m *Manager) setPerformanceDefaults(defaults *Config) {
533534 m.viper.SetDefault("performance.network_process_memory_strict_threshold", defaults.Performance.NetworkProcessMemoryStrictThreshold)
534535 }
535536
537+func (m *Manager) setDownloadsDefaults(defaults *Config) {
538+ m.viper.SetDefault("downloads.path", defaults.Downloads.Path)
539+}
540+
536541 // New returns a new default configuration instance.
537542 // This is a convenience function for getting default config without the full manager.
538543 func New() *Config {
@@ -365,6 +365,7 @@ func (m *Manager) setDefaults() {
365 m.setSessionDefaults(defaults)365 m.setSessionDefaults(defaults)
366 m.setUpdateDefaults(defaults)366 m.setUpdateDefaults(defaults)
367 m.setPerformanceDefaults(defaults)367 m.setPerformanceDefaults(defaults)
368+ m.setDownloadsDefaults(defaults)
368 }369 }
369 370
370 func (m *Manager) setHistoryDefaults(defaults *Config) {371 func (m *Manager) setHistoryDefaults(defaults *Config) {
@@ -533,6 +534,10 @@ func (m *Manager) setPerformanceDefaults(defaults *Config) {
533 m.viper.SetDefault("performance.network_process_memory_strict_threshold", defaults.Performance.NetworkProcessMemoryStrictThreshold)534 m.viper.SetDefault("performance.network_process_memory_strict_threshold", defaults.Performance.NetworkProcessMemoryStrictThreshold)
534 }535 }
535 536
537+func (m *Manager) setDownloadsDefaults(defaults *Config) {
538+ m.viper.SetDefault("downloads.path", defaults.Downloads.Path)
539+}
540+
536 // New returns a new default configuration instance.541 // New returns a new default configuration instance.
537 // This is a convenience function for getting default config without the full manager.542 // This is a convenience function for getting default config without the full manager.
538 func New() *Config {543 func New() *Config {
M internal/infrastructure/config/schema.go +9 -0
@@ -35,6 +35,8 @@ type Config struct {
3535 Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"`
3636 // Update controls automatic update checking and downloading.
3737 Update UpdateConfig `mapstructure:"update" yaml:"update" toml:"update"`
38+ // Downloads configures file download behavior.
39+ Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"`
3840 }
3941
4042 // RenderingMode selects GPU vs CPU rendering.
@@ -372,6 +374,13 @@ type UpdateConfig struct {
372374 NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
373375 }
374376
377+// DownloadsConfig holds file download preferences.
378+type DownloadsConfig struct {
379+ // Path is the directory where downloads are saved.
380+ // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.
381+ Path string `mapstructure:"path" yaml:"path" toml:"path"`
382+}
383+
375384 // WorkspaceConfig captures layout, pane, and tab behavior preferences.
376385 type WorkspaceConfig struct {
377386 // NewPaneURL is the URL loaded when creating a new empty pane/tab.
@@ -35,6 +35,8 @@ type Config struct {
35 Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"`35 Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"`
36 // Update controls automatic update checking and downloading.36 // Update controls automatic update checking and downloading.
37 Update UpdateConfig `mapstructure:"update" yaml:"update" toml:"update"`37 Update UpdateConfig `mapstructure:"update" yaml:"update" toml:"update"`
38+ // Downloads configures file download behavior.
39+ Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"`
38 }40 }
39 41
40 // RenderingMode selects GPU vs CPU rendering.42 // RenderingMode selects GPU vs CPU rendering.
@@ -372,6 +374,13 @@ type UpdateConfig struct {
372 NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`374 NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
373 }375 }
374 376
377+// DownloadsConfig holds file download preferences.
378+type DownloadsConfig struct {
379+ // Path is the directory where downloads are saved.
380+ // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.
381+ Path string `mapstructure:"path" yaml:"path" toml:"path"`
382+}
383+
375 // WorkspaceConfig captures layout, pane, and tab behavior preferences.384 // WorkspaceConfig captures layout, pane, and tab behavior preferences.
376 type WorkspaceConfig struct {385 type WorkspaceConfig struct {
377 // NewPaneURL is the URL loaded when creating a new empty pane/tab.386 // NewPaneURL is the URL loaded when creating a new empty pane/tab.
M internal/infrastructure/config/schema_provider.go +16 -0
@@ -25,6 +25,7 @@ const (
2525 SectionRuntime = "Runtime"
2626 SectionDatabase = "Database"
2727 SectionSearch = "Search"
28+ SectionDownloads = "Downloads"
2829 )
2930
3031 // SchemaProvider implements port.ConfigSchemaProvider.
@@ -92,6 +93,9 @@ func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo {
9293 // Database section
9394 keys = append(keys, p.getDatabaseKeys()...)
9495
96+ // Downloads section
97+ keys = append(keys, p.getDownloadsKeys(defaults)...)
98+
9599 return keys
96100 }
97101
@@ -1040,3 +1044,15 @@ func (*SchemaProvider) getDatabaseKeys() []entity.ConfigKeyInfo {
10401044 },
10411045 }
10421046 }
1047+
1048+func (*SchemaProvider) getDownloadsKeys(_ *Config) []entity.ConfigKeyInfo {
1049+ return []entity.ConfigKeyInfo{
1050+ {
1051+ Key: "downloads.path",
1052+ Type: "string",
1053+ Default: "(empty = $XDG_DOWNLOAD_DIR or ~/Downloads)",
1054+ Description: "Directory where downloads are saved",
1055+ Section: SectionDownloads,
1056+ },
1057+ }
1058+}
@@ -25,6 +25,7 @@ const (
25 SectionRuntime = "Runtime"25 SectionRuntime = "Runtime"
26 SectionDatabase = "Database"26 SectionDatabase = "Database"
27 SectionSearch = "Search"27 SectionSearch = "Search"
28+ SectionDownloads = "Downloads"
28 )29 )
29 30
30 // SchemaProvider implements port.ConfigSchemaProvider.31 // SchemaProvider implements port.ConfigSchemaProvider.
@@ -92,6 +93,9 @@ func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo {
92 // Database section93 // Database section
93 keys = append(keys, p.getDatabaseKeys()...)94 keys = append(keys, p.getDatabaseKeys()...)
94 95
96+ // Downloads section
97+ keys = append(keys, p.getDownloadsKeys(defaults)...)
98+
95 return keys99 return keys
96 }100 }
97 101
@@ -1040,3 +1044,15 @@ func (*SchemaProvider) getDatabaseKeys() []entity.ConfigKeyInfo {
1040 },1044 },
1041 }1045 }
1042 }1046 }
1047+
1048+func (*SchemaProvider) getDownloadsKeys(_ *Config) []entity.ConfigKeyInfo {
1049+ return []entity.ConfigKeyInfo{
1050+ {
1051+ Key: "downloads.path",
1052+ Type: "string",
1053+ Default: "(empty = $XDG_DOWNLOAD_DIR or ~/Downloads)",
1054+ Description: "Directory where downloads are saved",
1055+ Section: SectionDownloads,
1056+ },
1057+ }
1058+}
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/context.go +28 -3
@@ -26,9 +26,10 @@ type WebKitContext struct {
2626 dataDir string
2727 cacheDir string
2828
29- logger zerolog.Logger
30- mu sync.RWMutex
31- initialized bool
29+ logger zerolog.Logger
30+ mu sync.RWMutex
31+ initialized bool
32+ downloadHandler *DownloadHandler
3233 }
3334
3435 // NewWebKitContext creates and initializes a WebKitContext with a persistent NetworkSession.
@@ -236,3 +237,27 @@ func (c *WebKitContext) Close() error {
236237 c.logger.Debug().Msg("webkit context closed")
237238 return nil
238239 }
240+
241+// SetDownloadHandler configures download handling for this context.
242+// The handler will be notified of all download events from the NetworkSession.
243+func (c *WebKitContext) SetDownloadHandler(ctx context.Context, handler *DownloadHandler) {
244+ c.mu.Lock()
245+ c.downloadHandler = handler
246+ session := c.networkSession
247+ c.mu.Unlock()
248+
249+ if session == nil || handler == nil {
250+ return
251+ }
252+
253+ // Connect download-started signal on NetworkSession.
254+ downloadStartedCb := func(_ webkit.NetworkSession, downloadPtr uintptr) {
255+ download := webkit.DownloadNewFromInternalPtr(downloadPtr)
256+ if download != nil {
257+ handler.HandleDownload(ctx, download)
258+ }
259+ }
260+ session.ConnectDownloadStarted(&downloadStartedCb)
261+
262+ c.logger.Debug().Msg("download handler connected to network session")
263+}
@@ -26,9 +26,10 @@ type WebKitContext struct {
26 dataDir string26 dataDir string
27 cacheDir string27 cacheDir string
28 28
29- logger zerolog.Logger29+ logger zerolog.Logger
30- mu sync.RWMutex30+ mu sync.RWMutex
31- initialized bool31+ initialized bool
32+ downloadHandler *DownloadHandler
32 }33 }
33 34
34 // NewWebKitContext creates and initializes a WebKitContext with a persistent NetworkSession.35 // NewWebKitContext creates and initializes a WebKitContext with a persistent NetworkSession.
@@ -236,3 +237,27 @@ func (c *WebKitContext) Close() error {
236 c.logger.Debug().Msg("webkit context closed")237 c.logger.Debug().Msg("webkit context closed")
237 return nil238 return nil
238 }239 }
240+
241+// SetDownloadHandler configures download handling for this context.
242+// The handler will be notified of all download events from the NetworkSession.
243+func (c *WebKitContext) SetDownloadHandler(ctx context.Context, handler *DownloadHandler) {
244+ c.mu.Lock()
245+ c.downloadHandler = handler
246+ session := c.networkSession
247+ c.mu.Unlock()
248+
249+ if session == nil || handler == nil {
250+ return
251+ }
252+
253+ // Connect download-started signal on NetworkSession.
254+ downloadStartedCb := func(_ webkit.NetworkSession, downloadPtr uintptr) {
255+ download := webkit.DownloadNewFromInternalPtr(downloadPtr)
256+ if download != nil {
257+ handler.HandleDownload(ctx, download)
258+ }
259+ }
260+ session.ConnectDownloadStarted(&downloadStartedCb)
261+
262+ c.logger.Debug().Msg("download handler connected to network session")
263+}
A internal/infrastructure/webkit/download_handler.go +186 -0
@@ -0,0 +1,186 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "sync"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/bnema/puregotk-webkit/webkit"
14+)
15+
16+const (
17+ // dirPerm is the permission mode for creating download directories.
18+ dirPerm = 0755
19+)
20+
21+// DownloadHandler manages WebKit downloads and notifies the UI layer.
22+type DownloadHandler struct {
23+ downloadPath string
24+ eventHandler port.DownloadEventHandler
25+ mu sync.RWMutex
26+}
27+
28+// NewDownloadHandler creates a new download handler.
29+func NewDownloadHandler(downloadPath string, handler port.DownloadEventHandler) *DownloadHandler {
30+ return &DownloadHandler{
31+ downloadPath: downloadPath,
32+ eventHandler: handler,
33+ }
34+}
35+
36+// downloadState tracks the state of a single download to prevent duplicate notifications.
37+// This is allocated per-download to ensure proper isolation between concurrent downloads.
38+type downloadState struct {
39+ failed bool
40+ mu sync.Mutex
41+}
42+
43+// HandleDownload sets up signal handlers for a new download.
44+func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
45+ log := logging.FromContext(ctx)
46+
47+ h.mu.RLock()
48+ downloadPath := h.downloadPath
49+ eventHandler := h.eventHandler
50+ h.mu.RUnlock()
51+
52+ // Track download state in a dedicated struct to ensure proper isolation.
53+ // Each download gets its own state to prevent interference between concurrent downloads.
54+ state := &downloadState{}
55+
56+ // Handle decide-destination signal to set download path.
57+ //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)
58+ decideDestCb := func(d webkit.Download, suggestedFilename string) bool {
59+ // Ensure download directory exists.
60+ if err := os.MkdirAll(downloadPath, dirPerm); err != nil {
61+ log.Error().Err(err).Str("path", downloadPath).Msg("failed to create download directory")
62+ d.Cancel()
63+ return false
64+ }
65+
66+ // Sanitize filename to prevent path traversal attacks.
67+ safeName := sanitizeFilename(suggestedFilename)
68+ destPath := filepath.Join(downloadPath, safeName)
69+
70+ log.Debug().
71+ Str("suggested", suggestedFilename).
72+ Str("sanitized", safeName).
73+ Str("destPath", destPath).
74+ Msg("setting download destination")
75+
76+ // WebKit expects an absolute file path for the destination.
77+ d.SetDestination(destPath)
78+
79+ // Notify: download started.
80+ if eventHandler != nil {
81+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
82+ Type: port.DownloadEventStarted,
83+ Filename: safeName,
84+ Destination: destPath,
85+ })
86+ }
87+
88+ log.Info().
89+ Str("filename", safeName).
90+ Str("destination", destPath).
91+ Msg("download started")
92+
93+ return false // false = we handled it synchronously
94+ }
95+ download.ConnectDecideDestination(&decideDestCb)
96+
97+ // Handle failed signal.
98+ failedCb := func(d webkit.Download, _ uintptr) {
99+ state.mu.Lock()
100+ state.failed = true
101+ state.mu.Unlock()
102+
103+ dest := d.GetDestination()
104+ filename := extractFilename(dest)
105+
106+ // Create error for the failed download.
107+ downloadErr := fmt.Errorf("download failed: %s", filename)
108+
109+ if eventHandler != nil {
110+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
111+ Type: port.DownloadEventFailed,
112+ Filename: filename,
113+ Destination: dest,
114+ Error: downloadErr,
115+ })
116+ }
117+
118+ log.Warn().Str("filename", filename).Msg("download failed")
119+ }
120+ download.ConnectFailed(&failedCb)
121+
122+ // Handle finished signal.
123+ finishedCb := func(d webkit.Download) {
124+ state.mu.Lock()
125+ failed := state.failed
126+ state.mu.Unlock()
127+
128+ // Don't send finished event if we already sent a failed event.
129+ if failed {
130+ return
131+ }
132+
133+ dest := d.GetDestination()
134+ filename := extractFilename(dest)
135+
136+ if eventHandler != nil {
137+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
138+ Type: port.DownloadEventFinished,
139+ Filename: filename,
140+ Destination: dest,
141+ })
142+ }
143+
144+ log.Info().Str("filename", filename).Msg("download finished")
145+ }
146+ download.ConnectFinished(&finishedCb)
147+}
148+
149+// SetDownloadPath updates the download directory.
150+func (h *DownloadHandler) SetDownloadPath(path string) {
151+ h.mu.Lock()
152+ defer h.mu.Unlock()
153+ h.downloadPath = path
154+}
155+
156+// extractFilename extracts the filename from a file:// URI or path.
157+func extractFilename(dest string) string {
158+ // Remove file:// prefix if present.
159+ path := strings.TrimPrefix(dest, "file://")
160+ base := filepath.Base(path)
161+
162+ // Handle edge cases consistently with sanitizeFilename.
163+ if base == "." || base == "" {
164+ return "download"
165+ }
166+ return base
167+}
168+
169+// sanitizeFilename sanitizes a suggested filename to prevent path traversal attacks.
170+// It extracts only the base name and handles edge cases like "." or "..".
171+func sanitizeFilename(name string) string {
172+ // Normalize Windows-style separators to forward slashes.
173+ // filepath.Base only handles the OS-native separator, so on Linux
174+ // backslashes would not be treated as path separators.
175+ name = strings.ReplaceAll(name, "\\", "/")
176+
177+ // Get only the base name (removes any directory components).
178+ clean := filepath.Base(name)
179+
180+ // If Base returns "." or ".." (edge cases), use fallback.
181+ if clean == "." || clean == ".." || clean == "" {
182+ return "download"
183+ }
184+
185+ return clean
186+}
@@ -0,0 +1,186 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "sync"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/bnema/puregotk-webkit/webkit"
14+)
15+
16+const (
17+ // dirPerm is the permission mode for creating download directories.
18+ dirPerm = 0755
19+)
20+
21+// DownloadHandler manages WebKit downloads and notifies the UI layer.
22+type DownloadHandler struct {
23+ downloadPath string
24+ eventHandler port.DownloadEventHandler
25+ mu sync.RWMutex
26+}
27+
28+// NewDownloadHandler creates a new download handler.
29+func NewDownloadHandler(downloadPath string, handler port.DownloadEventHandler) *DownloadHandler {
30+ return &DownloadHandler{
31+ downloadPath: downloadPath,
32+ eventHandler: handler,
33+ }
34+}
35+
36+// downloadState tracks the state of a single download to prevent duplicate notifications.
37+// This is allocated per-download to ensure proper isolation between concurrent downloads.
38+type downloadState struct {
39+ failed bool
40+ mu sync.Mutex
41+}
42+
43+// HandleDownload sets up signal handlers for a new download.
44+func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
45+ log := logging.FromContext(ctx)
46+
47+ h.mu.RLock()
48+ downloadPath := h.downloadPath
49+ eventHandler := h.eventHandler
50+ h.mu.RUnlock()
51+
52+ // Track download state in a dedicated struct to ensure proper isolation.
53+ // Each download gets its own state to prevent interference between concurrent downloads.
54+ state := &downloadState{}
55+
56+ // Handle decide-destination signal to set download path.
57+ //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)
58+ decideDestCb := func(d webkit.Download, suggestedFilename string) bool {
59+ // Ensure download directory exists.
60+ if err := os.MkdirAll(downloadPath, dirPerm); err != nil {
61+ log.Error().Err(err).Str("path", downloadPath).Msg("failed to create download directory")
62+ d.Cancel()
63+ return false
64+ }
65+
66+ // Sanitize filename to prevent path traversal attacks.
67+ safeName := sanitizeFilename(suggestedFilename)
68+ destPath := filepath.Join(downloadPath, safeName)
69+
70+ log.Debug().
71+ Str("suggested", suggestedFilename).
72+ Str("sanitized", safeName).
73+ Str("destPath", destPath).
74+ Msg("setting download destination")
75+
76+ // WebKit expects an absolute file path for the destination.
77+ d.SetDestination(destPath)
78+
79+ // Notify: download started.
80+ if eventHandler != nil {
81+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
82+ Type: port.DownloadEventStarted,
83+ Filename: safeName,
84+ Destination: destPath,
85+ })
86+ }
87+
88+ log.Info().
89+ Str("filename", safeName).
90+ Str("destination", destPath).
91+ Msg("download started")
92+
93+ return false // false = we handled it synchronously
94+ }
95+ download.ConnectDecideDestination(&decideDestCb)
96+
97+ // Handle failed signal.
98+ failedCb := func(d webkit.Download, _ uintptr) {
99+ state.mu.Lock()
100+ state.failed = true
101+ state.mu.Unlock()
102+
103+ dest := d.GetDestination()
104+ filename := extractFilename(dest)
105+
106+ // Create error for the failed download.
107+ downloadErr := fmt.Errorf("download failed: %s", filename)
108+
109+ if eventHandler != nil {
110+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
111+ Type: port.DownloadEventFailed,
112+ Filename: filename,
113+ Destination: dest,
114+ Error: downloadErr,
115+ })
116+ }
117+
118+ log.Warn().Str("filename", filename).Msg("download failed")
119+ }
120+ download.ConnectFailed(&failedCb)
121+
122+ // Handle finished signal.
123+ finishedCb := func(d webkit.Download) {
124+ state.mu.Lock()
125+ failed := state.failed
126+ state.mu.Unlock()
127+
128+ // Don't send finished event if we already sent a failed event.
129+ if failed {
130+ return
131+ }
132+
133+ dest := d.GetDestination()
134+ filename := extractFilename(dest)
135+
136+ if eventHandler != nil {
137+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
138+ Type: port.DownloadEventFinished,
139+ Filename: filename,
140+ Destination: dest,
141+ })
142+ }
143+
144+ log.Info().Str("filename", filename).Msg("download finished")
145+ }
146+ download.ConnectFinished(&finishedCb)
147+}
148+
149+// SetDownloadPath updates the download directory.
150+func (h *DownloadHandler) SetDownloadPath(path string) {
151+ h.mu.Lock()
152+ defer h.mu.Unlock()
153+ h.downloadPath = path
154+}
155+
156+// extractFilename extracts the filename from a file:// URI or path.
157+func extractFilename(dest string) string {
158+ // Remove file:// prefix if present.
159+ path := strings.TrimPrefix(dest, "file://")
160+ base := filepath.Base(path)
161+
162+ // Handle edge cases consistently with sanitizeFilename.
163+ if base == "." || base == "" {
164+ return "download"
165+ }
166+ return base
167+}
168+
169+// sanitizeFilename sanitizes a suggested filename to prevent path traversal attacks.
170+// It extracts only the base name and handles edge cases like "." or "..".
171+func sanitizeFilename(name string) string {
172+ // Normalize Windows-style separators to forward slashes.
173+ // filepath.Base only handles the OS-native separator, so on Linux
174+ // backslashes would not be treated as path separators.
175+ name = strings.ReplaceAll(name, "\\", "/")
176+
177+ // Get only the base name (removes any directory components).
178+ clean := filepath.Base(name)
179+
180+ // If Base returns "." or ".." (edge cases), use fallback.
181+ if clean == "." || clean == ".." || clean == "" {
182+ return "download"
183+ }
184+
185+ return clean
186+}
A internal/infrastructure/webkit/download_handler_test.go +148 -0
@@ -0,0 +1,148 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestSanitizeFilename(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ expected string
14+ }{
15+ {
16+ name: "normal filename",
17+ input: "document.pdf",
18+ expected: "document.pdf",
19+ },
20+ {
21+ name: "filename with spaces",
22+ input: "my document.pdf",
23+ expected: "my document.pdf",
24+ },
25+ {
26+ name: "path traversal with parent dirs",
27+ input: "../../../etc/passwd",
28+ expected: "passwd",
29+ },
30+ {
31+ name: "path traversal hidden file",
32+ input: "../.ssh/id_rsa",
33+ expected: "id_rsa",
34+ },
35+ {
36+ name: "nested path",
37+ input: "foo/bar/baz.txt",
38+ expected: "baz.txt",
39+ },
40+ {
41+ name: "absolute path",
42+ input: "/etc/passwd",
43+ expected: "passwd",
44+ },
45+ {
46+ name: "dot only",
47+ input: ".",
48+ expected: "download",
49+ },
50+ {
51+ name: "double dot only",
52+ input: "..",
53+ expected: "download",
54+ },
55+ {
56+ name: "empty string",
57+ input: "",
58+ expected: "download",
59+ },
60+ {
61+ name: "hidden file",
62+ input: ".bashrc",
63+ expected: ".bashrc",
64+ },
65+ {
66+ name: "windows style path",
67+ input: "..\\..\\Windows\\System32\\config",
68+ expected: "config",
69+ },
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ result := sanitizeFilename(tt.input)
75+ assert.Equal(t, tt.expected, result)
76+ })
77+ }
78+}
79+
80+func TestExtractFilename(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ input string
84+ expected string
85+ }{
86+ {
87+ name: "file URI with path",
88+ input: "file:///home/user/Downloads/document.pdf",
89+ expected: "document.pdf",
90+ },
91+ {
92+ name: "file URI root",
93+ input: "file:///document.pdf",
94+ expected: "document.pdf",
95+ },
96+ {
97+ name: "plain path",
98+ input: "/home/user/Downloads/image.png",
99+ expected: "image.png",
100+ },
101+ {
102+ name: "filename only",
103+ input: "video.mp4",
104+ expected: "video.mp4",
105+ },
106+ {
107+ name: "empty string",
108+ input: "",
109+ expected: "download",
110+ },
111+ {
112+ name: "file URI with spaces",
113+ input: "file:///home/user/My Documents/file.txt",
114+ expected: "file.txt",
115+ },
116+ }
117+
118+ for _, tt := range tests {
119+ t.Run(tt.name, func(t *testing.T) {
120+ result := extractFilename(tt.input)
121+ assert.Equal(t, tt.expected, result)
122+ })
123+ }
124+}
125+
126+func TestNewDownloadHandler(t *testing.T) {
127+ t.Run("creates handler with path and nil event handler", func(t *testing.T) {
128+ handler := NewDownloadHandler("/tmp/downloads", nil)
129+
130+ assert.NotNil(t, handler)
131+ assert.Equal(t, "/tmp/downloads", handler.downloadPath)
132+ assert.Nil(t, handler.eventHandler)
133+ })
134+
135+ t.Run("creates handler with custom path", func(t *testing.T) {
136+ handler := NewDownloadHandler("/custom/path", nil)
137+
138+ assert.Equal(t, "/custom/path", handler.downloadPath)
139+ })
140+}
141+
142+func TestDownloadHandler_SetDownloadPath(t *testing.T) {
143+ handler := NewDownloadHandler("/initial/path", nil)
144+
145+ handler.SetDownloadPath("/new/path")
146+
147+ assert.Equal(t, "/new/path", handler.downloadPath)
148+}
@@ -0,0 +1,148 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestSanitizeFilename(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ expected string
14+ }{
15+ {
16+ name: "normal filename",
17+ input: "document.pdf",
18+ expected: "document.pdf",
19+ },
20+ {
21+ name: "filename with spaces",
22+ input: "my document.pdf",
23+ expected: "my document.pdf",
24+ },
25+ {
26+ name: "path traversal with parent dirs",
27+ input: "../../../etc/passwd",
28+ expected: "passwd",
29+ },
30+ {
31+ name: "path traversal hidden file",
32+ input: "../.ssh/id_rsa",
33+ expected: "id_rsa",
34+ },
35+ {
36+ name: "nested path",
37+ input: "foo/bar/baz.txt",
38+ expected: "baz.txt",
39+ },
40+ {
41+ name: "absolute path",
42+ input: "/etc/passwd",
43+ expected: "passwd",
44+ },
45+ {
46+ name: "dot only",
47+ input: ".",
48+ expected: "download",
49+ },
50+ {
51+ name: "double dot only",
52+ input: "..",
53+ expected: "download",
54+ },
55+ {
56+ name: "empty string",
57+ input: "",
58+ expected: "download",
59+ },
60+ {
61+ name: "hidden file",
62+ input: ".bashrc",
63+ expected: ".bashrc",
64+ },
65+ {
66+ name: "windows style path",
67+ input: "..\\..\\Windows\\System32\\config",
68+ expected: "config",
69+ },
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ result := sanitizeFilename(tt.input)
75+ assert.Equal(t, tt.expected, result)
76+ })
77+ }
78+}
79+
80+func TestExtractFilename(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ input string
84+ expected string
85+ }{
86+ {
87+ name: "file URI with path",
88+ input: "file:///home/user/Downloads/document.pdf",
89+ expected: "document.pdf",
90+ },
91+ {
92+ name: "file URI root",
93+ input: "file:///document.pdf",
94+ expected: "document.pdf",
95+ },
96+ {
97+ name: "plain path",
98+ input: "/home/user/Downloads/image.png",
99+ expected: "image.png",
100+ },
101+ {
102+ name: "filename only",
103+ input: "video.mp4",
104+ expected: "video.mp4",
105+ },
106+ {
107+ name: "empty string",
108+ input: "",
109+ expected: "download",
110+ },
111+ {
112+ name: "file URI with spaces",
113+ input: "file:///home/user/My Documents/file.txt",
114+ expected: "file.txt",
115+ },
116+ }
117+
118+ for _, tt := range tests {
119+ t.Run(tt.name, func(t *testing.T) {
120+ result := extractFilename(tt.input)
121+ assert.Equal(t, tt.expected, result)
122+ })
123+ }
124+}
125+
126+func TestNewDownloadHandler(t *testing.T) {
127+ t.Run("creates handler with path and nil event handler", func(t *testing.T) {
128+ handler := NewDownloadHandler("/tmp/downloads", nil)
129+
130+ assert.NotNil(t, handler)
131+ assert.Equal(t, "/tmp/downloads", handler.downloadPath)
132+ assert.Nil(t, handler.eventHandler)
133+ })
134+
135+ t.Run("creates handler with custom path", func(t *testing.T) {
136+ handler := NewDownloadHandler("/custom/path", nil)
137+
138+ assert.Equal(t, "/custom/path", handler.downloadPath)
139+ })
140+}
141+
142+func TestDownloadHandler_SetDownloadPath(t *testing.T) {
143+ handler := NewDownloadHandler("/initial/path", nil)
144+
145+ handler.SetDownloadPath("/new/path")
146+
147+ assert.Equal(t, "/new/path", handler.downloadPath)
148+}
M internal/infrastructure/webkit/webview.go +41 -8
@@ -761,28 +761,46 @@ func (wv *WebView) Stop(ctx context.Context) error {
761761 }
762762
763763 // GoBack navigates back in history.
764+// Uses WebKit native navigation first, falls back to JavaScript history.back() for SPA compatibility.
764765 func (wv *WebView) GoBack(ctx context.Context) error {
765766 if wv.destroyed.Load() {
766767 return fmt.Errorf("webview %d is destroyed", wv.id)
767768 }
768- if !wv.inner.CanGoBack() {
769- return fmt.Errorf("cannot go back")
769+ log := logging.FromContext(ctx)
770+
771+ // Try WebKit native navigation first if available (for non-SPA pages)
772+ if wv.inner.CanGoBack() {
773+ wv.inner.GoBack()
774+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (native)")
775+ return nil
770776 }
771- wv.inner.GoBack()
772- logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go back")
777+
778+ // Fall back to JavaScript history.back() for SPA navigation
779+ // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
780+ wv.RunJavaScript(ctx, "history.back()", "")
781+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (js fallback)")
773782 return nil
774783 }
775784
776785 // GoForward navigates forward in history.
786+// Uses WebKit native navigation first, falls back to JavaScript history.forward() for SPA compatibility.
777787 func (wv *WebView) GoForward(ctx context.Context) error {
778788 if wv.destroyed.Load() {
779789 return fmt.Errorf("webview %d is destroyed", wv.id)
780790 }
781- if !wv.inner.CanGoForward() {
782- return fmt.Errorf("cannot go forward")
791+ log := logging.FromContext(ctx)
792+
793+ // Try WebKit native navigation first if available (for non-SPA pages)
794+ if wv.inner.CanGoForward() {
795+ wv.inner.GoForward()
796+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (native)")
797+ return nil
783798 }
784- wv.inner.GoForward()
785- logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go forward")
799+
800+ // Fall back to JavaScript history.forward() for SPA navigation
801+ // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
802+ wv.RunJavaScript(ctx, "history.forward()", "")
803+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (js fallback)")
786804 return nil
787805 }
788806
@@ -950,6 +968,21 @@ func (wv *WebView) ShowDevTools() error {
950968 return nil
951969 }
952970
971+// Print opens the print dialog for the current page.
972+func (wv *WebView) Print() error {
973+ if wv.destroyed.Load() {
974+ return fmt.Errorf("webview %d is destroyed", wv.id)
975+ }
976+ printOp := webkit.NewPrintOperation(wv.inner)
977+ if printOp == nil {
978+ return fmt.Errorf("failed to create print operation for webview %d", wv.id)
979+ }
980+ // RunDialog with nil parent window - GTK will use the widget's toplevel
981+ printOp.RunDialog(nil)
982+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("print dialog opened")
983+ return nil
984+}
985+
953986 // IsDestroyed returns true if the WebView has been destroyed.
954987 func (wv *WebView) IsDestroyed() bool {
955988 return wv.destroyed.Load()
@@ -761,28 +761,46 @@ func (wv *WebView) Stop(ctx context.Context) error {
761 }761 }
762 762
763 // GoBack navigates back in history.763 // GoBack navigates back in history.
764+// Uses WebKit native navigation first, falls back to JavaScript history.back() for SPA compatibility.
764 func (wv *WebView) GoBack(ctx context.Context) error {765 func (wv *WebView) GoBack(ctx context.Context) error {
765 if wv.destroyed.Load() {766 if wv.destroyed.Load() {
766 return fmt.Errorf("webview %d is destroyed", wv.id)767 return fmt.Errorf("webview %d is destroyed", wv.id)
767 }768 }
768- if !wv.inner.CanGoBack() {769+ log := logging.FromContext(ctx)
769- return fmt.Errorf("cannot go back")770+
771+ // Try WebKit native navigation first if available (for non-SPA pages)
772+ if wv.inner.CanGoBack() {
773+ wv.inner.GoBack()
774+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (native)")
775+ return nil
770 }776 }
771- wv.inner.GoBack()777+
772- logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go back")778+ // Fall back to JavaScript history.back() for SPA navigation
779+ // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
780+ wv.RunJavaScript(ctx, "history.back()", "")
781+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (js fallback)")
773 return nil782 return nil
774 }783 }
775 784
776 // GoForward navigates forward in history.785 // GoForward navigates forward in history.
786+// Uses WebKit native navigation first, falls back to JavaScript history.forward() for SPA compatibility.
777 func (wv *WebView) GoForward(ctx context.Context) error {787 func (wv *WebView) GoForward(ctx context.Context) error {
778 if wv.destroyed.Load() {788 if wv.destroyed.Load() {
779 return fmt.Errorf("webview %d is destroyed", wv.id)789 return fmt.Errorf("webview %d is destroyed", wv.id)
780 }790 }
781- if !wv.inner.CanGoForward() {791+ log := logging.FromContext(ctx)
782- return fmt.Errorf("cannot go forward")792+
793+ // Try WebKit native navigation first if available (for non-SPA pages)
794+ if wv.inner.CanGoForward() {
795+ wv.inner.GoForward()
796+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (native)")
797+ return nil
783 }798 }
784- wv.inner.GoForward()799+
785- logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go forward")800+ // Fall back to JavaScript history.forward() for SPA navigation
801+ // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
802+ wv.RunJavaScript(ctx, "history.forward()", "")
803+ log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (js fallback)")
786 return nil804 return nil
787 }805 }
788 806
@@ -950,6 +968,21 @@ func (wv *WebView) ShowDevTools() error {
950 return nil968 return nil
951 }969 }
952 970
971+// Print opens the print dialog for the current page.
972+func (wv *WebView) Print() error {
973+ if wv.destroyed.Load() {
974+ return fmt.Errorf("webview %d is destroyed", wv.id)
975+ }
976+ printOp := webkit.NewPrintOperation(wv.inner)
977+ if printOp == nil {
978+ return fmt.Errorf("failed to create print operation for webview %d", wv.id)
979+ }
980+ // RunDialog with nil parent window - GTK will use the widget's toplevel
981+ printOp.RunDialog(nil)
982+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("print dialog opened")
983+ return nil
984+}
985+
953 // IsDestroyed returns true if the WebView has been destroyed.986 // IsDestroyed returns true if the WebView has been destroyed.
954 func (wv *WebView) IsDestroyed() bool {987 func (wv *WebView) IsDestroyed() bool {
955 return wv.destroyed.Load()988 return wv.destroyed.Load()
M internal/infrastructure/xdg/adapter.go +14 -0
@@ -72,4 +72,18 @@ func (a *Adapter) ManDir() (string, error) {
7272 return filepath.Join(dataHome, "man", "man1"), nil
7373 }
7474
75+func (a *Adapter) DownloadDir() (string, error) {
76+ // Check XDG_DOWNLOAD_DIR first (set by xdg-user-dirs).
77+ if dir := os.Getenv("XDG_DOWNLOAD_DIR"); dir != "" {
78+ return dir, nil
79+ }
80+
81+ // Fallback to ~/Downloads.
82+ home, err := os.UserHomeDir()
83+ if err != nil {
84+ return "", err
85+ }
86+ return filepath.Join(home, "Downloads"), nil
87+}
88+
7589 var _ port.XDGPaths = (*Adapter)(nil)
@@ -72,4 +72,18 @@ func (a *Adapter) ManDir() (string, error) {
72 return filepath.Join(dataHome, "man", "man1"), nil72 return filepath.Join(dataHome, "man", "man1"), nil
73 }73 }
74 74
75+func (a *Adapter) DownloadDir() (string, error) {
76+ // Check XDG_DOWNLOAD_DIR first (set by xdg-user-dirs).
77+ if dir := os.Getenv("XDG_DOWNLOAD_DIR"); dir != "" {
78+ return dir, nil
79+ }
80+
81+ // Fallback to ~/Downloads.
82+ home, err := os.UserHomeDir()
83+ if err != nil {
84+ return "", err
85+ }
86+ return filepath.Join(home, "Downloads"), nil
87+}
88+
75 var _ port.XDGPaths = (*Adapter)(nil)89 var _ port.XDGPaths = (*Adapter)(nil)
A internal/infrastructure/xdg/adapter_test.go +63 -0
@@ -0,0 +1,63 @@
1+package xdg
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAdapter_DownloadDir(t *testing.T) {
13+ adapter := New()
14+
15+ t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) {
16+ // Save and restore original env.
17+ original := os.Getenv("XDG_DOWNLOAD_DIR")
18+ defer func() {
19+ if original == "" {
20+ os.Unsetenv("XDG_DOWNLOAD_DIR")
21+ } else {
22+ os.Setenv("XDG_DOWNLOAD_DIR", original)
23+ }
24+ }()
25+
26+ os.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")
27+
28+ dir, err := adapter.DownloadDir()
29+
30+ require.NoError(t, err)
31+ assert.Equal(t, "/custom/downloads", dir)
32+ })
33+
34+ t.Run("falls back to ~/Downloads when XDG_DOWNLOAD_DIR not set", func(t *testing.T) {
35+ // Save and restore original env.
36+ original := os.Getenv("XDG_DOWNLOAD_DIR")
37+ defer func() {
38+ if original == "" {
39+ os.Unsetenv("XDG_DOWNLOAD_DIR")
40+ } else {
41+ os.Setenv("XDG_DOWNLOAD_DIR", original)
42+ }
43+ }()
44+
45+ os.Unsetenv("XDG_DOWNLOAD_DIR")
46+
47+ dir, err := adapter.DownloadDir()
48+
49+ require.NoError(t, err)
50+
51+ home, err := os.UserHomeDir()
52+ require.NoError(t, err)
53+
54+ expected := filepath.Join(home, "Downloads")
55+ assert.Equal(t, expected, dir)
56+ })
57+}
58+
59+func TestNew(t *testing.T) {
60+ adapter := New()
61+
62+ assert.NotNil(t, adapter)
63+}
@@ -0,0 +1,63 @@
1+package xdg
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAdapter_DownloadDir(t *testing.T) {
13+ adapter := New()
14+
15+ t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) {
16+ // Save and restore original env.
17+ original := os.Getenv("XDG_DOWNLOAD_DIR")
18+ defer func() {
19+ if original == "" {
20+ os.Unsetenv("XDG_DOWNLOAD_DIR")
21+ } else {
22+ os.Setenv("XDG_DOWNLOAD_DIR", original)
23+ }
24+ }()
25+
26+ os.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")
27+
28+ dir, err := adapter.DownloadDir()
29+
30+ require.NoError(t, err)
31+ assert.Equal(t, "/custom/downloads", dir)
32+ })
33+
34+ t.Run("falls back to ~/Downloads when XDG_DOWNLOAD_DIR not set", func(t *testing.T) {
35+ // Save and restore original env.
36+ original := os.Getenv("XDG_DOWNLOAD_DIR")
37+ defer func() {
38+ if original == "" {
39+ os.Unsetenv("XDG_DOWNLOAD_DIR")
40+ } else {
41+ os.Setenv("XDG_DOWNLOAD_DIR", original)
42+ }
43+ }()
44+
45+ os.Unsetenv("XDG_DOWNLOAD_DIR")
46+
47+ dir, err := adapter.DownloadDir()
48+
49+ require.NoError(t, err)
50+
51+ home, err := os.UserHomeDir()
52+ require.NoError(t, err)
53+
54+ expected := filepath.Join(home, "Downloads")
55+ assert.Equal(t, expected, dir)
56+ })
57+}
58+
59+func TestNew(t *testing.T) {
60+ adapter := New()
61+
62+ assert.NotNil(t, adapter)
63+}
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/app.go +71 -0
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "os"
8+ "path/filepath"
89 "sync"
910
1011 "github.com/bnema/dumber/internal/application/port"
@@ -240,6 +241,7 @@ func (a *App) onActivate(ctx context.Context) {
240241 a.initAppToasterOverlay()
241242 a.initFocusAndBorderOverlay()
242243 a.initAccentPicker(ctx)
244+ a.initDownloadHandler(ctx)
243245
244246 a.initCoordinators(ctx)
245247 a.initKeyboardHandler(ctx)
@@ -453,6 +455,72 @@ func (a *App) initAccentPicker(ctx context.Context) {
453455 log.Debug().Msg("accent picker initialized")
454456 }
455457
458+func (a *App) initDownloadHandler(ctx context.Context) {
459+ log := logging.FromContext(ctx)
460+
461+ if a.deps == nil || a.deps.WebContext == nil {
462+ log.Debug().Msg("WebContext not available, skipping download handler")
463+ return
464+ }
465+
466+ // Determine download path from config, fallback to XDG.
467+ downloadPath := ""
468+ if a.deps.Config != nil {
469+ downloadPath = a.deps.Config.Downloads.Path
470+ }
471+ if downloadPath == "" && a.deps.XDG != nil {
472+ var err error
473+ downloadPath, err = a.deps.XDG.DownloadDir()
474+ if err != nil {
475+ log.Warn().Err(err).Msg("failed to get XDG download dir")
476+ }
477+ }
478+ if downloadPath == "" {
479+ // Final fallback to ~/Downloads, with /tmp as last resort.
480+ home, err := os.UserHomeDir()
481+ if err != nil {
482+ log.Warn().Err(err).Msg("failed to get home dir, using /tmp for downloads")
483+ downloadPath = "/tmp"
484+ } else {
485+ downloadPath = filepath.Join(home, "Downloads")
486+ }
487+ }
488+
489+ // Create download event adapter to show toasts.
490+ eventAdapter := &downloadEventAdapter{app: a}
491+
492+ // Create and wire the download handler.
493+ handler := webkit.NewDownloadHandler(downloadPath, eventAdapter)
494+ a.deps.WebContext.SetDownloadHandler(ctx, handler)
495+
496+ log.Info().Str("path", downloadPath).Msg("download handler initialized")
497+}
498+
499+// downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
500+type downloadEventAdapter struct {
501+ app *App
502+}
503+
504+func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
505+ // Must schedule on GTK main thread.
506+ cb := glib.SourceFunc(func(_ uintptr) bool {
507+ if d.app.appToaster == nil {
508+ return false
509+ }
510+
511+ switch event.Type {
512+ case port.DownloadEventStarted:
513+ d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo)
514+ case port.DownloadEventFinished:
515+ d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
516+ case port.DownloadEventFailed:
517+ d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError)
518+ }
519+ return false
520+ })
521+ glib.IdleAdd(&cb, 0)
522+}
523+
456524 func (a *App) initKeyboardHandler(ctx context.Context) {
457525 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
458526 return
@@ -865,6 +933,9 @@ func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID,
865933 return
866934 }
867935 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
936+ if a.wsCoord != nil {
937+ a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
938+ }
868939 }
869940
870941 func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "os"7 "os"
8+ "path/filepath"
8 "sync"9 "sync"
9 10
10 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
@@ -240,6 +241,7 @@ func (a *App) onActivate(ctx context.Context) {
240 a.initAppToasterOverlay()241 a.initAppToasterOverlay()
241 a.initFocusAndBorderOverlay()242 a.initFocusAndBorderOverlay()
242 a.initAccentPicker(ctx)243 a.initAccentPicker(ctx)
244+ a.initDownloadHandler(ctx)
243 245
244 a.initCoordinators(ctx)246 a.initCoordinators(ctx)
245 a.initKeyboardHandler(ctx)247 a.initKeyboardHandler(ctx)
@@ -453,6 +455,72 @@ func (a *App) initAccentPicker(ctx context.Context) {
453 log.Debug().Msg("accent picker initialized")455 log.Debug().Msg("accent picker initialized")
454 }456 }
455 457
458+func (a *App) initDownloadHandler(ctx context.Context) {
459+ log := logging.FromContext(ctx)
460+
461+ if a.deps == nil || a.deps.WebContext == nil {
462+ log.Debug().Msg("WebContext not available, skipping download handler")
463+ return
464+ }
465+
466+ // Determine download path from config, fallback to XDG.
467+ downloadPath := ""
468+ if a.deps.Config != nil {
469+ downloadPath = a.deps.Config.Downloads.Path
470+ }
471+ if downloadPath == "" && a.deps.XDG != nil {
472+ var err error
473+ downloadPath, err = a.deps.XDG.DownloadDir()
474+ if err != nil {
475+ log.Warn().Err(err).Msg("failed to get XDG download dir")
476+ }
477+ }
478+ if downloadPath == "" {
479+ // Final fallback to ~/Downloads, with /tmp as last resort.
480+ home, err := os.UserHomeDir()
481+ if err != nil {
482+ log.Warn().Err(err).Msg("failed to get home dir, using /tmp for downloads")
483+ downloadPath = "/tmp"
484+ } else {
485+ downloadPath = filepath.Join(home, "Downloads")
486+ }
487+ }
488+
489+ // Create download event adapter to show toasts.
490+ eventAdapter := &downloadEventAdapter{app: a}
491+
492+ // Create and wire the download handler.
493+ handler := webkit.NewDownloadHandler(downloadPath, eventAdapter)
494+ a.deps.WebContext.SetDownloadHandler(ctx, handler)
495+
496+ log.Info().Str("path", downloadPath).Msg("download handler initialized")
497+}
498+
499+// downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
500+type downloadEventAdapter struct {
501+ app *App
502+}
503+
504+func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
505+ // Must schedule on GTK main thread.
506+ cb := glib.SourceFunc(func(_ uintptr) bool {
507+ if d.app.appToaster == nil {
508+ return false
509+ }
510+
511+ switch event.Type {
512+ case port.DownloadEventStarted:
513+ d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo)
514+ case port.DownloadEventFinished:
515+ d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
516+ case port.DownloadEventFailed:
517+ d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError)
518+ }
519+ return false
520+ })
521+ glib.IdleAdd(&cb, 0)
522+}
523+
456 func (a *App) initKeyboardHandler(ctx context.Context) {524 func (a *App) initKeyboardHandler(ctx context.Context) {
457 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {525 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
458 return526 return
@@ -865,6 +933,9 @@ func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID,
865 return933 return
866 }934 }
867 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)935 a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
936+ if a.wsCoord != nil {
937+ a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
938+ }
868 }939 }
869 940
870 func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {941 func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
M internal/ui/component/accent_picker.go +6 -6
@@ -177,9 +177,9 @@ func (ap *AccentPicker) selectAccent(index int) {
177177 return
178178 }
179179 accent := ap.accents[index]
180- // Invoke callback while holding lock to avoid race with Hide() clearing callbacks
181- ap.selectedCb(accent)
182- ap.mu.Unlock()
180+ cb := ap.selectedCb // Copy callback reference before unlocking
181+ ap.mu.Unlock() // Release lock BEFORE calling callback to avoid deadlock
182+ cb(accent) // Callback may call Hide() which needs to acquire ap.mu
183183 }
184184
185185 // cancel cancels the accent picker.
@@ -189,9 +189,9 @@ func (ap *AccentPicker) cancel() {
189189 ap.mu.Unlock()
190190 return
191191 }
192- // Invoke callback while holding lock to avoid race with Hide() clearing callbacks
193- ap.cancelCb()
194- ap.mu.Unlock()
192+ cb := ap.cancelCb // Copy callback reference before unlocking
193+ ap.mu.Unlock() // Release lock BEFORE calling callback to avoid deadlock
194+ cb() // Callback may call Hide() which needs to acquire ap.mu
195195 }
196196
197197 // setupKeyboardHandling sets up key event handling for navigation.
@@ -177,9 +177,9 @@ func (ap *AccentPicker) selectAccent(index int) {
177 return177 return
178 }178 }
179 accent := ap.accents[index]179 accent := ap.accents[index]
180- // Invoke callback while holding lock to avoid race with Hide() clearing callbacks180+ cb := ap.selectedCb // Copy callback reference before unlocking
181- ap.selectedCb(accent)181+ ap.mu.Unlock() // Release lock BEFORE calling callback to avoid deadlock
182- ap.mu.Unlock()182+ cb(accent) // Callback may call Hide() which needs to acquire ap.mu
183 }183 }
184 184
185 // cancel cancels the accent picker.185 // cancel cancels the accent picker.
@@ -189,9 +189,9 @@ func (ap *AccentPicker) cancel() {
189 ap.mu.Unlock()189 ap.mu.Unlock()
190 return190 return
191 }191 }
192- // Invoke callback while holding lock to avoid race with Hide() clearing callbacks192+ cb := ap.cancelCb // Copy callback reference before unlocking
193- ap.cancelCb()193+ ap.mu.Unlock() // Release lock BEFORE calling callback to avoid deadlock
194- ap.mu.Unlock()194+ cb() // Callback may call Hide() which needs to acquire ap.mu
195 }195 }
196 196
197 // setupKeyboardHandling sets up key event handling for navigation.197 // setupKeyboardHandling sets up key event handling for navigation.
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 +22 -8
@@ -133,6 +133,7 @@ func (c *NavigationCoordinator) HardReload(ctx context.Context) error {
133133 }
134134
135135 // GoBack navigates back in history.
136+// Calls webview directly - the webview layer handles SPA navigation via JS fallback.
136137 func (c *NavigationCoordinator) GoBack(ctx context.Context) error {
137138 log := logging.FromContext(ctx)
138139
@@ -142,14 +143,11 @@ func (c *NavigationCoordinator) GoBack(ctx context.Context) error {
142143 return nil
143144 }
144145
145- if c.navigateUC != nil {
146- return c.navigateUC.GoBack(ctx, wv)
147- }
148-
149146 return wv.GoBack(ctx)
150147 }
151148
152149 // GoForward navigates forward in history.
150+// Calls webview directly - the webview layer handles SPA navigation via JS fallback.
153151 func (c *NavigationCoordinator) GoForward(ctx context.Context) error {
154152 log := logging.FromContext(ctx)
155153
@@ -159,10 +157,6 @@ func (c *NavigationCoordinator) GoForward(ctx context.Context) error {
159157 return nil
160158 }
161159
162- if c.navigateUC != nil {
163- return c.navigateUC.GoForward(ctx, wv)
164- }
165-
166160 return wv.GoForward(ctx)
167161 }
168162
@@ -200,6 +194,26 @@ func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error {
200194 return fmt.Errorf("webview does not support devtools")
201195 }
202196
197+// PrintPage opens the print dialog for the active WebView.
198+func (c *NavigationCoordinator) PrintPage(ctx context.Context) error {
199+ log := logging.FromContext(ctx)
200+
201+ wv := c.contentCoord.ActiveWebView(ctx)
202+ if wv == nil {
203+ log.Warn().Msg("no active webview for print")
204+ return fmt.Errorf("no active webview")
205+ }
206+
207+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening print dialog")
208+
209+ // Type assert to access Print (not in port.WebView interface)
210+ if webkitWV, ok := interface{}(wv).(*webkit.WebView); ok {
211+ return webkitWV.Print()
212+ }
213+
214+ return fmt.Errorf("webview does not support printing")
215+}
216+
203217 // UpdateHistoryTitle updates the title of a history entry after page load.
204218 func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) {
205219 log := logging.FromContext(ctx)
@@ -133,6 +133,7 @@ func (c *NavigationCoordinator) HardReload(ctx context.Context) error {
133 }133 }
134 134
135 // GoBack navigates back in history.135 // GoBack navigates back in history.
136+// Calls webview directly - the webview layer handles SPA navigation via JS fallback.
136 func (c *NavigationCoordinator) GoBack(ctx context.Context) error {137 func (c *NavigationCoordinator) GoBack(ctx context.Context) error {
137 log := logging.FromContext(ctx)138 log := logging.FromContext(ctx)
138 139
@@ -142,14 +143,11 @@ func (c *NavigationCoordinator) GoBack(ctx context.Context) error {
142 return nil143 return nil
143 }144 }
144 145
145- if c.navigateUC != nil {
146- return c.navigateUC.GoBack(ctx, wv)
147- }
148-
149 return wv.GoBack(ctx)146 return wv.GoBack(ctx)
150 }147 }
151 148
152 // GoForward navigates forward in history.149 // GoForward navigates forward in history.
150+// Calls webview directly - the webview layer handles SPA navigation via JS fallback.
153 func (c *NavigationCoordinator) GoForward(ctx context.Context) error {151 func (c *NavigationCoordinator) GoForward(ctx context.Context) error {
154 log := logging.FromContext(ctx)152 log := logging.FromContext(ctx)
155 153
@@ -159,10 +157,6 @@ func (c *NavigationCoordinator) GoForward(ctx context.Context) error {
159 return nil157 return nil
160 }158 }
161 159
162- if c.navigateUC != nil {
163- return c.navigateUC.GoForward(ctx, wv)
164- }
165-
166 return wv.GoForward(ctx)160 return wv.GoForward(ctx)
167 }161 }
168 162
@@ -200,6 +194,26 @@ func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error {
200 return fmt.Errorf("webview does not support devtools")194 return fmt.Errorf("webview does not support devtools")
201 }195 }
202 196
197+// PrintPage opens the print dialog for the active WebView.
198+func (c *NavigationCoordinator) PrintPage(ctx context.Context) error {
199+ log := logging.FromContext(ctx)
200+
201+ wv := c.contentCoord.ActiveWebView(ctx)
202+ if wv == nil {
203+ log.Warn().Msg("no active webview for print")
204+ return fmt.Errorf("no active webview")
205+ }
206+
207+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening print dialog")
208+
209+ // Type assert to access Print (not in port.WebView interface)
210+ if webkitWV, ok := interface{}(wv).(*webkit.WebView); ok {
211+ return webkitWV.Print()
212+ }
213+
214+ return fmt.Errorf("webview does not support printing")
215+}
216+
203 // UpdateHistoryTitle updates the title of a history entry after page load.217 // 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) {218 func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) {
205 log := logging.FromContext(ctx)219 log := logging.FromContext(ctx)
M internal/ui/coordinator/update.go +3 -4
@@ -99,12 +99,11 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
9999 // showUpdateNotification displays a toast notification about the update.
100100 func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *usecase.CheckUpdateOutput) {
101101 var msg string
102- if result.CanAutoUpdate && c.config.Update.AutoDownload {
102+ switch {
103+ case result.CanAutoUpdate && c.config.Update.AutoDownload:
103104 msg = fmt.Sprintf("Downloading update %s...", result.LatestVersion)
104- } else if result.CanAutoUpdate {
105+ default:
105106 msg = fmt.Sprintf("Update %s available", result.LatestVersion)
106- } else {
107- msg = fmt.Sprintf("Update %s available (manual install required)", result.LatestVersion)
108107 }
109108
110109 // Dispatch to GTK main thread.
@@ -99,12 +99,11 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
99 // showUpdateNotification displays a toast notification about the update.99 // showUpdateNotification displays a toast notification about the update.
100 func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *usecase.CheckUpdateOutput) {100 func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *usecase.CheckUpdateOutput) {
101 var msg string101 var msg string
102- if result.CanAutoUpdate && c.config.Update.AutoDownload {102+ switch {
103+ case result.CanAutoUpdate && c.config.Update.AutoDownload:
103 msg = fmt.Sprintf("Downloading update %s...", result.LatestVersion)104 msg = fmt.Sprintf("Downloading update %s...", result.LatestVersion)
104- } else if result.CanAutoUpdate {105+ default:
105 msg = fmt.Sprintf("Update %s available", result.LatestVersion)106 msg = fmt.Sprintf("Update %s available", result.LatestVersion)
106- } else {
107- msg = fmt.Sprintf("Update %s available (manual install required)", result.LatestVersion)
108 }107 }
109 108
110 // Dispatch to GTK main thread.109 // Dispatch to GTK main thread.
M internal/ui/coordinator/workspace.go +177 -125
@@ -263,6 +263,7 @@ func (c *WorkspaceCoordinator) applySplitToView(
263263
264264 if needsAttach {
265265 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
266+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
266267 }
267268 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
268269 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
@@ -1055,6 +1056,7 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
10551056 }
10561057 c.contentCoord.ReleaseWebView(ctx, paneID)
10571058 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1059+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
10581060 }
10591061 } else {
10601062 if err := wsView.Rebuild(ctx); err != nil {
@@ -1062,6 +1064,7 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
10621064 }
10631065 c.contentCoord.ReleaseWebView(ctx, paneID)
10641066 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1067+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
10651068 }
10661069
10671070 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
@@ -1095,17 +1098,10 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose(
10951098 return fmt.Errorf("stacked view not found for pane %s", closingPaneID)
10961099 }
10971100
1098- // Find the index of the closing pane in the stack
1099- closingIndex := -1
1100- for i, child := range stackNode.Children {
1101- if child.Pane != nil && child.Pane.ID == closingPaneID {
1102- closingIndex = i
1103- break
1104- }
1105- }
1106-
1101+ // Find the index of the closing pane in the StackedView UI
1102+ closingIndex := stackedView.FindPaneIndex(string(closingPaneID))
11071103 if closingIndex < 0 {
1108- return fmt.Errorf("pane %s not found in stack", closingPaneID)
1104+ return fmt.Errorf("pane %s not found in stacked view", closingPaneID)
11091105 }
11101106
11111107 // Remove the pane from the StackedView widget
@@ -1120,12 +1116,48 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose(
11201116 // Release the closing pane's webview
11211117 c.contentCoord.ReleaseWebView(ctx, closingPaneID)
11221118
1119+ // Sync remaining title bars with current titles from ContentCoordinator.
1120+ // The domain model (stackNode.Children) has already been updated by the use case,
1121+ // so we iterate the remaining children and update their titles in the UI.
1122+ c.syncStackedPaneTitles(ctx, stackedView, stackNode)
1123+
11231124 log.Debug().
11241125 Int("removed_index", closingIndex).
11251126 Msg("incremental stack close completed successfully")
11261127 return nil
11271128 }
11281129
1130+// syncStackedPaneTitles updates all title bars in a StackedView with current titles.
1131+func (c *WorkspaceCoordinator) syncStackedPaneTitles(ctx context.Context, stackedView *layout.StackedView, stackNode *entity.PaneNode) {
1132+ log := logging.FromContext(ctx)
1133+
1134+ if stackedView == nil || stackNode == nil {
1135+ return
1136+ }
1137+
1138+ for i, child := range stackNode.Children {
1139+ if child.Pane == nil {
1140+ continue
1141+ }
1142+
1143+ title := c.contentCoord.GetTitle(child.Pane.ID)
1144+ if title == "" {
1145+ title = child.Pane.Title
1146+ }
1147+ if title == "" {
1148+ title = defaultPaneTitle
1149+ }
1150+
1151+ if err := stackedView.UpdateTitle(i, title); err != nil {
1152+ log.Warn().
1153+ Err(err).
1154+ Int("index", i).
1155+ Str("pane_id", string(child.Pane.ID)).
1156+ Msg("failed to sync title bar")
1157+ }
1158+ }
1159+}
1160+
11291161 // FocusPane navigates focus to an adjacent pane.
11301162 func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.NavigateDirection) error {
11311163 log := logging.FromContext(ctx)
@@ -1228,6 +1260,7 @@ func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView
12281260 }
12291261
12301262 // StackPane adds a new pane stacked on top of the active pane.
1263+// Uses CreateStack use case for new stacks, AddToStack for existing stacks.
12311264 func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
12321265 log := logging.FromContext(ctx)
12331266
@@ -1236,19 +1269,64 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
12361269 return nil
12371270 }
12381271
1272+ // Create new pane entity
12391273 newPaneID := entity.PaneID(c.generateID())
12401274 newPane := entity.NewPane(newPaneID)
12411275 newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL)
12421276 newPane.Title = defaultPaneTitle
12431277
1244- stackNode, needsFirstPaneTitleUpdate := c.ensureStackNode(ctx, stackCtx.activeNode, stackCtx.activePaneID, stackCtx.originalTitle)
1245- insertIndex := insertStackChild(stackNode, newPaneID, newPane)
1278+ // Determine if we need to create a new stack or add to existing
1279+ var stackNode *entity.PaneNode
1280+ var needsFirstPaneTitleUpdate bool
12461281
1247- log.Debug().
1248- Int("stack_size", len(stackNode.Children)).
1249- Int("insert_index", insertIndex).
1250- Int("active_index", stackNode.ActiveStackIndex).
1251- Msg("domain tree updated with position-aware insertion")
1282+ if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {
1283+ // Already in a stack - use AddToStack use case
1284+ stackNode = stackCtx.activeNode.Parent
1285+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)
1286+ if err != nil {
1287+ log.Error().Err(err).Msg("failed to add pane to stack via use case")
1288+ return err
1289+ }
1290+ log.Debug().
1291+ Int("stack_size", len(stackNode.Children)).
1292+ Int("insert_index", output.StackIndex).
1293+ Msg("added to existing stack via use case")
1294+ } else if stackCtx.activeNode.IsStacked {
1295+ // Active node is already a stack container - add to it
1296+ stackNode = stackCtx.activeNode
1297+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)
1298+ if err != nil {
1299+ log.Error().Err(err).Msg("failed to add pane to stack via use case")
1300+ return err
1301+ }
1302+ log.Debug().
1303+ Int("stack_size", len(stackNode.Children)).
1304+ Int("insert_index", output.StackIndex).
1305+ Msg("added to stack container via use case")
1306+ } else {
1307+ // Need to create a new stack - use CreateStack use case
1308+ // But CreateStack creates its own pane, so we need a different approach
1309+ // Convert to stack first, then the new pane is already created
1310+ output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode)
1311+ if err != nil {
1312+ log.Error().Err(err).Msg("failed to create stack via use case")
1313+ return err
1314+ }
1315+ stackNode = output.StackNode
1316+ // CreateStack creates its own new pane, use that instead
1317+ newPane = output.NewPane
1318+ newPaneID = newPane.ID
1319+ needsFirstPaneTitleUpdate = true
1320+
1321+ // Update the original pane's title in the domain
1322+ if output.OriginalNode != nil && output.OriginalNode.Pane != nil {
1323+ output.OriginalNode.Pane.Title = stackCtx.originalTitle
1324+ }
1325+
1326+ log.Debug().
1327+ Int("stack_size", len(stackNode.Children)).
1328+ Msg("created new stack via use case")
1329+ }
12521330
12531331 // Create PaneView for the new pane
12541332 newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
@@ -1281,9 +1359,6 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
12811359 }
12821360 }
12831361
1284- // Update workspace active pane ID
1285- stackCtx.ws.ActivePaneID = newPaneID
1286-
12871362 // Update workspace view
12881363 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
12891364 log.Warn().Err(err).Msg("failed to set active pane")
@@ -1364,71 +1439,6 @@ func (c *WorkspaceCoordinator) prepareStackPane(
13641439 }, true
13651440 }
13661441
1367-func (c *WorkspaceCoordinator) ensureStackNode(
1368- ctx context.Context,
1369- activeNode *entity.PaneNode,
1370- activePaneID entity.PaneID,
1371- originalTitle string,
1372-) (*entity.PaneNode, bool) {
1373- log := logging.FromContext(ctx)
1374- if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1375- stackNode := activeNode.Parent
1376- log.Debug().
1377- Str("stack_id", stackNode.ID).
1378- Str("active_pane", string(activePaneID)).
1379- Int("stack_size", len(stackNode.Children)).
1380- Msg("adding to existing stack via parent")
1381- return stackNode, false
1382- }
1383-
1384- if activeNode.IsStacked {
1385- return activeNode, false
1386- }
1387-
1388- originalPane := activeNode.Pane
1389- originalPane.Title = originalTitle
1390- originalPaneChild := &entity.PaneNode{
1391- ID: activeNode.ID + "_0",
1392- Pane: originalPane,
1393- Parent: activeNode,
1394- }
1395-
1396- activeNode.Pane = nil
1397- activeNode.IsStacked = true
1398- activeNode.Children = []*entity.PaneNode{originalPaneChild}
1399-
1400- log.Debug().
1401- Str("node_id", activeNode.ID).
1402- Str("original_pane", string(originalPane.ID)).
1403- Str("original_title", originalTitle).
1404- Msg("converted leaf to stacked node")
1405-
1406- return activeNode, true
1407-}
1408-
1409-func insertStackChild(stackNode *entity.PaneNode, newPaneID entity.PaneID, newPane *entity.Pane) int {
1410- newChildNode := &entity.PaneNode{
1411- ID: stackNode.ID + "_" + string(newPaneID),
1412- Pane: newPane,
1413- Parent: stackNode,
1414- }
1415-
1416- currentActiveIndex := stackNode.ActiveStackIndex
1417- if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1418- currentActiveIndex = len(stackNode.Children) - 1
1419- if currentActiveIndex < 0 {
1420- currentActiveIndex = -1
1421- }
1422- }
1423- insertIndex := currentActiveIndex + 1
1424-
1425- stackNode.Children = append(stackNode.Children, nil)
1426- copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1427- stackNode.Children[insertIndex] = newChildNode
1428- stackNode.ActiveStackIndex = insertIndex
1429- return insertIndex
1430-}
1431-
14321442 func (c *WorkspaceCoordinator) updateFirstStackTitle(
14331443 ctx context.Context,
14341444 wsView *component.WorkspaceView,
@@ -1577,6 +1587,54 @@ func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID en
15771587 }
15781588 }
15791589
1590+// SetupStackedPaneCallbacks sets up title bar click and close callbacks for all stacked panes.
1591+// This must be called after Rebuild() to restore callbacks on newly created StackedView instances.
1592+func (c *WorkspaceCoordinator) SetupStackedPaneCallbacks(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
1593+ log := logging.FromContext(ctx)
1594+
1595+ if ws == nil || ws.Root == nil || wsView == nil {
1596+ return
1597+ }
1598+
1599+ tr := wsView.TreeRenderer()
1600+ if tr == nil {
1601+ return
1602+ }
1603+
1604+ // Walk the tree and set up callbacks for all stacked nodes
1605+ ws.Root.Walk(func(node *entity.PaneNode) bool {
1606+ if !node.IsStacked || len(node.Children) == 0 {
1607+ return true
1608+ }
1609+
1610+ // Get the StackedView for any pane in this stack
1611+ firstChild := node.Children[0]
1612+ if firstChild.Pane == nil {
1613+ return true
1614+ }
1615+
1616+ stackedView := tr.GetStackedViewForPane(string(firstChild.Pane.ID))
1617+ if stackedView == nil {
1618+ return true
1619+ }
1620+
1621+ capturedStackNode := node
1622+ stackedView.SetOnActivate(func(index int) {
1623+ c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1624+ })
1625+ stackedView.SetOnClosePane(func(paneID string) {
1626+ c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1627+ })
1628+
1629+ log.Debug().
1630+ Str("stack_id", node.ID).
1631+ Int("stack_size", len(node.Children)).
1632+ Msg("set up stacked pane callbacks")
1633+
1634+ return true
1635+ })
1636+}
1637+
15801638 // --- Popup Insertion ---
15811639
15821640 // SetOnCreatePopupTab sets the callback for creating popup tabs.
@@ -1726,6 +1784,7 @@ func (c *WorkspaceCoordinator) attachPopupWebView(
17261784 }
17271785
17281786 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.
1787+// Uses CreateStack and AddToStack use cases for proper domain model management.
17291788 func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {
17301789 log := logging.FromContext(ctx)
17311790
@@ -1739,16 +1798,17 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
17391798 return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
17401799 }
17411800
1742- stackNode := c.resolveStackNode(input, parentNode, log)
1743- if stackNode == nil {
1744- return fmt.Errorf("failed to resolve stack node for popup")
1745- }
1801+ // Resolve or create stack node
1802+ stackNode := c.resolveOrCreateStackNode(ctx, parentNode, input.ParentPaneID)
17461803
1747- c.insertStackChild(stackNode, input.PopupPane)
1804+ // Add popup pane to stack using use case
1805+ if _, err := c.panesUC.AddToStack(ctx, ws, stackNode, input.PopupPane); err != nil {
1806+ return fmt.Errorf("failed to add popup to stack: %w", err)
1807+ }
17481808
17491809 if wsView == nil {
1750- // No workspace view to update; keep domain model consistent.
1751- ws.ActivePaneID = input.PopupPane.ID
1810+ // No workspace view to update; domain model already updated by use case.
1811+ return nil
17521812 }
17531813
17541814 if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {
@@ -1763,27 +1823,37 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
17631823 return nil
17641824 }
17651825
1766-func (c *WorkspaceCoordinator) resolveStackNode(
1767- input InsertPopupInput,
1826+// resolveOrCreateStackNode finds an existing stack or creates one for popup insertion.
1827+// Note: For popups, we can't use CreateStack use case since it creates a new pane,
1828+// but we already have the popup pane. This conversion is a special case.
1829+func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
1830+ ctx context.Context,
17681831 parentNode *entity.PaneNode,
1769- log *zerolog.Logger,
1832+ parentPaneID entity.PaneID,
17701833 ) *entity.PaneNode {
1834+ log := logging.FromContext(ctx)
1835+
1836+ // Check if parent is already in a stack
17711837 if parentNode.Parent != nil && parentNode.Parent.IsStacked {
1838+ log.Debug().
1839+ Str("stack_id", parentNode.Parent.ID).
1840+ Msg("using existing parent stack for popup")
17721841 return parentNode.Parent
17731842 }
1843+
1844+ // Check if parent is already a stack container
17741845 if parentNode.IsStacked {
1846+ log.Debug().
1847+ Str("stack_id", parentNode.ID).
1848+ Msg("parent is already a stack container")
17751849 return parentNode
17761850 }
1777- return c.convertToStackedNode(input, parentNode, log)
1778-}
17791851
1780-func (c *WorkspaceCoordinator) convertToStackedNode(
1781- input InsertPopupInput,
1782- parentNode *entity.PaneNode,
1783- log *zerolog.Logger,
1784-) *entity.PaneNode {
1852+ // Need to convert leaf to stack for popup insertion.
1853+ // We can't use CreateStack use case since it creates a new pane,
1854+ // but we already have the popup pane to insert.
17851855 originalPane := parentNode.Pane
1786- originalTitle := c.contentCoord.GetTitle(input.ParentPaneID)
1856+ originalTitle := c.contentCoord.GetTitle(parentPaneID)
17871857 if originalTitle == "" {
17881858 originalTitle = originalPane.Title
17891859 }
@@ -1792,14 +1862,17 @@ func (c *WorkspaceCoordinator) convertToStackedNode(
17921862 }
17931863 originalPane.Title = originalTitle
17941864
1865+ // Create child node for the original pane
17951866 originalPaneChild := &entity.PaneNode{
17961867 ID: parentNode.ID + "_0",
17971868 Pane: originalPane,
17981869 Parent: parentNode,
17991870 }
18001871
1872+ // Convert parentNode to stack container
18011873 parentNode.Pane = nil
18021874 parentNode.IsStacked = true
1875+ parentNode.ActiveStackIndex = 0
18031876 parentNode.Children = []*entity.PaneNode{originalPaneChild}
18041877
18051878 log.Debug().
@@ -1810,28 +1883,6 @@ func (c *WorkspaceCoordinator) convertToStackedNode(
18101883 return parentNode
18111884 }
18121885
1813-func (c *WorkspaceCoordinator) insertStackChild(stackNode *entity.PaneNode, pane *entity.Pane) {
1814- newChildNode := &entity.PaneNode{
1815- ID: stackNode.ID + "_popup_" + string(pane.ID),
1816- Pane: pane,
1817- Parent: stackNode,
1818- }
1819-
1820- currentActiveIndex := stackNode.ActiveStackIndex
1821- if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1822- currentActiveIndex = len(stackNode.Children) - 1
1823- if currentActiveIndex < 0 {
1824- currentActiveIndex = -1
1825- }
1826- }
1827- insertIndex := currentActiveIndex + 1
1828-
1829- stackNode.Children = append(stackNode.Children, nil)
1830- copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1831- stackNode.Children[insertIndex] = newChildNode
1832- stackNode.ActiveStackIndex = insertIndex
1833-}
1834-
18351886 func (c *WorkspaceCoordinator) attachPopupPaneView(
18361887 ctx context.Context,
18371888 input InsertPopupInput,
@@ -1933,6 +1984,7 @@ func (c *WorkspaceCoordinator) ConsumeOrExpelPane(ctx context.Context, direction
19331984 log.Warn().Err(err).Msg("failed to rebuild workspace view")
19341985 }
19351986 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1987+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
19361988 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
19371989 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
19381990 }
@@ -263,6 +263,7 @@ func (c *WorkspaceCoordinator) applySplitToView(
263 263
264 if needsAttach {264 if needsAttach {
265 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)265 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
266+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
266 }267 }
267 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {268 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
268 log.Warn().Err(err).Msg("failed to set active pane in workspace view")269 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
@@ -1055,6 +1056,7 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
1055 }1056 }
1056 c.contentCoord.ReleaseWebView(ctx, paneID)1057 c.contentCoord.ReleaseWebView(ctx, paneID)
1057 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)1058 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1059+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
1058 }1060 }
1059 } else {1061 } else {
1060 if err := wsView.Rebuild(ctx); err != nil {1062 if err := wsView.Rebuild(ctx); err != nil {
@@ -1062,6 +1064,7 @@ func (c *WorkspaceCoordinator) finalizePaneClose(
1062 }1064 }
1063 c.contentCoord.ReleaseWebView(ctx, paneID)1065 c.contentCoord.ReleaseWebView(ctx, paneID)
1064 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)1066 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1067+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
1065 }1068 }
1066 1069
1067 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {1070 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
@@ -1095,17 +1098,10 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose(
1095 return fmt.Errorf("stacked view not found for pane %s", closingPaneID)1098 return fmt.Errorf("stacked view not found for pane %s", closingPaneID)
1096 }1099 }
1097 1100
1098- // Find the index of the closing pane in the stack1101+ // Find the index of the closing pane in the StackedView UI
1099- closingIndex := -11102+ closingIndex := stackedView.FindPaneIndex(string(closingPaneID))
1100- for i, child := range stackNode.Children {
1101- if child.Pane != nil && child.Pane.ID == closingPaneID {
1102- closingIndex = i
1103- break
1104- }
1105- }
1106-
1107 if closingIndex < 0 {1103 if closingIndex < 0 {
1108- return fmt.Errorf("pane %s not found in stack", closingPaneID)1104+ return fmt.Errorf("pane %s not found in stacked view", closingPaneID)
1109 }1105 }
1110 1106
1111 // Remove the pane from the StackedView widget1107 // Remove the pane from the StackedView widget
@@ -1120,12 +1116,48 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose(
1120 // Release the closing pane's webview1116 // Release the closing pane's webview
1121 c.contentCoord.ReleaseWebView(ctx, closingPaneID)1117 c.contentCoord.ReleaseWebView(ctx, closingPaneID)
1122 1118
1119+ // Sync remaining title bars with current titles from ContentCoordinator.
1120+ // The domain model (stackNode.Children) has already been updated by the use case,
1121+ // so we iterate the remaining children and update their titles in the UI.
1122+ c.syncStackedPaneTitles(ctx, stackedView, stackNode)
1123+
1123 log.Debug().1124 log.Debug().
1124 Int("removed_index", closingIndex).1125 Int("removed_index", closingIndex).
1125 Msg("incremental stack close completed successfully")1126 Msg("incremental stack close completed successfully")
1126 return nil1127 return nil
1127 }1128 }
1128 1129
1130+// syncStackedPaneTitles updates all title bars in a StackedView with current titles.
1131+func (c *WorkspaceCoordinator) syncStackedPaneTitles(ctx context.Context, stackedView *layout.StackedView, stackNode *entity.PaneNode) {
1132+ log := logging.FromContext(ctx)
1133+
1134+ if stackedView == nil || stackNode == nil {
1135+ return
1136+ }
1137+
1138+ for i, child := range stackNode.Children {
1139+ if child.Pane == nil {
1140+ continue
1141+ }
1142+
1143+ title := c.contentCoord.GetTitle(child.Pane.ID)
1144+ if title == "" {
1145+ title = child.Pane.Title
1146+ }
1147+ if title == "" {
1148+ title = defaultPaneTitle
1149+ }
1150+
1151+ if err := stackedView.UpdateTitle(i, title); err != nil {
1152+ log.Warn().
1153+ Err(err).
1154+ Int("index", i).
1155+ Str("pane_id", string(child.Pane.ID)).
1156+ Msg("failed to sync title bar")
1157+ }
1158+ }
1159+}
1160+
1129 // FocusPane navigates focus to an adjacent pane.1161 // FocusPane navigates focus to an adjacent pane.
1130 func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.NavigateDirection) error {1162 func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.NavigateDirection) error {
1131 log := logging.FromContext(ctx)1163 log := logging.FromContext(ctx)
@@ -1228,6 +1260,7 @@ func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView
1228 }1260 }
1229 1261
1230 // StackPane adds a new pane stacked on top of the active pane.1262 // StackPane adds a new pane stacked on top of the active pane.
1263+// Uses CreateStack use case for new stacks, AddToStack for existing stacks.
1231 func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {1264 func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1232 log := logging.FromContext(ctx)1265 log := logging.FromContext(ctx)
1233 1266
@@ -1236,19 +1269,64 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1236 return nil1269 return nil
1237 }1270 }
1238 1271
1272+ // Create new pane entity
1239 newPaneID := entity.PaneID(c.generateID())1273 newPaneID := entity.PaneID(c.generateID())
1240 newPane := entity.NewPane(newPaneID)1274 newPane := entity.NewPane(newPaneID)
1241 newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL)1275 newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL)
1242 newPane.Title = defaultPaneTitle1276 newPane.Title = defaultPaneTitle
1243 1277
1244- stackNode, needsFirstPaneTitleUpdate := c.ensureStackNode(ctx, stackCtx.activeNode, stackCtx.activePaneID, stackCtx.originalTitle)1278+ // Determine if we need to create a new stack or add to existing
1245- insertIndex := insertStackChild(stackNode, newPaneID, newPane)1279+ var stackNode *entity.PaneNode
1280+ var needsFirstPaneTitleUpdate bool
1246 1281
1247- log.Debug().1282+ if stackCtx.activeNode.Parent != nil && stackCtx.activeNode.Parent.IsStacked {
1248- Int("stack_size", len(stackNode.Children)).1283+ // Already in a stack - use AddToStack use case
1249- Int("insert_index", insertIndex).1284+ stackNode = stackCtx.activeNode.Parent
1250- Int("active_index", stackNode.ActiveStackIndex).1285+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)
1251- Msg("domain tree updated with position-aware insertion")1286+ if err != nil {
1287+ log.Error().Err(err).Msg("failed to add pane to stack via use case")
1288+ return err
1289+ }
1290+ log.Debug().
1291+ Int("stack_size", len(stackNode.Children)).
1292+ Int("insert_index", output.StackIndex).
1293+ Msg("added to existing stack via use case")
1294+ } else if stackCtx.activeNode.IsStacked {
1295+ // Active node is already a stack container - add to it
1296+ stackNode = stackCtx.activeNode
1297+ output, err := c.panesUC.AddToStack(ctx, stackCtx.ws, stackNode, newPane)
1298+ if err != nil {
1299+ log.Error().Err(err).Msg("failed to add pane to stack via use case")
1300+ return err
1301+ }
1302+ log.Debug().
1303+ Int("stack_size", len(stackNode.Children)).
1304+ Int("insert_index", output.StackIndex).
1305+ Msg("added to stack container via use case")
1306+ } else {
1307+ // Need to create a new stack - use CreateStack use case
1308+ // But CreateStack creates its own pane, so we need a different approach
1309+ // Convert to stack first, then the new pane is already created
1310+ output, err := c.panesUC.CreateStack(ctx, stackCtx.ws, stackCtx.activeNode)
1311+ if err != nil {
1312+ log.Error().Err(err).Msg("failed to create stack via use case")
1313+ return err
1314+ }
1315+ stackNode = output.StackNode
1316+ // CreateStack creates its own new pane, use that instead
1317+ newPane = output.NewPane
1318+ newPaneID = newPane.ID
1319+ needsFirstPaneTitleUpdate = true
1320+
1321+ // Update the original pane's title in the domain
1322+ if output.OriginalNode != nil && output.OriginalNode.Pane != nil {
1323+ output.OriginalNode.Pane.Title = stackCtx.originalTitle
1324+ }
1325+
1326+ log.Debug().
1327+ Int("stack_size", len(stackNode.Children)).
1328+ Msg("created new stack via use case")
1329+ }
1252 1330
1253 // Create PaneView for the new pane1331 // Create PaneView for the new pane
1254 newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)1332 newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
@@ -1281,9 +1359,6 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1281 }1359 }
1282 }1360 }
1283 1361
1284- // Update workspace active pane ID
1285- stackCtx.ws.ActivePaneID = newPaneID
1286-
1287 // Update workspace view1362 // Update workspace view
1288 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {1363 if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
1289 log.Warn().Err(err).Msg("failed to set active pane")1364 log.Warn().Err(err).Msg("failed to set active pane")
@@ -1364,71 +1439,6 @@ func (c *WorkspaceCoordinator) prepareStackPane(
1364 }, true1439 }, true
1365 }1440 }
1366 1441
1367-func (c *WorkspaceCoordinator) ensureStackNode(
1368- ctx context.Context,
1369- activeNode *entity.PaneNode,
1370- activePaneID entity.PaneID,
1371- originalTitle string,
1372-) (*entity.PaneNode, bool) {
1373- log := logging.FromContext(ctx)
1374- if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1375- stackNode := activeNode.Parent
1376- log.Debug().
1377- Str("stack_id", stackNode.ID).
1378- Str("active_pane", string(activePaneID)).
1379- Int("stack_size", len(stackNode.Children)).
1380- Msg("adding to existing stack via parent")
1381- return stackNode, false
1382- }
1383-
1384- if activeNode.IsStacked {
1385- return activeNode, false
1386- }
1387-
1388- originalPane := activeNode.Pane
1389- originalPane.Title = originalTitle
1390- originalPaneChild := &entity.PaneNode{
1391- ID: activeNode.ID + "_0",
1392- Pane: originalPane,
1393- Parent: activeNode,
1394- }
1395-
1396- activeNode.Pane = nil
1397- activeNode.IsStacked = true
1398- activeNode.Children = []*entity.PaneNode{originalPaneChild}
1399-
1400- log.Debug().
1401- Str("node_id", activeNode.ID).
1402- Str("original_pane", string(originalPane.ID)).
1403- Str("original_title", originalTitle).
1404- Msg("converted leaf to stacked node")
1405-
1406- return activeNode, true
1407-}
1408-
1409-func insertStackChild(stackNode *entity.PaneNode, newPaneID entity.PaneID, newPane *entity.Pane) int {
1410- newChildNode := &entity.PaneNode{
1411- ID: stackNode.ID + "_" + string(newPaneID),
1412- Pane: newPane,
1413- Parent: stackNode,
1414- }
1415-
1416- currentActiveIndex := stackNode.ActiveStackIndex
1417- if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1418- currentActiveIndex = len(stackNode.Children) - 1
1419- if currentActiveIndex < 0 {
1420- currentActiveIndex = -1
1421- }
1422- }
1423- insertIndex := currentActiveIndex + 1
1424-
1425- stackNode.Children = append(stackNode.Children, nil)
1426- copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1427- stackNode.Children[insertIndex] = newChildNode
1428- stackNode.ActiveStackIndex = insertIndex
1429- return insertIndex
1430-}
1431-
1432 func (c *WorkspaceCoordinator) updateFirstStackTitle(1442 func (c *WorkspaceCoordinator) updateFirstStackTitle(
1433 ctx context.Context,1443 ctx context.Context,
1434 wsView *component.WorkspaceView,1444 wsView *component.WorkspaceView,
@@ -1577,6 +1587,54 @@ func (c *WorkspaceCoordinator) onStackedPaneClose(ctx context.Context, paneID en
1577 }1587 }
1578 }1588 }
1579 1589
1590+// SetupStackedPaneCallbacks sets up title bar click and close callbacks for all stacked panes.
1591+// This must be called after Rebuild() to restore callbacks on newly created StackedView instances.
1592+func (c *WorkspaceCoordinator) SetupStackedPaneCallbacks(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
1593+ log := logging.FromContext(ctx)
1594+
1595+ if ws == nil || ws.Root == nil || wsView == nil {
1596+ return
1597+ }
1598+
1599+ tr := wsView.TreeRenderer()
1600+ if tr == nil {
1601+ return
1602+ }
1603+
1604+ // Walk the tree and set up callbacks for all stacked nodes
1605+ ws.Root.Walk(func(node *entity.PaneNode) bool {
1606+ if !node.IsStacked || len(node.Children) == 0 {
1607+ return true
1608+ }
1609+
1610+ // Get the StackedView for any pane in this stack
1611+ firstChild := node.Children[0]
1612+ if firstChild.Pane == nil {
1613+ return true
1614+ }
1615+
1616+ stackedView := tr.GetStackedViewForPane(string(firstChild.Pane.ID))
1617+ if stackedView == nil {
1618+ return true
1619+ }
1620+
1621+ capturedStackNode := node
1622+ stackedView.SetOnActivate(func(index int) {
1623+ c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1624+ })
1625+ stackedView.SetOnClosePane(func(paneID string) {
1626+ c.onStackedPaneClose(ctx, entity.PaneID(paneID))
1627+ })
1628+
1629+ log.Debug().
1630+ Str("stack_id", node.ID).
1631+ Int("stack_size", len(node.Children)).
1632+ Msg("set up stacked pane callbacks")
1633+
1634+ return true
1635+ })
1636+}
1637+
1580 // --- Popup Insertion ---1638 // --- Popup Insertion ---
1581 1639
1582 // SetOnCreatePopupTab sets the callback for creating popup tabs.1640 // SetOnCreatePopupTab sets the callback for creating popup tabs.
@@ -1726,6 +1784,7 @@ func (c *WorkspaceCoordinator) attachPopupWebView(
1726 }1784 }
1727 1785
1728 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.1786 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.
1787+// Uses CreateStack and AddToStack use cases for proper domain model management.
1729 func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {1788 func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {
1730 log := logging.FromContext(ctx)1789 log := logging.FromContext(ctx)
1731 1790
@@ -1739,16 +1798,17 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
1739 return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)1798 return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
1740 }1799 }
1741 1800
1742- stackNode := c.resolveStackNode(input, parentNode, log)1801+ // Resolve or create stack node
1743- if stackNode == nil {1802+ stackNode := c.resolveOrCreateStackNode(ctx, parentNode, input.ParentPaneID)
1744- return fmt.Errorf("failed to resolve stack node for popup")
1745- }
1746 1803
1747- c.insertStackChild(stackNode, input.PopupPane)1804+ // Add popup pane to stack using use case
1805+ if _, err := c.panesUC.AddToStack(ctx, ws, stackNode, input.PopupPane); err != nil {
1806+ return fmt.Errorf("failed to add popup to stack: %w", err)
1807+ }
1748 1808
1749 if wsView == nil {1809 if wsView == nil {
1750- // No workspace view to update; keep domain model consistent.1810+ // No workspace view to update; domain model already updated by use case.
1751- ws.ActivePaneID = input.PopupPane.ID1811+ return nil
1752 }1812 }
1753 1813
1754 if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {1814 if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {
@@ -1763,27 +1823,37 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
1763 return nil1823 return nil
1764 }1824 }
1765 1825
1766-func (c *WorkspaceCoordinator) resolveStackNode(1826+// resolveOrCreateStackNode finds an existing stack or creates one for popup insertion.
1767- input InsertPopupInput,1827+// Note: For popups, we can't use CreateStack use case since it creates a new pane,
1828+// but we already have the popup pane. This conversion is a special case.
1829+func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
1830+ ctx context.Context,
1768 parentNode *entity.PaneNode,1831 parentNode *entity.PaneNode,
1769- log *zerolog.Logger,1832+ parentPaneID entity.PaneID,
1770 ) *entity.PaneNode {1833 ) *entity.PaneNode {
1834+ log := logging.FromContext(ctx)
1835+
1836+ // Check if parent is already in a stack
1771 if parentNode.Parent != nil && parentNode.Parent.IsStacked {1837 if parentNode.Parent != nil && parentNode.Parent.IsStacked {
1838+ log.Debug().
1839+ Str("stack_id", parentNode.Parent.ID).
1840+ Msg("using existing parent stack for popup")
1772 return parentNode.Parent1841 return parentNode.Parent
1773 }1842 }
1843+
1844+ // Check if parent is already a stack container
1774 if parentNode.IsStacked {1845 if parentNode.IsStacked {
1846+ log.Debug().
1847+ Str("stack_id", parentNode.ID).
1848+ Msg("parent is already a stack container")
1775 return parentNode1849 return parentNode
1776 }1850 }
1777- return c.convertToStackedNode(input, parentNode, log)
1778-}
1779 1851
1780-func (c *WorkspaceCoordinator) convertToStackedNode(1852+ // Need to convert leaf to stack for popup insertion.
1781- input InsertPopupInput,1853+ // We can't use CreateStack use case since it creates a new pane,
1782- parentNode *entity.PaneNode,1854+ // but we already have the popup pane to insert.
1783- log *zerolog.Logger,
1784-) *entity.PaneNode {
1785 originalPane := parentNode.Pane1855 originalPane := parentNode.Pane
1786- originalTitle := c.contentCoord.GetTitle(input.ParentPaneID)1856+ originalTitle := c.contentCoord.GetTitle(parentPaneID)
1787 if originalTitle == "" {1857 if originalTitle == "" {
1788 originalTitle = originalPane.Title1858 originalTitle = originalPane.Title
1789 }1859 }
@@ -1792,14 +1862,17 @@ func (c *WorkspaceCoordinator) convertToStackedNode(
1792 }1862 }
1793 originalPane.Title = originalTitle1863 originalPane.Title = originalTitle
1794 1864
1865+ // Create child node for the original pane
1795 originalPaneChild := &entity.PaneNode{1866 originalPaneChild := &entity.PaneNode{
1796 ID: parentNode.ID + "_0",1867 ID: parentNode.ID + "_0",
1797 Pane: originalPane,1868 Pane: originalPane,
1798 Parent: parentNode,1869 Parent: parentNode,
1799 }1870 }
1800 1871
1872+ // Convert parentNode to stack container
1801 parentNode.Pane = nil1873 parentNode.Pane = nil
1802 parentNode.IsStacked = true1874 parentNode.IsStacked = true
1875+ parentNode.ActiveStackIndex = 0
1803 parentNode.Children = []*entity.PaneNode{originalPaneChild}1876 parentNode.Children = []*entity.PaneNode{originalPaneChild}
1804 1877
1805 log.Debug().1878 log.Debug().
@@ -1810,28 +1883,6 @@ func (c *WorkspaceCoordinator) convertToStackedNode(
1810 return parentNode1883 return parentNode
1811 }1884 }
1812 1885
1813-func (c *WorkspaceCoordinator) insertStackChild(stackNode *entity.PaneNode, pane *entity.Pane) {
1814- newChildNode := &entity.PaneNode{
1815- ID: stackNode.ID + "_popup_" + string(pane.ID),
1816- Pane: pane,
1817- Parent: stackNode,
1818- }
1819-
1820- currentActiveIndex := stackNode.ActiveStackIndex
1821- if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1822- currentActiveIndex = len(stackNode.Children) - 1
1823- if currentActiveIndex < 0 {
1824- currentActiveIndex = -1
1825- }
1826- }
1827- insertIndex := currentActiveIndex + 1
1828-
1829- stackNode.Children = append(stackNode.Children, nil)
1830- copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
1831- stackNode.Children[insertIndex] = newChildNode
1832- stackNode.ActiveStackIndex = insertIndex
1833-}
1834-
1835 func (c *WorkspaceCoordinator) attachPopupPaneView(1886 func (c *WorkspaceCoordinator) attachPopupPaneView(
1836 ctx context.Context,1887 ctx context.Context,
1837 input InsertPopupInput,1888 input InsertPopupInput,
@@ -1933,6 +1984,7 @@ func (c *WorkspaceCoordinator) ConsumeOrExpelPane(ctx context.Context, direction
1933 log.Warn().Err(err).Msg("failed to rebuild workspace view")1984 log.Warn().Err(err).Msg("failed to rebuild workspace view")
1934 }1985 }
1935 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)1986 c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
1987+ c.SetupStackedPaneCallbacks(ctx, ws, wsView)
1936 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {1988 if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
1937 log.Warn().Err(err).Msg("failed to set active pane in workspace view")1989 log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1938 }1990 }
M internal/ui/deps.go +3 -0
@@ -31,6 +31,9 @@ type Dependencies struct {
3131 ColorResolver port.ColorSchemeResolver
3232 AdwaitaDetector *colorscheme.AdwaitaDetector
3333
34+ // XDG paths
35+ XDG port.XDGPaths
36+
3437 // WebKit infrastructure
3538 WebContext *webkit.WebKitContext
3639 Pool *webkit.WebViewPool
@@ -31,6 +31,9 @@ type Dependencies struct {
31 ColorResolver port.ColorSchemeResolver31 ColorResolver port.ColorSchemeResolver
32 AdwaitaDetector *colorscheme.AdwaitaDetector32 AdwaitaDetector *colorscheme.AdwaitaDetector
33 33
34+ // XDG paths
35+ XDG port.XDGPaths
36+
34 // WebKit infrastructure37 // WebKit infrastructure
35 WebContext *webkit.WebKitContext38 WebContext *webkit.WebKitContext
36 Pool *webkit.WebViewPool39 Pool *webkit.WebViewPool
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