Merge pull request #117 from bnema/next
Release v0.25.0
48 files · +1552 -925
M .github/workflows/aur.yml +1 -1
| @@ -88,7 +88,7 @@ jobs: | |||
| 88 | 88 | 'package() {' \ | |
| 89 | 89 | ' cd dumber' \ | |
| 90 | 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 | 92 | ' install -Dm644 assets/logo-512.png "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png"' \ | |
| 93 | 93 | ' install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"' \ | |
| 94 | 94 | '}' \ | |
| @@ -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: | |||
| 94 | 94 | url: https://github.com/bnema/dumber/releases/download/${VERSION}/dumber_linux_x86_64.tar.gz | |
| 95 | 95 | sha256: ${SHA256} | |
| 96 | 96 | - type: file | |
| 97 | - | path: dev.bnema.Dumber.desktop | |
| 97 | + | path: flatpak/dev.bnema.Dumber.desktop | |
| 98 | + | dest-filename: dev.bnema.Dumber.desktop | |
| 98 | 99 | - type: file | |
| 99 | 100 | path: assets/logo.svg | |
| 100 | 101 | dest-filename: logo.svg | |
| @@ -102,7 +103,8 @@ jobs: | |||
| 102 | 103 | path: assets/logo-512.png | |
| 103 | 104 | dest-filename: logo-512.png | |
| 104 | 105 | - 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 | |
| 106 | 108 | EOF | |
| 107 | 109 | echo "Generated manifest:" | |
| 108 | 110 | 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.gz | 94 | url: https://github.com/bnema/dumber/releases/download/${VERSION}/dumber_linux_x86_64.tar.gz | ||
| 95 | sha256: ${SHA256} | 95 | sha256: ${SHA256} | ||
| 96 | - type: file | 96 | - type: file | ||
| 97 | - | path: dev.bnema.Dumber.desktop | 97 | + | path: flatpak/dev.bnema.Dumber.desktop |
| 98 | + | dest-filename: dev.bnema.Dumber.desktop | |||
| 98 | - type: file | 99 | - type: file | ||
| 99 | path: assets/logo.svg | 100 | path: assets/logo.svg | ||
| 100 | dest-filename: logo.svg | 101 | dest-filename: logo.svg | ||
| @@ -102,7 +103,8 @@ jobs: | |||||
| 102 | path: assets/logo-512.png | 103 | path: assets/logo-512.png | ||
| 103 | dest-filename: logo-512.png | 104 | dest-filename: logo-512.png | ||
| 104 | - type: file | 105 | - 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 | |||
| 106 | EOF | 108 | EOF | ||
| 107 | echo "Generated manifest:" | 109 | echo "Generated manifest:" | ||
| 108 | cat dev.bnema.Dumber.yml | 110 | cat dev.bnema.Dumber.yml | ||
M .goreleaser.yaml +3 -1
| @@ -45,7 +45,9 @@ archives: | |||
| 45 | 45 | - LICENSE | |
| 46 | 46 | - README.md | |
| 47 | 47 | - CHANGELOG.md | |
| 48 | - | - dev.bnema.Dumber.desktop | |
| 48 | + | - src: flatpak/dev.bnema.Dumber.desktop | |
| 49 | + | dst: . | |
| 50 | + | strip_parent: true | |
| 49 | 51 | - src: assets/logo-512.png | |
| 50 | 52 | dst: . | |
| 51 | 53 | strip_parent: true | |
| @@ -45,7 +45,9 @@ archives: | |||||
| 45 | - LICENSE | 45 | - LICENSE | ||
| 46 | - README.md | 46 | - README.md | ||
| 47 | - CHANGELOG.md | 47 | - CHANGELOG.md | ||
| 48 | - | - dev.bnema.Dumber.desktop | 48 | + | - src: flatpak/dev.bnema.Dumber.desktop |
| 49 | + | dst: . | |||
| 50 | + | strip_parent: true | |||
| 49 | - src: assets/logo-512.png | 51 | - src: assets/logo-512.png | ||
| 50 | dst: . | 52 | dst: . | ||
| 51 | strip_parent: true | 53 | strip_parent: true | ||
M CHANGELOG.md +22 -0
| @@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file. | |||
| 4 | 4 | ||
| 5 | 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 | 29 | ## [0.24.0] - 2026-01-06 | |
| 8 | 30 | ||
| 9 | 31 | ### 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-06 | 29 | ## [0.24.0] - 2026-01-06 | ||
| 8 | 30 | ||||
| 9 | ### Added | 31 | ### Added | ||
M aur/dumber-browser-git/PKGBUILD +1 -1
| @@ -43,7 +43,7 @@ build() { | |||
| 43 | 43 | package() { | |
| 44 | 44 | cd dumber | |
| 45 | 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 | 47 | install -Dm644 assets/logo-512.png "${pkgdir}/usr/share/icons/hicolor/512x512/apps/dev.bnema.Dumber.png" | |
| 48 | 48 | install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" | |
| 49 | 49 | } | |
| @@ -43,7 +43,7 @@ build() { | |||||
| 43 | package() { | 43 | package() { | ||
| 44 | cd dumber | 44 | 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 ( | |||
| 25 | 25 | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| 26 | 26 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 27 | 27 | "github.com/bnema/dumber/internal/infrastructure/updater" | |
| 28 | + | "github.com/bnema/dumber/internal/infrastructure/xdg" | |
| 28 | 29 | "github.com/bnema/dumber/internal/logging" | |
| 29 | 30 | "github.com/bnema/dumber/internal/ui" | |
| 30 | 31 | "github.com/bnema/dumber/internal/ui/theme" | |
| @@ -428,6 +429,7 @@ func buildUIDependencies( | |||
| 428 | 429 | Theme: themeManager, | |
| 429 | 430 | ColorResolver: colorResolver, | |
| 430 | 431 | AdwaitaDetector: adwaitaDetector, | |
| 432 | + | XDG: xdg.New(), | |
| 431 | 433 | WebContext: stack.Context, | |
| 432 | 434 | Pool: stack.Pool, | |
| 433 | 435 | 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 | |||
| 597 | 597 | ||
| 598 | 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 | 617 | ## Environment Variables | |
| 601 | 618 | ||
| 602 | 619 | 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 Variables | 617 | ## 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 @@ | |||
| 1 | 1 | [Desktop Entry] | |
| 2 | 2 | Name=Dumber | |
| 3 | 3 | 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 | |
| 5 | 5 | Icon=dev.bnema.Dumber | |
| 6 | 6 | Terminal=false | |
| 7 | 7 | Type=Application | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | [Desktop Entry] | 1 | [Desktop Entry] | ||
| 2 | Name=Dumber | 2 | Name=Dumber | ||
| 3 | Comment=A minimal keyboard-driven browser for tiling WMs | 3 | 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 |
| 5 | Icon=dev.bnema.Dumber | 5 | Icon=dev.bnema.Dumber | ||
| 6 | Terminal=false | 6 | Terminal=false | ||
| 7 | Type=Application | 7 | 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 { | |||
| 143 | 143 | Stop(ctx context.Context) error | |
| 144 | 144 | ||
| 145 | 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 | 147 | GoBack(ctx context.Context) error | |
| 148 | 148 | ||
| 149 | 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 | 151 | GoForward(ctx context.Context) error | |
| 152 | 152 | ||
| 153 | 153 | // --- State Queries --- | |
| @@ -143,11 +143,11 @@ type WebView interface { | |||||
| 143 | Stop(ctx context.Context) error | 143 | 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) error | 147 | 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) error | 151 | 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 { | |||
| 14 | 14 | // ManDir returns the user man page directory (man1 section). | |
| 15 | 15 | // Typically $XDG_DATA_HOME/man/man1 or ~/.local/share/man/man1. | |
| 16 | 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 | } | |
| @@ -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 { | |||
| 455 | 455 | ||
| 456 | 456 | // Close removes a pane and promotes its sibling. | |
| 457 | 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 | 459 | func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) { | |
| 459 | 460 | log := logging.FromContext(ctx) | |
| 460 | 461 | if uc == nil { | |
| @@ -482,7 +483,12 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p | |||
| 482 | 483 | return nil, nil | |
| 483 | 484 | } | |
| 484 | 485 | ||
| 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) | |
| 486 | 492 | var sibling *entity.PaneNode | |
| 487 | 493 | for _, child := range parent.Children { | |
| 488 | 494 | if child != paneNode { | |
| @@ -539,6 +545,94 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p | |||
| 539 | 545 | 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 | 636 | // Focus sets the active pane in the workspace. | |
| 543 | 637 | // Delegates to ApplyFocusChange to ensure stack index is updated when focusing a stacked pane. | |
| 544 | 638 | 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, nil | 483 | return nil, nil | ||
| 483 | } | 484 | } | ||
| 484 | 485 | ||||
| 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) | |||
| 486 | var sibling *entity.PaneNode | 492 | 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, nil | 545 | 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 | |||
| 240 | 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 | 243 | // Stop stops the current page load. | |
| 270 | 244 | func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error { | |
| 271 | 245 | 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 ( | |||
| 9 | 9 | "github.com/bnema/dumber/assets" | |
| 10 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 11 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | var setupCmd = &cobra.Command{ | |
| @@ -62,6 +63,23 @@ func runSetupInstall(_ *cobra.Command, _ []string) error { | |||
| 62 | 63 | } | |
| 63 | 64 | ||
| 64 | 65 | 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 | 83 | adapter := desktop.New() | |
| 66 | 84 | uc := usecase.NewInstallDesktopUseCase(adapter) | |
| 67 | 85 | ||
| @@ -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.Theme | 65 | 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 { | |||
| 47 | 47 | "Session", | |
| 48 | 48 | "Omnibox", | |
| 49 | 49 | "Content Filtering", | |
| 50 | + | "Clipboard", | |
| 50 | 51 | "Rendering", | |
| 51 | 52 | "Media", | |
| 52 | 53 | "Update", | |
| 54 | + | "Downloads", | |
| 53 | 55 | "Debug", | |
| 54 | 56 | "Performance", | |
| 55 | 57 | "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 { | |||
| 156 | 156 | }) | |
| 157 | 157 | 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 | + | } | |
| @@ -156,3 +156,37 @@ func (n *PaneNode) LeafCount() int { | |||||
| 156 | }) | 156 | }) | ||
| 157 | return count | 157 | 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 { | |||
| 64 | 64 | }) | |
| 65 | 65 | 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 | + | } | |
| @@ -64,3 +64,12 @@ func (w *Workspace) AllPanes() []*Pane { | |||||
| 64 | }) | 64 | }) | ||
| 65 | return panes | 65 | 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 { | |||
| 311 | 311 | AutoDownload: false, // Conservative: don't auto-download by default | |
| 312 | 312 | 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 | ||
| @@ -311,6 +311,9 @@ func DefaultConfig() *Config { | |||||
| 311 | AutoDownload: false, // Conservative: don't auto-download by default | 311 | AutoDownload: false, // Conservative: don't auto-download by default | ||
| 312 | NotifyOnNewSettings: true, // Show toast when new config settings available | 312 | 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() { | |||
| 365 | 365 | m.setSessionDefaults(defaults) | |
| 366 | 366 | m.setUpdateDefaults(defaults) | |
| 367 | 367 | m.setPerformanceDefaults(defaults) | |
| 368 | + | m.setDownloadsDefaults(defaults) | |
| 368 | 369 | } | |
| 369 | 370 | ||
| 370 | 371 | func (m *Manager) setHistoryDefaults(defaults *Config) { | |
| @@ -533,6 +534,10 @@ func (m *Manager) setPerformanceDefaults(defaults *Config) { | |||
| 533 | 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 | 541 | // New returns a new default configuration instance. | |
| 537 | 542 | // This is a convenience function for getting default config without the full manager. | |
| 538 | 543 | 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 { | |||
| 35 | 35 | Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"` | |
| 36 | 36 | // Update controls automatic update checking and downloading. | |
| 37 | 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 | 42 | // RenderingMode selects GPU vs CPU rendering. | |
| @@ -372,6 +374,13 @@ type UpdateConfig struct { | |||
| 372 | 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 | 384 | // WorkspaceConfig captures layout, pane, and tab behavior preferences. | |
| 376 | 385 | type WorkspaceConfig struct { | |
| 377 | 386 | // 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 ( | |||
| 25 | 25 | SectionRuntime = "Runtime" | |
| 26 | 26 | SectionDatabase = "Database" | |
| 27 | 27 | SectionSearch = "Search" | |
| 28 | + | SectionDownloads = "Downloads" | |
| 28 | 29 | ) | |
| 29 | 30 | ||
| 30 | 31 | // SchemaProvider implements port.ConfigSchemaProvider. | |
| @@ -92,6 +93,9 @@ func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo { | |||
| 92 | 93 | // Database section | |
| 93 | 94 | keys = append(keys, p.getDatabaseKeys()...) | |
| 94 | 95 | ||
| 96 | + | // Downloads section | |
| 97 | + | keys = append(keys, p.getDownloadsKeys(defaults)...) | |
| 98 | + | ||
| 95 | 99 | 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 | + | } | |
| @@ -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 section | 93 | // 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 keys | 99 | 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 { | |||
| 185 | 185 | dirs.ConfigHome, | |
| 186 | 186 | dirs.DataHome, | |
| 187 | 187 | dirs.StateHome, | |
| 188 | + | dirs.CacheHome, | |
| 188 | 189 | } | |
| 189 | 190 | ||
| 190 | 191 | 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 ( | |||
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | 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 | |
| 23 | 24 | ) | |
| 24 | 25 | ||
| 25 | 26 | // desktopFileTemplate is the freedesktop.org desktop entry format. | |
| @@ -157,7 +158,7 @@ func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus | |||
| 157 | 158 | out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output() | |
| 158 | 159 | if err == nil { | |
| 159 | 160 | currentDefault := strings.TrimSpace(string(out)) | |
| 160 | - | status.IsDefaultBrowser = currentDefault == desktopFileName | |
| 161 | + | 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 | 307 | return fmt.Errorf("xdg-settings not found (install xdg-utils)") | |
| 307 | 308 | } | |
| 308 | 309 | ||
| 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 == "" { | |
| 315 | 314 | return fmt.Errorf("desktop file not installed - run 'dumber setup install' first") | |
| 316 | 315 | } | |
| 317 | 316 | ||
| 318 | 317 | // 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 | 319 | if out, err := cmd.CombinedOutput(); err != nil { | |
| 321 | 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 | 324 | 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 | 357 | // UnsetAsDefaultBrowser resets default browser if dumber is currently default. | |
| 329 | 358 | func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error { | |
| 330 | 359 | 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 = 0644 | 21 | + | iconFileName = "dumber.svg" |
| 22 | - | dirPerm = 0755 | 22 | + | 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 == desktopFileName | 161 | + | 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 installed | 310 | + | // 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 err | 313 | + | 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 browser | 317 | // 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 nil | 324 | 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() { | |||
| 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 | 230 | // Clear removes all entries from the in-memory cache. | |
| 222 | 231 | func (c *Cache) Clear() { | |
| 223 | 232 | 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 { | |||
| 185 | 185 | s.cache.WritePNGSized(InternalDomain, pngData, size) | |
| 186 | 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 | + | } | |
| @@ -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 ( | |||
| 23 | 23 | "time" | |
| 24 | 24 | ||
| 25 | 25 | "github.com/bnema/dumber/internal/domain/entity" | |
| 26 | + | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 26 | 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 | 94 | }, 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 | 111 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPIURL, http.NoBody) | |
| 97 | 112 | if err != nil { | |
| 98 | 113 | 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 | }, nil | 94 | }, 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 { | |||
| 26 | 26 | dataDir string | |
| 27 | 27 | cacheDir string | |
| 28 | 28 | ||
| 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 | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | // NewWebKitContext creates and initializes a WebKitContext with a persistent NetworkSession. | |
| @@ -236,3 +237,27 @@ func (c *WebKitContext) Close() error { | |||
| 236 | 237 | c.logger.Debug().Msg("webkit context closed") | |
| 237 | 238 | 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 | + | } | |
| @@ -26,9 +26,10 @@ type WebKitContext struct { | |||||
| 26 | dataDir string | 26 | dataDir string | ||
| 27 | cacheDir string | 27 | cacheDir string | ||
| 28 | 28 | ||||
| 29 | - | logger zerolog.Logger | 29 | + | logger zerolog.Logger |
| 30 | - | mu sync.RWMutex | 30 | + | mu sync.RWMutex |
| 31 | - | initialized bool | 31 | + | 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 nil | 238 | 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 { | |||
| 761 | 761 | } | |
| 762 | 762 | ||
| 763 | 763 | // GoBack navigates back in history. | |
| 764 | + | // Uses WebKit native navigation first, falls back to JavaScript history.back() for SPA compatibility. | |
| 764 | 765 | func (wv *WebView) GoBack(ctx context.Context) error { | |
| 765 | 766 | if wv.destroyed.Load() { | |
| 766 | 767 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 767 | 768 | } | |
| 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 | |
| 770 | 776 | } | |
| 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)") | |
| 773 | 782 | return nil | |
| 774 | 783 | } | |
| 775 | 784 | ||
| 776 | 785 | // GoForward navigates forward in history. | |
| 786 | + | // Uses WebKit native navigation first, falls back to JavaScript history.forward() for SPA compatibility. | |
| 777 | 787 | func (wv *WebView) GoForward(ctx context.Context) error { | |
| 778 | 788 | if wv.destroyed.Load() { | |
| 779 | 789 | return fmt.Errorf("webview %d is destroyed", wv.id) | |
| 780 | 790 | } | |
| 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 | |
| 783 | 798 | } | |
| 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)") | |
| 786 | 804 | return nil | |
| 787 | 805 | } | |
| 788 | 806 | ||
| @@ -950,6 +968,21 @@ func (wv *WebView) ShowDevTools() error { | |||
| 950 | 968 | 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 | 986 | // IsDestroyed returns true if the WebView has been destroyed. | |
| 954 | 987 | func (wv *WebView) IsDestroyed() bool { | |
| 955 | 988 | 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 nil | 782 | 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 nil | 804 | return nil | ||
| 787 | } | 805 | } | ||
| 788 | 806 | ||||
| @@ -950,6 +968,21 @@ func (wv *WebView) ShowDevTools() error { | |||||
| 950 | return nil | 968 | 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) { | |||
| 72 | 72 | 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 | 89 | var _ port.XDGPaths = (*Adapter)(nil) | |
| @@ -72,4 +72,18 @@ func (a *Adapter) ManDir() (string, error) { | |||||
| 72 | return filepath.Join(dataHome, "man", "man1"), nil | 72 | 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 | |||
| 166 | 166 | return | |
| 167 | 167 | } | |
| 168 | 168 | ||
| 169 | - | log := logging.FromContext(ctx) | |
| 170 | - | ||
| 171 | 169 | // Store in texture cache | |
| 172 | 170 | a.setTexture(domain, texture) | |
| 173 | 171 | ||
| 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) | |
| 197 | 174 | } | |
| 198 | 175 | ||
| 199 | 176 | // StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL. | |
| @@ -205,34 +182,57 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin( | |||
| 205 | 182 | ||
| 206 | 183 | // Also store under original URL domain if different | |
| 207 | 184 | if originURL != "" && originURL != currentURL { | |
| 208 | - | log := logging.FromContext(ctx) | |
| 209 | 185 | originDomain := domainurl.ExtractDomain(originURL) | |
| 210 | 186 | currentDomain := domainurl.ExtractDomain(currentURL) | |
| 211 | 187 | if originDomain != "" && originDomain != currentDomain { | |
| 212 | 188 | a.setTexture(originDomain, texture) | |
| 189 | + | a.saveFaviconToDisk(ctx, originDomain, texture) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | } | |
| 213 | 193 | ||
| 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) | |
| 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 | 238 | // 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 | return | 166 | return | ||
| 167 | } | 167 | } | ||
| 168 | 168 | ||||
| 169 | - | log := logging.FromContext(ctx) | |||
| 170 | - | ||||
| 171 | // Store in texture cache | 169 | // 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 thread | 172 | + | // Export as PNG and ensure disk cache |
| 175 | - | // since GTK texture operations need to happen there | 173 | + | 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 different | 183 | // 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 too | 194 | + | // 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 ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | + | "path/filepath" | |
| 8 | 9 | "sync" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -240,6 +241,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 240 | 241 | a.initAppToasterOverlay() | |
| 241 | 242 | a.initFocusAndBorderOverlay() | |
| 242 | 243 | a.initAccentPicker(ctx) | |
| 244 | + | a.initDownloadHandler(ctx) | |
| 243 | 245 | ||
| 244 | 246 | a.initCoordinators(ctx) | |
| 245 | 247 | a.initKeyboardHandler(ctx) | |
| @@ -453,6 +455,72 @@ func (a *App) initAccentPicker(ctx context.Context) { | |||
| 453 | 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 | 524 | func (a *App) initKeyboardHandler(ctx context.Context) { | |
| 457 | 525 | if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil { | |
| 458 | 526 | return | |
| @@ -865,6 +933,9 @@ func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, | |||
| 865 | 933 | return | |
| 866 | 934 | } | |
| 867 | 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 | 941 | 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 | return | 526 | return | ||
| @@ -865,6 +933,9 @@ func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, | |||||
| 865 | return | 933 | 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) { | |||
| 177 | 177 | return | |
| 178 | 178 | } | |
| 179 | 179 | 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 | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | 185 | // cancel cancels the accent picker. | |
| @@ -189,9 +189,9 @@ func (ap *AccentPicker) cancel() { | |||
| 189 | 189 | ap.mu.Unlock() | |
| 190 | 190 | return | |
| 191 | 191 | } | |
| 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 | |
| 195 | 195 | } | |
| 196 | 196 | ||
| 197 | 197 | // setupKeyboardHandling sets up key event handling for navigation. | |
| @@ -177,9 +177,9 @@ func (ap *AccentPicker) selectAccent(index int) { | |||||
| 177 | return | 177 | 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 callbacks | 180 | + | 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 | return | 190 | return | ||
| 191 | } | 191 | } | ||
| 192 | - | // Invoke callback while holding lock to avoid race with Hide() clearing callbacks | 192 | + | 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 { | |||
| 297 | 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 | 301 | const singlePaneClass = "single-pane" | |
| 302 | 302 | ||
| 303 | 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 | 305 | // Must be called with the lock held. | |
| 305 | 306 | func (wv *WorkspaceView) updateSinglePaneModeInternal() { | |
| 306 | 307 | if wv.container == nil { | |
| 307 | 308 | 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 | 317 | wv.container.AddCssClass(singlePaneClass) | |
| 312 | 318 | } else { | |
| 313 | 319 | 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 | return | 308 | 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 { | |||
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | 135 | // GoBack navigates back in history. | |
| 136 | + | // Calls webview directly - the webview layer handles SPA navigation via JS fallback. | |
| 136 | 137 | func (c *NavigationCoordinator) GoBack(ctx context.Context) error { | |
| 137 | 138 | log := logging.FromContext(ctx) | |
| 138 | 139 | ||
| @@ -142,14 +143,11 @@ func (c *NavigationCoordinator) GoBack(ctx context.Context) error { | |||
| 142 | 143 | return nil | |
| 143 | 144 | } | |
| 144 | 145 | ||
| 145 | - | if c.navigateUC != nil { | |
| 146 | - | return c.navigateUC.GoBack(ctx, wv) | |
| 147 | - | } | |
| 148 | - | ||
| 149 | 146 | return wv.GoBack(ctx) | |
| 150 | 147 | } | |
| 151 | 148 | ||
| 152 | 149 | // GoForward navigates forward in history. | |
| 150 | + | // Calls webview directly - the webview layer handles SPA navigation via JS fallback. | |
| 153 | 151 | func (c *NavigationCoordinator) GoForward(ctx context.Context) error { | |
| 154 | 152 | log := logging.FromContext(ctx) | |
| 155 | 153 | ||
| @@ -159,10 +157,6 @@ func (c *NavigationCoordinator) GoForward(ctx context.Context) error { | |||
| 159 | 157 | return nil | |
| 160 | 158 | } | |
| 161 | 159 | ||
| 162 | - | if c.navigateUC != nil { | |
| 163 | - | return c.navigateUC.GoForward(ctx, wv) | |
| 164 | - | } | |
| 165 | - | ||
| 166 | 160 | return wv.GoForward(ctx) | |
| 167 | 161 | } | |
| 168 | 162 | ||
| @@ -200,6 +194,26 @@ func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error { | |||
| 200 | 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 | 217 | // UpdateHistoryTitle updates the title of a history entry after page load. | |
| 204 | 218 | func (c *NavigationCoordinator) UpdateHistoryTitle(ctx context.Context, paneID entity.PaneID, url, title string) { | |
| 205 | 219 | 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 nil | 143 | 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 nil | 157 | 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) { | |||
| 99 | 99 | // showUpdateNotification displays a toast notification about the update. | |
| 100 | 100 | func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *usecase.CheckUpdateOutput) { | |
| 101 | 101 | var msg string | |
| 102 | - | if result.CanAutoUpdate && c.config.Update.AutoDownload { | |
| 102 | + | switch { | |
| 103 | + | case result.CanAutoUpdate && c.config.Update.AutoDownload: | |
| 103 | 104 | msg = fmt.Sprintf("Downloading update %s...", result.LatestVersion) | |
| 104 | - | } else if result.CanAutoUpdate { | |
| 105 | + | default: | |
| 105 | 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 | 109 | // 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 string | 101 | 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( | |||
| 263 | 263 | ||
| 264 | 264 | if needsAttach { | |
| 265 | 265 | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |
| 266 | + | c.SetupStackedPaneCallbacks(ctx, ws, wsView) | |
| 266 | 267 | } | |
| 267 | 268 | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |
| 268 | 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 | 1057 | c.contentCoord.ReleaseWebView(ctx, paneID) | |
| 1057 | 1058 | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |
| 1059 | + | c.SetupStackedPaneCallbacks(ctx, ws, wsView) | |
| 1058 | 1060 | } | |
| 1059 | 1061 | } else { | |
| 1060 | 1062 | if err := wsView.Rebuild(ctx); err != nil { | |
| @@ -1062,6 +1064,7 @@ func (c *WorkspaceCoordinator) finalizePaneClose( | |||
| 1062 | 1064 | } | |
| 1063 | 1065 | c.contentCoord.ReleaseWebView(ctx, paneID) | |
| 1064 | 1066 | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |
| 1067 | + | c.SetupStackedPaneCallbacks(ctx, ws, wsView) | |
| 1065 | 1068 | } | |
| 1066 | 1069 | ||
| 1067 | 1070 | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |
| @@ -1095,17 +1098,10 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose( | |||
| 1095 | 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 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)) | |
| 1107 | 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 | 1107 | // Remove the pane from the StackedView widget | |
| @@ -1120,12 +1116,48 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose( | |||
| 1120 | 1116 | // Release the closing pane's webview | |
| 1121 | 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 | 1124 | log.Debug(). | |
| 1124 | 1125 | Int("removed_index", closingIndex). | |
| 1125 | 1126 | Msg("incremental stack close completed successfully") | |
| 1126 | 1127 | 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 | 1161 | // FocusPane navigates focus to an adjacent pane. | |
| 1130 | 1162 | func (c *WorkspaceCoordinator) FocusPane(ctx context.Context, direction usecase.NavigateDirection) error { | |
| 1131 | 1163 | log := logging.FromContext(ctx) | |
| @@ -1228,6 +1260,7 @@ func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView | |||
| 1228 | 1260 | } | |
| 1229 | 1261 | ||
| 1230 | 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 | 1264 | func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |
| 1232 | 1265 | log := logging.FromContext(ctx) | |
| 1233 | 1266 | ||
| @@ -1236,19 +1269,64 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1236 | 1269 | return nil | |
| 1237 | 1270 | } | |
| 1238 | 1271 | ||
| 1272 | + | // Create new pane entity | |
| 1239 | 1273 | newPaneID := entity.PaneID(c.generateID()) | |
| 1240 | 1274 | newPane := entity.NewPane(newPaneID) | |
| 1241 | 1275 | newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL) | |
| 1242 | 1276 | newPane.Title = defaultPaneTitle | |
| 1243 | 1277 | ||
| 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 | |
| 1246 | 1281 | ||
| 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 | + | } | |
| 1252 | 1330 | ||
| 1253 | 1331 | // Create PaneView for the new pane | |
| 1254 | 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 | 1362 | // Update workspace view | |
| 1288 | 1363 | if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil { | |
| 1289 | 1364 | log.Warn().Err(err).Msg("failed to set active pane") | |
| @@ -1364,71 +1439,6 @@ func (c *WorkspaceCoordinator) prepareStackPane( | |||
| 1364 | 1439 | }, 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 | 1442 | func (c *WorkspaceCoordinator) updateFirstStackTitle( | |
| 1433 | 1443 | ctx context.Context, | |
| 1434 | 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 | 1638 | // --- Popup Insertion --- | |
| 1581 | 1639 | ||
| 1582 | 1640 | // SetOnCreatePopupTab sets the callback for creating popup tabs. | |
| @@ -1726,6 +1784,7 @@ func (c *WorkspaceCoordinator) attachPopupWebView( | |||
| 1726 | 1784 | } | |
| 1727 | 1785 | ||
| 1728 | 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 | 1788 | func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error { | |
| 1730 | 1789 | log := logging.FromContext(ctx) | |
| 1731 | 1790 | ||
| @@ -1739,16 +1798,17 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins | |||
| 1739 | 1798 | return fmt.Errorf("parent pane not found: %s", input.ParentPaneID) | |
| 1740 | 1799 | } | |
| 1741 | 1800 | ||
| 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) | |
| 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 | 1809 | 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 | |
| 1752 | 1812 | } | |
| 1753 | 1813 | ||
| 1754 | 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 | 1823 | return nil | |
| 1764 | 1824 | } | |
| 1765 | 1825 | ||
| 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, | |
| 1768 | 1831 | parentNode *entity.PaneNode, | |
| 1769 | - | log *zerolog.Logger, | |
| 1832 | + | parentPaneID entity.PaneID, | |
| 1770 | 1833 | ) *entity.PaneNode { | |
| 1834 | + | log := logging.FromContext(ctx) | |
| 1835 | + | ||
| 1836 | + | // Check if parent is already in a stack | |
| 1771 | 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 | 1841 | return parentNode.Parent | |
| 1773 | 1842 | } | |
| 1843 | + | ||
| 1844 | + | // Check if parent is already a stack container | |
| 1774 | 1845 | if parentNode.IsStacked { | |
| 1846 | + | log.Debug(). | |
| 1847 | + | Str("stack_id", parentNode.ID). | |
| 1848 | + | Msg("parent is already a stack container") | |
| 1775 | 1849 | return parentNode | |
| 1776 | 1850 | } | |
| 1777 | - | return c.convertToStackedNode(input, parentNode, log) | |
| 1778 | - | } | |
| 1779 | 1851 | ||
| 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. | |
| 1785 | 1855 | originalPane := parentNode.Pane | |
| 1786 | - | originalTitle := c.contentCoord.GetTitle(input.ParentPaneID) | |
| 1856 | + | originalTitle := c.contentCoord.GetTitle(parentPaneID) | |
| 1787 | 1857 | if originalTitle == "" { | |
| 1788 | 1858 | originalTitle = originalPane.Title | |
| 1789 | 1859 | } | |
| @@ -1792,14 +1862,17 @@ func (c *WorkspaceCoordinator) convertToStackedNode( | |||
| 1792 | 1862 | } | |
| 1793 | 1863 | originalPane.Title = originalTitle | |
| 1794 | 1864 | ||
| 1865 | + | // Create child node for the original pane | |
| 1795 | 1866 | originalPaneChild := &entity.PaneNode{ | |
| 1796 | 1867 | ID: parentNode.ID + "_0", | |
| 1797 | 1868 | Pane: originalPane, | |
| 1798 | 1869 | Parent: parentNode, | |
| 1799 | 1870 | } | |
| 1800 | 1871 | ||
| 1872 | + | // Convert parentNode to stack container | |
| 1801 | 1873 | parentNode.Pane = nil | |
| 1802 | 1874 | parentNode.IsStacked = true | |
| 1875 | + | parentNode.ActiveStackIndex = 0 | |
| 1803 | 1876 | parentNode.Children = []*entity.PaneNode{originalPaneChild} | |
| 1804 | 1877 | ||
| 1805 | 1878 | log.Debug(). | |
| @@ -1810,28 +1883,6 @@ func (c *WorkspaceCoordinator) convertToStackedNode( | |||
| 1810 | 1883 | 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 | 1886 | func (c *WorkspaceCoordinator) attachPopupPaneView( | |
| 1836 | 1887 | ctx context.Context, | |
| 1837 | 1888 | input InsertPopupInput, | |
| @@ -1933,6 +1984,7 @@ func (c *WorkspaceCoordinator) ConsumeOrExpelPane(ctx context.Context, direction | |||
| 1933 | 1984 | log.Warn().Err(err).Msg("failed to rebuild workspace view") | |
| 1934 | 1985 | } | |
| 1935 | 1986 | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |
| 1987 | + | c.SetupStackedPaneCallbacks(ctx, ws, wsView) | |
| 1936 | 1988 | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |
| 1937 | 1989 | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |
| 1938 | 1990 | } | |
| @@ -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 stack | 1101 | + | // Find the index of the closing pane in the StackedView UI |
| 1099 | - | closingIndex := -1 | 1102 | + | 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 widget | 1107 | // Remove the pane from the StackedView widget | ||
| @@ -1120,12 +1116,48 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose( | |||||
| 1120 | // Release the closing pane's webview | 1116 | // 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 nil | 1127 | 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 nil | 1269 | 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 = defaultPaneTitle | 1276 | 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 pane | 1331 | // 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 view | 1362 | // 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 | }, true | 1439 | }, 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.ID | 1811 | + | 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 nil | 1823 | 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.Parent | 1841 | 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 parentNode | 1849 | 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.Pane | 1855 | 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.Title | 1858 | originalTitle = originalPane.Title | ||
| 1789 | } | 1859 | } | ||
| @@ -1792,14 +1862,17 @@ func (c *WorkspaceCoordinator) convertToStackedNode( | |||||
| 1792 | } | 1862 | } | ||
| 1793 | originalPane.Title = originalTitle | 1863 | 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 = nil | 1873 | parentNode.Pane = nil | ||
| 1802 | parentNode.IsStacked = true | 1874 | 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 parentNode | 1883 | 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 { | |||
| 31 | 31 | ColorResolver port.ColorSchemeResolver | |
| 32 | 32 | AdwaitaDetector *colorscheme.AdwaitaDetector | |
| 33 | 33 | ||
| 34 | + | // XDG paths | |
| 35 | + | XDG port.XDGPaths | |
| 36 | + | ||
| 34 | 37 | // WebKit infrastructure | |
| 35 | 38 | WebContext *webkit.WebKitContext | |
| 36 | 39 | Pool *webkit.WebViewPool | |
| @@ -31,6 +31,9 @@ type Dependencies struct { | |||||
| 31 | ColorResolver port.ColorSchemeResolver | 31 | ColorResolver port.ColorSchemeResolver | ||
| 32 | AdwaitaDetector *colorscheme.AdwaitaDetector | 32 | AdwaitaDetector *colorscheme.AdwaitaDetector | ||
| 33 | 33 | ||||
| 34 | + | // XDG paths | |||
| 35 | + | XDG port.XDGPaths | |||
| 36 | + | ||||
| 34 | // WebKit infrastructure | 37 | // WebKit infrastructure | ||
| 35 | WebContext *webkit.WebKitContext | 38 | WebContext *webkit.WebKitContext | ||
| 36 | Pool *webkit.WebViewPool | 39 | Pool *webkit.WebViewPool | ||
M internal/ui/dispatcher/keyboard.go +1 -0
| @@ -179,6 +179,7 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 179 | 179 | input.ActionGoForward: d.navCoord.GoForward, | |
| 180 | 180 | input.ActionReload: d.navCoord.Reload, | |
| 181 | 181 | input.ActionHardReload: d.navCoord.HardReload, | |
| 182 | + | input.ActionPrintPage: d.navCoord.PrintPage, | |
| 182 | 183 | // Zoom actions | |
| 183 | 184 | input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") }, | |
| 184 | 185 | 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 actions | 183 | // 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 ( | |||
| 170 | 170 | ActionReload Action = "reload" | |
| 171 | 171 | ActionHardReload Action = "hard_reload" | |
| 172 | 172 | ActionStop Action = "stop" | |
| 173 | + | ActionPrintPage Action = "print_page" | |
| 173 | 174 | ||
| 174 | 175 | // Zoom | |
| 175 | 176 | ActionZoomIn Action = "zoom_in" | |
| @@ -360,8 +361,8 @@ func (s *ShortcutSet) registerStandardShortcuts() { | |||
| 360 | 361 | s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload | |
| 361 | 362 | s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload | |
| 362 | 363 | 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 | |
| 365 | 366 | s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn | |
| 366 | 367 | s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed) | |
| 367 | 368 | s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut | |
| @@ -369,6 +370,7 @@ func (s *ShortcutSet) registerStandardShortcuts() { | |||
| 369 | 370 | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | |
| 370 | 371 | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | |
| 371 | 372 | s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL | |
| 373 | + | s.Global[KeyBinding{uint('p'), ModCtrl | ModShift}] = ActionPrintPage | |
| 372 | 374 | // Session management - direct shortcut to open session manager | |
| 373 | 375 | s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager | |
| 374 | 376 | } | |
| @@ -379,6 +381,8 @@ func (s *ShortcutSet) registerPaneNavigationShortcuts() { | |||
| 379 | 381 | s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp | |
| 380 | 382 | 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 | 386 | s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp | |
| 383 | 387 | s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown | |
| 384 | 388 | } | |
| @@ -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 | // Zoom | 175 | // 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}] = ActionReload | 361 | s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload | ||
| 361 | s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload | 362 | s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload | ||
| 362 | s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools | 363 | 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_Left), ModCtrl}] = ActionGoBack |
| 364 | - | s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionGoForward | 365 | + | s.Global[KeyBinding{uint(gdk.KEY_Right), ModCtrl}] = ActionGoForward |
| 365 | s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn | 366 | 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}] = ActionZoomOut | 368 | 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}] = ActionQuit | 370 | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | ||
| 370 | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | 371 | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | ||
| 371 | s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL | 372 | 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 manager | 374 | // Session management - direct shortcut to open session manager | ||
| 373 | s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager | 375 | 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}] = ActionFocusUp | 381 | s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp | ||
| 380 | s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown | 382 | 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}] = ActionFocusUp | 386 | s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp | ||
| 383 | s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown | 387 | s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown | ||
| 384 | } | 388 | } | ||