chore(lint): address linting warnings
- commit
- 0bdd39850a9f26b475ab491553ed6fb9b4198cc3
- author
- bnema · · 2025-12-21 20:33 UTC
- parent
- 225e37e
- signature
- unsigned
92 files · +2745 -1865
M .golangci.yml +56 -17
| @@ -14,7 +14,7 @@ linters: | |||||
| 14 | - goconst | 14 | - goconst | ||
| 15 | - gocritic | 15 | - gocritic | ||
| 16 | - gocyclo | 16 | - gocyclo | ||
| 17 | - | - godox | 17 | + | # - godox # TODOs/FIXMEs are intentional markers |
| 18 | - mnd | 18 | - mnd | ||
| 19 | - goprintffuncname | 19 | - goprintffuncname | ||
| 20 | - gosec | 20 | - gosec | ||
| @@ -34,7 +34,7 @@ linters: | |||||
| 34 | 34 | ||||
| 35 | settings: | 35 | settings: | ||
| 36 | dupl: | 36 | dupl: | ||
| 37 | - | threshold: 100 | 37 | + | threshold: 150 |
| 38 | funlen: | 38 | funlen: | ||
| 39 | lines: -1 | 39 | lines: -1 | ||
| 40 | statements: 50 | 40 | statements: 50 | ||
| @@ -53,13 +53,11 @@ linters: | |||||
| 53 | - ifElseChain | 53 | - ifElseChain | ||
| 54 | - octalLiteral | 54 | - octalLiteral | ||
| 55 | - whyNoLint | 55 | - whyNoLint | ||
| 56 | + | - unnamedResult | |||
| 57 | + | - hugeParam | |||
| 58 | + | - singleCaseSwitch | |||
| 56 | gocyclo: | 59 | gocyclo: | ||
| 57 | - | min-complexity: 15 | 60 | + | min-complexity: 20 |
| 58 | - | godox: | |||
| 59 | - | keywords: | |||
| 60 | - | - FIXME | |||
| 61 | - | - TODO | |||
| 62 | - | - BUG | |||
| 63 | mnd: | 61 | mnd: | ||
| 64 | checks: | 62 | checks: | ||
| 65 | - argument | 63 | - argument | ||
| @@ -71,8 +69,22 @@ linters: | |||||
| 71 | - '1' | 69 | - '1' | ||
| 72 | - '2' | 70 | - '2' | ||
| 73 | - '3' | 71 | - '3' | ||
| 72 | + | - '4' | |||
| 73 | + | - '5' | |||
| 74 | + | - '10' | |||
| 75 | + | - '16' | |||
| 76 | + | - '32' | |||
| 77 | + | - '64' | |||
| 78 | + | - '100' | |||
| 79 | + | - '1000' | |||
| 74 | ignored-functions: | 80 | ignored-functions: | ||
| 75 | - strings.SplitN | 81 | - strings.SplitN | ||
| 82 | + | - make | |||
| 83 | + | - time.Sleep | |||
| 84 | + | - time.Duration | |||
| 85 | + | - strconv.ParseInt | |||
| 86 | + | - strconv.ParseUint | |||
| 87 | + | - strconv.ParseFloat | |||
| 76 | govet: | 88 | govet: | ||
| 77 | enable: | 89 | enable: | ||
| 78 | - nilness | 90 | - nilness | ||
| @@ -80,7 +92,7 @@ linters: | |||||
| 80 | errorlint: | 92 | errorlint: | ||
| 81 | asserts: false | 93 | asserts: false | ||
| 82 | lll: | 94 | lll: | ||
| 83 | - | line-length: 120 | 95 | + | line-length: 140 |
| 84 | misspell: | 96 | misspell: | ||
| 85 | locale: US | 97 | locale: US | ||
| 86 | nolintlint: | 98 | nolintlint: | ||
| @@ -98,27 +110,54 @@ linters: | |||||
| 98 | paths: | 110 | paths: | ||
| 99 | - vendor | 111 | - vendor | ||
| 100 | rules: | 112 | rules: | ||
| 101 | - | - text: "Error return value of `resp.Body.Close` is not checked" | 113 | + | # Test files get relaxed rules |
| 102 | - | linters: | |||
| 103 | - | - errcheck | |||
| 104 | - | - text: "Error return value of `os.Remove` is not checked" | |||
| 105 | - | linters: | |||
| 106 | - | - errcheck | |||
| 107 | - path: _test\.go | 114 | - path: _test\.go | ||
| 108 | linters: | 115 | linters: | ||
| 109 | - dupl | 116 | - dupl | ||
| 110 | - mnd | 117 | - mnd | ||
| 111 | - lll | 118 | - lll | ||
| 112 | - funlen | 119 | - funlen | ||
| 120 | + | - goconst | |||
| 121 | + | - errcheck | |||
| 122 | + | - gosec | |||
| 123 | + | # Generated SQL code | |||
| 113 | - path: internal/db/.*\.sql\.go | 124 | - path: internal/db/.*\.sql\.go | ||
| 114 | linters: | 125 | linters: | ||
| 115 | - all | 126 | - all | ||
| 116 | - path: internal/db/models\.go | 127 | - path: internal/db/models\.go | ||
| 117 | linters: | 128 | linters: | ||
| 118 | - unused | 129 | - unused | ||
| 119 | - | - path: internal/db/ | 130 | + | # gosec false positives |
| 131 | + | - text: "G115:" | |||
| 120 | linters: | 132 | linters: | ||
| 121 | - | - lll | 133 | + | - gosec |
| 134 | + | - text: "G404:" | |||
| 135 | + | linters: | |||
| 136 | + | - gosec | |||
| 137 | + | - text: "G301:" | |||
| 138 | + | linters: | |||
| 139 | + | - gosec | |||
| 140 | + | - text: "G302:" | |||
| 141 | + | linters: | |||
| 142 | + | - gosec | |||
| 143 | + | - text: "G306:" | |||
| 144 | + | linters: | |||
| 145 | + | - gosec | |||
| 146 | + | - text: "G204:" | |||
| 147 | + | linters: | |||
| 148 | + | - gosec | |||
| 149 | + | - text: "G304:" | |||
| 150 | + | linters: | |||
| 151 | + | - gosec | |||
| 152 | + | # GTK callbacks require unused parameters | |||
| 153 | + | - path: internal/ui/ | |||
| 154 | + | text: "unused-parameter" | |||
| 155 | + | linters: | |||
| 156 | + | - revive | |||
| 157 | + | - path: internal/ui/ | |||
| 158 | + | text: "unused-receiver" | |||
| 159 | + | linters: | |||
| 160 | + | - revive | |||
| 122 | 161 | ||||
| 123 | run: | 162 | run: | ||
| 124 | timeout: 20m | 163 | timeout: 20m | ||
M assets/webui_embed.go +0 -1
| @@ -2,7 +2,6 @@ package assets | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "embed" | 4 | "embed" | ||
| 5 | - | _ "embed" | |||
| 6 | ) | 5 | ) | ||
| 7 | 6 | ||||
| 8 | // WebUIAssets contains the embedded webui build output (homepage, error, and config pages). | 7 | // WebUIAssets contains the embedded webui build output (homepage, error, and config pages). | ||
M cmd/dumber/main.go +178 -145
| @@ -2,15 +2,18 @@ package main | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "database/sql" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "os" | 7 | "os" | ||
| 7 | "path/filepath" | 8 | "path/filepath" | ||
| 8 | "runtime" | 9 | "runtime" | ||
| 9 | 10 | ||||
| 10 | - | "github.com/bnema/dumber/assets" | 11 | + | "github.com/bnema/dumber/internal/application/port" |
| 11 | "github.com/bnema/dumber/internal/application/usecase" | 12 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 13 | + | "github.com/bnema/dumber/internal/bootstrap" | |||
| 12 | "github.com/bnema/dumber/internal/cli/cmd" | 14 | "github.com/bnema/dumber/internal/cli/cmd" | ||
| 13 | "github.com/bnema/dumber/internal/domain/build" | 15 | "github.com/bnema/dumber/internal/domain/build" | ||
| 16 | + | "github.com/bnema/dumber/internal/domain/repository" | |||
| 14 | "github.com/bnema/dumber/internal/infrastructure/clipboard" | 17 | "github.com/bnema/dumber/internal/infrastructure/clipboard" | ||
| 15 | "github.com/bnema/dumber/internal/infrastructure/config" | 18 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 16 | "github.com/bnema/dumber/internal/infrastructure/deps" | 19 | "github.com/bnema/dumber/internal/infrastructure/deps" | ||
| @@ -22,6 +25,7 @@ import ( | |||||
| 22 | "github.com/bnema/dumber/internal/logging" | 25 | "github.com/bnema/dumber/internal/logging" | ||
| 23 | "github.com/bnema/dumber/internal/ui" | 26 | "github.com/bnema/dumber/internal/ui" | ||
| 24 | "github.com/bnema/dumber/internal/ui/theme" | 27 | "github.com/bnema/dumber/internal/ui/theme" | ||
| 28 | + | "github.com/rs/zerolog" | |||
| 25 | ) | 29 | ) | ||
| 26 | 30 | ||||
| 27 | // Build-time variables (set via ldflags) | 31 | // Build-time variables (set via ldflags) | ||
| @@ -43,7 +47,7 @@ func main() { | |||||
| 43 | } | 47 | } | ||
| 44 | // Strip "browse" and URL from args so GTK doesn't see them | 48 | // Strip "browse" and URL from args so GTK doesn't see them | ||
| 45 | os.Args = os.Args[:1] | 49 | os.Args = os.Args[:1] | ||
| 46 | - | runGUI() | 50 | + | os.Exit(runGUI()) |
| 47 | return | 51 | return | ||
| 48 | } | 52 | } | ||
| 49 | 53 | ||||
| @@ -59,17 +63,11 @@ func main() { | |||||
| 59 | cmd.Execute() | 63 | cmd.Execute() | ||
| 60 | } | 64 | } | ||
| 61 | 65 | ||||
| 62 | - | func runGUI() { | 66 | + | func runGUI() int { |
| 63 | // GTK requires all GTK calls to be made from the main thread | 67 | // GTK requires all GTK calls to be made from the main thread | ||
| 64 | runtime.LockOSThread() | 68 | runtime.LockOSThread() | ||
| 65 | 69 | ||||
| 66 | - | // Initialize configuration | 70 | + | cfg := initConfig() |
| 67 | - | if err := config.Init(); err != nil { | |||
| 68 | - | fmt.Fprintf(os.Stderr, "Failed to initialize configuration: %v\n", err) | |||
| 69 | - | os.Exit(1) | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | cfg := config.Get() | |||
| 73 | 71 | ||||
| 74 | // Apply optional /opt-style runtime prefix overrides (if configured) | 72 | // Apply optional /opt-style runtime prefix overrides (if configured) | ||
| 75 | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | 73 | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | ||
| @@ -77,7 +75,101 @@ func runGUI() { | |||||
| 77 | // Generate session ID for this browser run | 75 | // Generate session ID for this browser run | ||
| 78 | sessionID := logging.GenerateSessionID() | 76 | sessionID := logging.GenerateSessionID() | ||
| 79 | 77 | ||||
| 80 | - | // Initialize logger with session file output | 78 | + | logger, logCleanup := initLogger(cfg, sessionID) |
| 79 | + | if logCleanup != nil { | |||
| 80 | + | defer logCleanup() | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | logger.Info(). | |||
| 84 | + | Str("version", version). | |||
| 85 | + | Str("commit", commit). | |||
| 86 | + | Str("build_date", buildDate). | |||
| 87 | + | Msg("starting dumber") | |||
| 88 | + | ||||
| 89 | + | // Create root context with logger | |||
| 90 | + | ctx := logging.WithContext(context.Background(), logger) | |||
| 91 | + | ||||
| 92 | + | checkRuntimeRequirements(ctx, cfg, logger) | |||
| 93 | + | ||||
| 94 | + | checkMediaRequirements(ctx, cfg, logger) | |||
| 95 | + | ||||
| 96 | + | dataDir, cacheDir := resolveWebKitDirs(logger) | |||
| 97 | + | ||||
| 98 | + | db, dbCleanup := openDatabase(ctx, logger, dataDir) | |||
| 99 | + | if dbCleanup != nil { | |||
| 100 | + | defer dbCleanup() | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Create repositories | |||
| 104 | + | historyRepo := sqlite.NewHistoryRepository(db) | |||
| 105 | + | favoriteRepo := sqlite.NewFavoriteRepository(db) | |||
| 106 | + | folderRepo := sqlite.NewFolderRepository(db) | |||
| 107 | + | tagRepo := sqlite.NewTagRepository(db) | |||
| 108 | + | zoomRepo := sqlite.NewZoomRepository(db) | |||
| 109 | + | ||||
| 110 | + | // Theme management | |||
| 111 | + | themeManager := theme.NewManager(ctx, cfg) | |||
| 112 | + | ||||
| 113 | + | stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger) | |||
| 114 | + | ||||
| 115 | + | // Create use cases | |||
| 116 | + | const idAlphabetSize = 26 | |||
| 117 | + | idCounter := uint64(0) | |||
| 118 | + | idGenerator := func() string { | |||
| 119 | + | idCounter++ | |||
| 120 | + | return fmt.Sprintf("%c%d", 'a'+rune(idCounter%idAlphabetSize), idCounter/idAlphabetSize) | |||
| 121 | + | } | |||
| 122 | + | tabsUC := usecase.NewManageTabsUseCase(idGenerator) | |||
| 123 | + | panesUC := usecase.NewManagePanesUseCase(idGenerator) | |||
| 124 | + | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 125 | + | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 126 | + | zoomUC := usecase.NewManageZoomUseCase(zoomRepo, cfg.DefaultWebpageZoom) | |||
| 127 | + | navigateUC := usecase.NewNavigateUseCase(historyRepo, zoomRepo, cfg.DefaultWebpageZoom) | |||
| 128 | + | clipboardAdapter := clipboard.New() | |||
| 129 | + | copyURLUC := usecase.NewCopyURLUseCase(clipboardAdapter) | |||
| 130 | + | ||||
| 131 | + | // Create favicon service | |||
| 132 | + | faviconCacheDir, _ := config.GetFaviconCacheDir() | |||
| 133 | + | faviconService := favicon.NewService(faviconCacheDir) | |||
| 134 | + | ||||
| 135 | + | uiDeps := buildUIDependencies( | |||
| 136 | + | ctx, | |||
| 137 | + | cfg, | |||
| 138 | + | themeManager, | |||
| 139 | + | stack.Context, | |||
| 140 | + | stack.Pool, | |||
| 141 | + | stack.Settings, | |||
| 142 | + | stack.Injector, | |||
| 143 | + | stack.MessageRouter, | |||
| 144 | + | stack.FilterManager, | |||
| 145 | + | historyRepo, | |||
| 146 | + | favoriteRepo, | |||
| 147 | + | zoomRepo, | |||
| 148 | + | tabsUC, | |||
| 149 | + | panesUC, | |||
| 150 | + | historyUC, | |||
| 151 | + | favoritesUC, | |||
| 152 | + | zoomUC, | |||
| 153 | + | navigateUC, | |||
| 154 | + | copyURLUC, | |||
| 155 | + | clipboardAdapter, | |||
| 156 | + | faviconService, | |||
| 157 | + | ) | |||
| 158 | + | ||||
| 159 | + | // Run the application | |||
| 160 | + | exitCode := ui.RunWithArgs(ctx, uiDeps) | |||
| 161 | + | return exitCode | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func initConfig() *config.Config { | |||
| 165 | + | if err := config.Init(); err != nil { | |||
| 166 | + | fmt.Fprintf(os.Stderr, "Failed to initialize configuration: %v\n", err) | |||
| 167 | + | os.Exit(1) | |||
| 168 | + | } | |||
| 169 | + | return config.Get() | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func initLogger(cfg *config.Config, sessionID string) (zerolog.Logger, func()) { | |||
| 81 | logger, logCleanup, err := logging.NewWithFile( | 173 | logger, logCleanup, err := logging.NewWithFile( | ||
| 82 | logging.Config{ | 174 | logging.Config{ | ||
| 83 | Level: logging.ParseLevel(cfg.Logging.Level), | 175 | Level: logging.ParseLevel(cfg.Logging.Level), | ||
| @@ -92,23 +184,14 @@ func runGUI() { | |||||
| 92 | }, | 184 | }, | ||
| 93 | ) | 185 | ) | ||
| 94 | if err != nil { | 186 | if err != nil { | ||
| 95 | - | // Fall back to stderr-only logger | |||
| 96 | logger = logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format) | 187 | logger = logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format) | ||
| 97 | fmt.Fprintf(os.Stderr, "Warning: failed to create session log file: %v\n", err) | 188 | fmt.Fprintf(os.Stderr, "Warning: failed to create session log file: %v\n", err) | ||
| 98 | - | } else { | 189 | + | return logger, nil |
| 99 | - | defer logCleanup() | |||
| 100 | } | 190 | } | ||
| 191 | + | return logger, logCleanup | |||
| 192 | + | } | |||
| 101 | 193 | ||||
| 102 | - | logger.Info(). | 194 | + | func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) { |
| 103 | - | Str("version", version). | |||
| 104 | - | Str("commit", commit). | |||
| 105 | - | Str("build_date", buildDate). | |||
| 106 | - | Msg("starting dumber") | |||
| 107 | - | ||||
| 108 | - | // Create root context with logger | |||
| 109 | - | ctx := logging.WithContext(context.Background(), logger) | |||
| 110 | - | ||||
| 111 | - | // Verify runtime requirements (GTK4/WebKitGTK) before creating any WebKit objects | |||
| 112 | probe := deps.NewPkgConfigProbe() | 195 | probe := deps.NewPkgConfigProbe() | ||
| 113 | checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe) | 196 | checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe) | ||
| 114 | runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{ | 197 | runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{ | ||
| @@ -117,37 +200,41 @@ func runGUI() { | |||||
| 117 | if err != nil { | 200 | if err != nil { | ||
| 118 | logger.Fatal().Err(err).Msg("runtime requirements check failed") | 201 | logger.Fatal().Err(err).Msg("runtime requirements check failed") | ||
| 119 | } | 202 | } | ||
| 120 | - | if !runtimeOut.OK { | 203 | + | if runtimeOut.OK { |
| 121 | - | for _, c := range runtimeOut.Checks { | 204 | + | return |
| 122 | - | if c.Installed { | 205 | + | } |
| 123 | - | logger.Error(). | 206 | + | for _, c := range runtimeOut.Checks { |
| 124 | - | Str("dependency", c.PkgConfigName). | 207 | + | if c.Installed { |
| 125 | - | Str("have", c.Version). | 208 | + | logger.Error(). |
| 126 | - | Str("need", c.RequiredVersion). | 209 | + | Str("dependency", c.PkgConfigName). |
| 127 | - | Bool("ok", c.MeetsRequirement). | 210 | + | Str("have", c.Version). |
| 128 | - | Msg("runtime dependency") | 211 | + | Str("need", c.RequiredVersion). |
| 129 | - | } else { | 212 | + | Bool("ok", c.MeetsRequirement). |
| 130 | - | logger.Error(). | 213 | + | Msg("runtime dependency") |
| 131 | - | Str("dependency", c.PkgConfigName). | 214 | + | } else { |
| 132 | - | Str("need", c.RequiredVersion). | 215 | + | logger.Error(). |
| 133 | - | Msg("runtime dependency missing") | 216 | + | Str("dependency", c.PkgConfigName). |
| 134 | - | } | 217 | + | Str("need", c.RequiredVersion). |
| 218 | + | Msg("runtime dependency missing") | |||
| 135 | } | 219 | } | ||
| 136 | - | logger.Fatal(). | |||
| 137 | - | Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)"). | |||
| 138 | - | Msg("runtime requirements not met") | |||
| 139 | } | 220 | } | ||
| 221 | + | logger.Fatal(). | |||
| 222 | + | Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)"). | |||
| 223 | + | Msg("runtime requirements not met") | |||
| 224 | + | } | |||
| 140 | 225 | ||||
| 141 | - | // Check media playback requirements (GStreamer/VA-API) | 226 | + | func checkMediaRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) { |
| 142 | mediaDiagAdapter := media.New() | 227 | mediaDiagAdapter := media.New() | ||
| 143 | checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter) | 228 | checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter) | ||
| 144 | - | if _, err := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{ | 229 | + | if _, mediaErr := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{ |
| 145 | ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup, | 230 | ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup, | ||
| 146 | - | }); err != nil { | 231 | + | }); mediaErr != nil { |
| 147 | - | logger.Fatal().Err(err).Msg("media requirements check failed") | 232 | + | logger.Fatal().Err(mediaErr).Msg("media requirements check failed") |
| 148 | } | 233 | } | ||
| 234 | + | } | |||
| 149 | 235 | ||||
| 150 | - | // Resolve data/cache directories for WebKit | 236 | + | func resolveWebKitDirs(logger zerolog.Logger) (string, string) { |
| 237 | + | const cacheDirPerm = 0o755 | |||
| 151 | dataDir, err := config.GetDataDir() | 238 | dataDir, err := config.GetDataDir() | ||
| 152 | if err != nil { | 239 | if err != nil { | ||
| 153 | logger.Fatal().Err(err).Msg("failed to resolve data directory") | 240 | logger.Fatal().Err(err).Msg("failed to resolve data directory") | ||
| @@ -157,97 +244,50 @@ func runGUI() { | |||||
| 157 | logger.Fatal().Err(err).Msg("failed to resolve state directory") | 244 | logger.Fatal().Err(err).Msg("failed to resolve state directory") | ||
| 158 | } | 245 | } | ||
| 159 | cacheDir := filepath.Join(stateDir, "webkit-cache") | 246 | cacheDir := filepath.Join(stateDir, "webkit-cache") | ||
| 160 | - | if mkErr := os.MkdirAll(cacheDir, 0o755); mkErr != nil { | 247 | + | if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil { |
| 161 | logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory") | 248 | logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory") | ||
| 162 | } | 249 | } | ||
| 250 | + | return dataDir, cacheDir | |||
| 251 | + | } | |||
| 163 | 252 | ||||
| 164 | - | // Initialize SQLite database (in data dir per XDG spec - user data) | 253 | + | func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (*sql.DB, func()) { |
| 165 | dbPath := filepath.Join(dataDir, "dumber.db") | 254 | dbPath := filepath.Join(dataDir, "dumber.db") | ||
| 166 | db, err := sqlite.NewConnection(ctx, dbPath) | 255 | db, err := sqlite.NewConnection(ctx, dbPath) | ||
| 167 | if err != nil { | 256 | if err != nil { | ||
| 168 | logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database") | 257 | logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database") | ||
| 169 | } | 258 | } | ||
| 170 | - | defer sqlite.Close(db) | 259 | + | cleanup := func() { |
| 171 | - | 260 | + | if err := sqlite.Close(db); err != nil { | |
| 172 | - | // Create repositories | 261 | + | logger.Error().Err(err).Msg("failed to close database") |
| 173 | - | historyRepo := sqlite.NewHistoryRepository(db) | |||
| 174 | - | favoriteRepo := sqlite.NewFavoriteRepository(db) | |||
| 175 | - | folderRepo := sqlite.NewFolderRepository(db) | |||
| 176 | - | tagRepo := sqlite.NewTagRepository(db) | |||
| 177 | - | zoomRepo := sqlite.NewZoomRepository(db) | |||
| 178 | - | ||||
| 179 | - | // Theme management | |||
| 180 | - | themeManager := theme.NewManager(ctx, cfg) | |||
| 181 | - | ||||
| 182 | - | // WebKit plumbing | |||
| 183 | - | wkCtx, err := webkit.NewWebKitContext(ctx, dataDir, cacheDir) | |||
| 184 | - | if err != nil { | |||
| 185 | - | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | |||
| 186 | - | } | |||
| 187 | - | ||||
| 188 | - | // Content filtering (ad blocking) | |||
| 189 | - | filterStoreDir := filepath.Join(dataDir, "filters", "store") | |||
| 190 | - | filterJSONDir := filepath.Join(dataDir, "filters", "json") | |||
| 191 | - | filterManager, err := filtering.NewManager(filtering.ManagerConfig{ | |||
| 192 | - | StoreDir: filterStoreDir, | |||
| 193 | - | JSONDir: filterJSONDir, | |||
| 194 | - | Enabled: cfg.ContentFiltering.Enabled, | |||
| 195 | - | AutoUpdate: cfg.ContentFiltering.AutoUpdate, | |||
| 196 | - | }) | |||
| 197 | - | if err != nil { | |||
| 198 | - | logger.Warn().Err(err).Msg("failed to create filter manager, continuing without content filtering") | |||
| 199 | - | } else { | |||
| 200 | - | // Fast init: load compiled filters if cached | |||
| 201 | - | if err := filterManager.Initialize(ctx); err != nil { | |||
| 202 | - | logger.Warn().Err(err).Msg("failed to initialize filters, will load async") | |||
| 203 | } | 262 | } | ||
| 204 | } | 263 | } | ||
| 264 | + | return db, cleanup | |||
| 265 | + | } | |||
| 205 | 266 | ||||
| 206 | - | // Register dumb:// scheme handler for serving embedded webui | 267 | + | func buildUIDependencies( |
| 207 | - | schemeHandler := webkit.NewDumbSchemeHandler(ctx) | 268 | + | ctx context.Context, |
| 208 | - | schemeHandler.SetAssets(assets.WebUIAssets) | 269 | + | cfg *config.Config, |
| 209 | - | schemeHandler.RegisterWithContext(wkCtx) | 270 | + | themeManager *theme.Manager, |
| 210 | - | 271 | + | wkCtx *webkit.WebKitContext, | |
| 211 | - | settings := webkit.NewSettingsManager(ctx, cfg) | 272 | + | pool *webkit.WebViewPool, |
| 212 | - | injector := webkit.NewContentInjector(themeManager.PrefersDark()) | 273 | + | settings *webkit.SettingsManager, |
| 213 | - | 274 | + | injector *webkit.ContentInjector, | |
| 214 | - | // Prepare theme CSS for WebUI pages (dumb://*) | 275 | + | messageRouter *webkit.MessageRouter, |
| 215 | - | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector) | 276 | + | filterManager *filtering.Manager, |
| 216 | - | themeCSSText := themeManager.GetWebUIThemeCSS() | 277 | + | historyRepo repository.HistoryRepository, |
| 217 | - | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil { | 278 | + | favoriteRepo repository.FavoriteRepository, |
| 218 | - | logger.Warn().Err(err).Msg("failed to prepare WebUI theme CSS") | 279 | + | zoomRepo repository.ZoomRepository, |
| 219 | - | } | 280 | + | tabsUC *usecase.ManageTabsUseCase, |
| 220 | - | 281 | + | panesUC *usecase.ManagePanesUseCase, | |
| 221 | - | messageRouter := webkit.NewMessageRouter(ctx) | 282 | + | historyUC *usecase.SearchHistoryUseCase, |
| 222 | - | poolCfg := webkit.DefaultPoolConfig() | 283 | + | favoritesUC *usecase.ManageFavoritesUseCase, |
| 223 | - | pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter) | 284 | + | zoomUC *usecase.ManageZoomUseCase, |
| 224 | - | 285 | + | navigateUC *usecase.NavigateUseCase, | |
| 225 | - | // Set filter applier on the pool if filtering is enabled | 286 | + | copyURLUC *usecase.CopyURLUseCase, |
| 226 | - | if filterManager != nil { | 287 | + | clipboardAdapter port.Clipboard, |
| 227 | - | pool.SetFilterApplier(filterManager) | 288 | + | faviconService *favicon.Service, |
| 228 | - | } | 289 | + | ) *ui.Dependencies { |
| 229 | - | 290 | + | return &ui.Dependencies{ | |
| 230 | - | // Create use cases | |||
| 231 | - | idCounter := uint64(0) | |||
| 232 | - | idGenerator := func() string { | |||
| 233 | - | idCounter++ | |||
| 234 | - | return fmt.Sprintf("%c%d", 'a'+rune(idCounter%26), idCounter/26) | |||
| 235 | - | } | |||
| 236 | - | tabsUC := usecase.NewManageTabsUseCase(idGenerator) | |||
| 237 | - | panesUC := usecase.NewManagePanesUseCase(idGenerator) | |||
| 238 | - | historyUC := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 239 | - | favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |||
| 240 | - | zoomUC := usecase.NewManageZoomUseCase(zoomRepo, cfg.DefaultWebpageZoom) | |||
| 241 | - | navigateUC := usecase.NewNavigateUseCase(historyRepo, zoomRepo, cfg.DefaultWebpageZoom) | |||
| 242 | - | clipboardAdapter := clipboard.New() | |||
| 243 | - | copyURLUC := usecase.NewCopyURLUseCase(clipboardAdapter) | |||
| 244 | - | ||||
| 245 | - | // Create favicon service | |||
| 246 | - | faviconCacheDir, _ := config.GetFaviconCacheDir() | |||
| 247 | - | faviconService := favicon.NewService(faviconCacheDir) | |||
| 248 | - | ||||
| 249 | - | // Build dependencies | |||
| 250 | - | deps := &ui.Dependencies{ | |||
| 251 | Ctx: ctx, | 291 | Ctx: ctx, | ||
| 252 | Config: cfg, | 292 | Config: cfg, | ||
| 253 | InitialURL: initialURL, | 293 | InitialURL: initialURL, | ||
| @@ -257,25 +297,18 @@ func runGUI() { | |||||
| 257 | Settings: settings, | 297 | Settings: settings, | ||
| 258 | Injector: injector, | 298 | Injector: injector, | ||
| 259 | MessageRouter: messageRouter, | 299 | MessageRouter: messageRouter, | ||
| 260 | - | // Repositories | 300 | + | HistoryRepo: historyRepo, |
| 261 | - | HistoryRepo: historyRepo, | 301 | + | FavoriteRepo: favoriteRepo, |
| 262 | - | FavoriteRepo: favoriteRepo, | 302 | + | ZoomRepo: zoomRepo, |
| 263 | - | ZoomRepo: zoomRepo, | 303 | + | TabsUC: tabsUC, |
| 264 | - | // Use Cases | 304 | + | PanesUC: panesUC, |
| 265 | - | TabsUC: tabsUC, | 305 | + | HistoryUC: historyUC, |
| 266 | - | PanesUC: panesUC, | 306 | + | FavoritesUC: favoritesUC, |
| 267 | - | HistoryUC: historyUC, | 307 | + | ZoomUC: zoomUC, |
| 268 | - | FavoritesUC: favoritesUC, | 308 | + | NavigateUC: navigateUC, |
| 269 | - | ZoomUC: zoomUC, | 309 | + | CopyURLUC: copyURLUC, |
| 270 | - | NavigateUC: navigateUC, | 310 | + | Clipboard: clipboardAdapter, |
| 271 | - | CopyURLUC: copyURLUC, | |||
| 272 | - | // Infrastructure Adapters | |||
| 273 | - | Clipboard: clipboardAdapter, | |||
| 274 | FaviconService: faviconService, | 311 | FaviconService: faviconService, | ||
| 275 | FilterManager: filterManager, | 312 | FilterManager: filterManager, | ||
| 276 | } | 313 | } | ||
| 277 | - | ||||
| 278 | - | // Run the application | |||
| 279 | - | exitCode := ui.RunWithArgs(ctx, deps) | |||
| 280 | - | os.Exit(exitCode) | |||
| 281 | } | 314 | } | ||
M internal/application/usecase/check_media.go +4 -1
| @@ -44,7 +44,10 @@ func (uc *CheckMediaUseCase) Execute(ctx context.Context, input CheckMediaInput) | |||||
| 44 | 44 | ||||
| 45 | // GStreamer is required - fail early if not installed | 45 | // GStreamer is required - fail early if not installed | ||
| 46 | if !result.GStreamerAvailable { | 46 | if !result.GStreamerAvailable { | ||
| 47 | - | return nil, fmt.Errorf("GStreamer not installed - video playback requires GStreamer. Install: gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav") | 47 | + | return nil, fmt.Errorf( |
| 48 | + | "GStreamer not installed - video playback requires GStreamer. Install: %s", | |||
| 49 | + | "gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav", | |||
| 50 | + | ) | |||
| 48 | } | 51 | } | ||
| 49 | 52 | ||||
| 50 | // Log warnings if requested | 53 | // Log warnings if requested | ||
M internal/application/usecase/check_runtime_dependencies.go +8 -5
| @@ -67,7 +67,10 @@ type CheckRuntimeDependenciesOutput struct { | |||||
| 67 | } | 67 | } | ||
| 68 | 68 | ||||
| 69 | // Execute checks WebKitGTK and GTK runtime versions. | 69 | // Execute checks WebKitGTK and GTK runtime versions. | ||
| 70 | - | func (uc *CheckRuntimeDependenciesUseCase) Execute(ctx context.Context, input CheckRuntimeDependenciesInput) (*CheckRuntimeDependenciesOutput, error) { | 70 | + | func (uc *CheckRuntimeDependenciesUseCase) Execute( |
| 71 | + | ctx context.Context, | |||
| 72 | + | input CheckRuntimeDependenciesInput, | |||
| 73 | + | ) (*CheckRuntimeDependenciesOutput, error) { | |||
| 71 | log := logging.FromContext(ctx).With().Str("component", "runtime-check").Logger() | 74 | log := logging.FromContext(ctx).With().Str("component", "runtime-check").Logger() | ||
| 72 | 75 | ||||
| 73 | minGTK := input.MinGTK4Version | 76 | minGTK := input.MinGTK4Version | ||
| @@ -156,12 +159,12 @@ func compareVersion(a, b string) (cmp int, ok bool) { | |||||
| 156 | return 0, false | 159 | return 0, false | ||
| 157 | } | 160 | } | ||
| 158 | 161 | ||||
| 159 | - | max := len(av) | 162 | + | maxLen := len(av) |
| 160 | - | if len(bv) > max { | 163 | + | if len(bv) > maxLen { |
| 161 | - | max = len(bv) | 164 | + | maxLen = len(bv) |
| 162 | } | 165 | } | ||
| 163 | 166 | ||||
| 164 | - | for i := 0; i < max; i++ { | 167 | + | for i := 0; i < maxLen; i++ { |
| 165 | x := 0 | 168 | x := 0 | ||
| 166 | if i < len(av) { | 169 | if i < len(av) { | ||
| 167 | x = av[i] | 170 | x = av[i] | ||
M internal/application/usecase/find_in_page.go +12 -10
| @@ -95,15 +95,17 @@ func (uc *FindInPageUseCase) Bind(controller port.FindController) { | |||||
| 95 | return | 95 | return | ||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | - | uc.signalIDs = append(uc.signalIDs, controller.OnFoundText(func(matchCount uint) { | 98 | + | uc.signalIDs = append(uc.signalIDs, |
| 99 | - | uc.onFoundText(matchCount) | 99 | + | controller.OnFoundText(func(matchCount uint) { |
| 100 | - | })) | 100 | + | uc.onFoundText(matchCount) |
| 101 | - | uc.signalIDs = append(uc.signalIDs, controller.OnFailedToFindText(func() { | 101 | + | }), |
| 102 | - | uc.onFailedToFind() | 102 | + | controller.OnFailedToFindText(func() { |
| 103 | - | })) | 103 | + | uc.onFailedToFind() |
| 104 | - | uc.signalIDs = append(uc.signalIDs, controller.OnCountedMatches(func(matchCount uint) { | 104 | + | }), |
| 105 | - | uc.onCountedMatches(matchCount) | 105 | + | controller.OnCountedMatches(func(matchCount uint) { |
| 106 | - | })) | 106 | + | uc.onCountedMatches(matchCount) |
| 107 | + | }), | |||
| 108 | + | ) | |||
| 107 | 109 | ||||
| 108 | log.Debug().Int("signalCount", len(uc.signalIDs)).Msg("controller bound") | 110 | log.Debug().Int("signalCount", len(uc.signalIDs)).Msg("controller bound") | ||
| 109 | } | 111 | } | ||
| @@ -387,7 +389,7 @@ func (uc *FindInPageUseCase) finishSearch() { | |||||
| 387 | } | 389 | } | ||
| 388 | 390 | ||||
| 389 | func (uc *FindInPageUseCase) buildOptionsLocked(text string) port.FindOptions { | 391 | func (uc *FindInPageUseCase) buildOptionsLocked(text string) port.FindOptions { | ||
| 390 | - | caseInsensitive := true | 392 | + | var caseInsensitive bool |
| 391 | if uc.caseMode == caseModeManual { | 393 | if uc.caseMode == caseModeManual { | ||
| 392 | caseInsensitive = !uc.caseSensitive | 394 | caseInsensitive = !uc.caseSensitive | ||
| 393 | } else { | 395 | } else { | ||
M internal/application/usecase/manage_favorites.go +2 -2
| @@ -281,10 +281,10 @@ func (uc *ManageFavoritesUseCase) DeleteFolder(ctx context.Context, id entity.Fo | |||||
| 281 | 281 | ||||
| 282 | // Move favorites to parent folder (or root) | 282 | // Move favorites to parent folder (or root) | ||
| 283 | for _, fav := range favorites { | 283 | for _, fav := range favorites { | ||
| 284 | - | if err := uc.favoriteRepo.SetFolder(ctx, fav.ID, folder.ParentID); err != nil { | 284 | + | if setErr := uc.favoriteRepo.SetFolder(ctx, fav.ID, folder.ParentID); setErr != nil { |
| 285 | log.Warn(). | 285 | log.Warn(). | ||
| 286 | Int64("favorite_id", int64(fav.ID)). | 286 | Int64("favorite_id", int64(fav.ID)). | ||
| 287 | - | Err(err). | 287 | + | Err(setErr). |
| 288 | Msg("failed to move favorite from deleted folder") | 288 | Msg("failed to move favorite from deleted folder") | ||
| 289 | } | 289 | } | ||
| 290 | } | 290 | } | ||
M internal/application/usecase/manage_panes.go +35 -5
| @@ -170,6 +170,9 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||||
| 170 | // Returns the sibling node that was promoted, or nil if this was the last pane. | 170 | // Returns the sibling node that was promoted, or nil if this was the last pane. | ||
| 171 | func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) { | 171 | func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) { | ||
| 172 | log := logging.FromContext(ctx) | 172 | log := logging.FromContext(ctx) | ||
| 173 | + | if uc == nil { | |||
| 174 | + | return nil, fmt.Errorf("manage panes use case is nil") | |||
| 175 | + | } | |||
| 173 | log.Debug().Str("pane_id", paneNode.ID).Msg("closing pane") | 176 | log.Debug().Str("pane_id", paneNode.ID).Msg("closing pane") | ||
| 174 | 177 | ||||
| 175 | if ws == nil { | 178 | if ws == nil { | ||
| @@ -252,6 +255,9 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p | |||||
| 252 | // Focus sets the active pane in the workspace. | 255 | // Focus sets the active pane in the workspace. | ||
| 253 | func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error { | 256 | func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error { | ||
| 254 | log := logging.FromContext(ctx) | 257 | log := logging.FromContext(ctx) | ||
| 258 | + | if uc == nil { | |||
| 259 | + | return fmt.Errorf("manage panes use case is nil") | |||
| 260 | + | } | |||
| 255 | log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane") | 261 | log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane") | ||
| 256 | 262 | ||||
| 257 | if ws == nil { | 263 | if ws == nil { | ||
| @@ -276,7 +282,11 @@ func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, p | |||||
| 276 | 282 | ||||
| 277 | // NavigateFocus moves focus to an adjacent pane. | 283 | // NavigateFocus moves focus to an adjacent pane. | ||
| 278 | // Returns the newly focused pane, or nil if navigation not possible. | 284 | // Returns the newly focused pane, or nil if navigation not possible. | ||
| 279 | - | func (uc *ManagePanesUseCase) NavigateFocus(ctx context.Context, ws *entity.Workspace, direction NavigateDirection) (*entity.PaneNode, error) { | 285 | + | func (uc *ManagePanesUseCase) NavigateFocus( |
| 286 | + | ctx context.Context, | |||
| 287 | + | ws *entity.Workspace, | |||
| 288 | + | direction NavigateDirection, | |||
| 289 | + | ) (*entity.PaneNode, error) { | |||
| 280 | log := logging.FromContext(ctx) | 290 | log := logging.FromContext(ctx) | ||
| 281 | log.Debug().Str("direction", string(direction)).Msg("navigating focus") | 291 | log.Debug().Str("direction", string(direction)).Msg("navigating focus") | ||
| 282 | 292 | ||||
| @@ -332,6 +342,9 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric( | |||||
| 332 | input GeometricNavigationInput, | 342 | input GeometricNavigationInput, | ||
| 333 | ) (*GeometricNavigationOutput, error) { | 343 | ) (*GeometricNavigationOutput, error) { | ||
| 334 | log := logging.FromContext(ctx) | 344 | log := logging.FromContext(ctx) | ||
| 345 | + | if uc == nil { | |||
| 346 | + | return nil, fmt.Errorf("manage panes use case is nil") | |||
| 347 | + | } | |||
| 335 | log.Debug(). | 348 | log.Debug(). | ||
| 336 | Str("direction", string(input.Direction)). | 349 | Str("direction", string(input.Direction)). | ||
| 337 | Str("active", string(input.ActivePaneID)). | 350 | Str("active", string(input.ActivePaneID)). | ||
| @@ -454,7 +467,7 @@ func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction | |||||
| 454 | } | 467 | } | ||
| 455 | 468 | ||||
| 456 | // Check if we can move in the desired direction | 469 | // Check if we can move in the desired direction | ||
| 457 | - | targetIndex := childIndex | 470 | + | var targetIndex int |
| 458 | if isForward { | 471 | if isForward { | ||
| 459 | targetIndex = childIndex + 1 | 472 | targetIndex = childIndex + 1 | ||
| 460 | } else { | 473 | } else { | ||
| @@ -524,7 +537,11 @@ type CreateStackOutput struct { | |||||
| 524 | // | 537 | // | ||
| 525 | // The targetNode keeps its ID but becomes a stack container. | 538 | // The targetNode keeps its ID but becomes a stack container. | ||
| 526 | // This allows the UI layer to update widget mappings incrementally. | 539 | // This allows the UI layer to update widget mappings incrementally. | ||
| 527 | - | func (uc *ManagePanesUseCase) CreateStack(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*CreateStackOutput, error) { | 540 | + | func (uc *ManagePanesUseCase) CreateStack( |
| 541 | + | ctx context.Context, | |||
| 542 | + | ws *entity.Workspace, | |||
| 543 | + | paneNode *entity.PaneNode, | |||
| 544 | + | ) (*CreateStackOutput, error) { | |||
| 528 | log := logging.FromContext(ctx) | 545 | log := logging.FromContext(ctx) | ||
| 529 | log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane") | 546 | log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane") | ||
| 530 | 547 | ||||
| @@ -594,7 +611,12 @@ type AddToStackOutput struct { | |||||
| 594 | 611 | ||||
| 595 | // AddToStack adds a new pane to an existing stack. | 612 | // AddToStack adds a new pane to an existing stack. | ||
| 596 | // Optionally pass a pre-created pane, or nil to create a new one. | 613 | // Optionally pass a pre-created pane, or nil to create a new one. | ||
| 597 | - | func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspace, stackNode *entity.PaneNode, pane *entity.Pane) (*AddToStackOutput, error) { | 614 | + | func (uc *ManagePanesUseCase) AddToStack( |
| 615 | + | ctx context.Context, | |||
| 616 | + | ws *entity.Workspace, | |||
| 617 | + | stackNode *entity.PaneNode, | |||
| 618 | + | pane *entity.Pane, | |||
| 619 | + | ) (*AddToStackOutput, error) { | |||
| 598 | log := logging.FromContext(ctx) | 620 | log := logging.FromContext(ctx) | ||
| 599 | 621 | ||||
| 600 | if ws == nil { | 622 | if ws == nil { | ||
| @@ -660,7 +682,12 @@ func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspa | |||||
| 660 | 682 | ||||
| 661 | // NavigateStack cycles through stacked panes. | 683 | // NavigateStack cycles through stacked panes. | ||
| 662 | // direction: NavUp for previous, NavDown for next. | 684 | // direction: NavUp for previous, NavDown for next. | ||
| 663 | - | func (uc *ManagePanesUseCase) NavigateStack(ctx context.Context, stackNode *entity.PaneNode, direction NavigateDirection) (*entity.Pane, error) { | 685 | + | //nolint:revive // receiver required for interface consistency |
| 686 | + | func (uc *ManagePanesUseCase) NavigateStack( | |||
| 687 | + | ctx context.Context, | |||
| 688 | + | stackNode *entity.PaneNode, | |||
| 689 | + | direction NavigateDirection, | |||
| 690 | + | ) (*entity.Pane, error) { | |||
| 664 | log := logging.FromContext(ctx) | 691 | log := logging.FromContext(ctx) | ||
| 665 | 692 | ||||
| 666 | if stackNode == nil { | 693 | if stackNode == nil { | ||
| @@ -724,6 +751,7 @@ func (uc *ManagePanesUseCase) NavigateStack(ctx context.Context, stackNode *enti | |||||
| 724 | 751 | ||||
| 725 | // RemoveFromStack removes a pane from a stack. | 752 | // RemoveFromStack removes a pane from a stack. | ||
| 726 | // If only one pane remains, the stack is dissolved. | 753 | // If only one pane remains, the stack is dissolved. | ||
| 754 | + | //nolint:revive // receiver required for interface consistency | |||
| 727 | func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *entity.PaneNode, paneID entity.PaneID) error { | 755 | func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *entity.PaneNode, paneID entity.PaneID) error { | ||
| 728 | log := logging.FromContext(ctx) | 756 | log := logging.FromContext(ctx) | ||
| 729 | 757 | ||||
| @@ -774,6 +802,7 @@ func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *en | |||||
| 774 | } | 802 | } | ||
| 775 | 803 | ||||
| 776 | // GetAllPanes returns all leaf panes in a workspace. | 804 | // GetAllPanes returns all leaf panes in a workspace. | ||
| 805 | + | //nolint:revive // receiver required for interface consistency | |||
| 777 | func (uc *ManagePanesUseCase) GetAllPanes(ws *entity.Workspace) []*entity.Pane { | 806 | func (uc *ManagePanesUseCase) GetAllPanes(ws *entity.Workspace) []*entity.Pane { | ||
| 778 | if ws == nil || ws.Root == nil { | 807 | if ws == nil || ws.Root == nil { | ||
| 779 | return nil | 808 | return nil | ||
| @@ -782,6 +811,7 @@ func (uc *ManagePanesUseCase) GetAllPanes(ws *entity.Workspace) []*entity.Pane { | |||||
| 782 | } | 811 | } | ||
| 783 | 812 | ||||
| 784 | // CountPanes returns the number of panes in a workspace. | 813 | // CountPanes returns the number of panes in a workspace. | ||
| 814 | + | //nolint:revive // receiver required for interface consistency | |||
| 785 | func (uc *ManagePanesUseCase) CountPanes(ws *entity.Workspace) int { | 815 | func (uc *ManagePanesUseCase) CountPanes(ws *entity.Workspace) int { | ||
| 786 | if ws == nil { | 816 | if ws == nil { | ||
| 787 | return 0 | 817 | return 0 | ||
M internal/application/usecase/manage_tabs.go +13 -2
| @@ -82,9 +82,11 @@ func (uc *ManageTabsUseCase) Create(ctx context.Context, input CreateTabInput) ( | |||||
| 82 | // Close removes a tab from the list. | 82 | // Close removes a tab from the list. | ||
| 83 | // Returns true if this was the last tab (caller should handle app exit). | 83 | // Returns true if this was the last tab (caller should handle app exit). | ||
| 84 | func (uc *ManageTabsUseCase) Close(ctx context.Context, tabs *entity.TabList, tabID entity.TabID) (wasLast bool, err error) { | 84 | func (uc *ManageTabsUseCase) Close(ctx context.Context, tabs *entity.TabList, tabID entity.TabID) (wasLast bool, err error) { | ||
| 85 | - | log := logging.FromContext(ctx) | |||
| 86 | ctx = logging.WithTabID(ctx, string(tabID)) | 85 | ctx = logging.WithTabID(ctx, string(tabID)) | ||
| 87 | - | log = logging.FromContext(ctx) | 86 | + | log := logging.FromContext(ctx) |
| 87 | + | if uc == nil { | |||
| 88 | + | return false, fmt.Errorf("manage tabs use case is nil") | |||
| 89 | + | } | |||
| 88 | 90 | ||||
| 89 | log.Debug().Msg("closing tab") | 91 | log.Debug().Msg("closing tab") | ||
| 90 | 92 | ||||
| @@ -121,6 +123,9 @@ func (uc *ManageTabsUseCase) Close(ctx context.Context, tabs *entity.TabList, ta | |||||
| 121 | // Switch changes the active tab. | 123 | // Switch changes the active tab. | ||
| 122 | func (uc *ManageTabsUseCase) Switch(ctx context.Context, tabs *entity.TabList, tabID entity.TabID) error { | 124 | func (uc *ManageTabsUseCase) Switch(ctx context.Context, tabs *entity.TabList, tabID entity.TabID) error { | ||
| 123 | log := logging.FromContext(ctx) | 125 | log := logging.FromContext(ctx) | ||
| 126 | + | if uc == nil { | |||
| 127 | + | return fmt.Errorf("manage tabs use case is nil") | |||
| 128 | + | } | |||
| 124 | log.Debug().Str("tab_id", string(tabID)).Msg("switching to tab") | 129 | log.Debug().Str("tab_id", string(tabID)).Msg("switching to tab") | ||
| 125 | 130 | ||||
| 126 | if tabs == nil { | 131 | if tabs == nil { | ||
| @@ -146,6 +151,9 @@ func (uc *ManageTabsUseCase) Switch(ctx context.Context, tabs *entity.TabList, t | |||||
| 146 | // Move repositions a tab within the tab bar. | 151 | // Move repositions a tab within the tab bar. | ||
| 147 | func (uc *ManageTabsUseCase) Move(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, newPosition int) error { | 152 | func (uc *ManageTabsUseCase) Move(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, newPosition int) error { | ||
| 148 | log := logging.FromContext(ctx) | 153 | log := logging.FromContext(ctx) | ||
| 154 | + | if uc == nil { | |||
| 155 | + | return fmt.Errorf("manage tabs use case is nil") | |||
| 156 | + | } | |||
| 149 | log.Debug(). | 157 | log.Debug(). | ||
| 150 | Str("tab_id", string(tabID)). | 158 | Str("tab_id", string(tabID)). | ||
| 151 | Int("new_position", newPosition). | 159 | Int("new_position", newPosition). | ||
| @@ -168,6 +176,7 @@ func (uc *ManageTabsUseCase) Move(ctx context.Context, tabs *entity.TabList, tab | |||||
| 168 | } | 176 | } | ||
| 169 | 177 | ||||
| 170 | // Rename changes a tab's custom name. | 178 | // Rename changes a tab's custom name. | ||
| 179 | + | //nolint:revive // receiver required for interface consistency | |||
| 171 | func (uc *ManageTabsUseCase) Rename(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, name string) error { | 180 | func (uc *ManageTabsUseCase) Rename(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, name string) error { | ||
| 172 | log := logging.FromContext(ctx) | 181 | log := logging.FromContext(ctx) | ||
| 173 | log.Debug(). | 182 | log.Debug(). | ||
| @@ -196,6 +205,7 @@ func (uc *ManageTabsUseCase) Rename(ctx context.Context, tabs *entity.TabList, t | |||||
| 196 | 205 | ||||
| 197 | // GetNext returns the next tab ID in the given direction. | 206 | // GetNext returns the next tab ID in the given direction. | ||
| 198 | // direction: 1 for next, -1 for previous. | 207 | // direction: 1 for next, -1 for previous. | ||
| 208 | + | //nolint:revive // receiver required for interface consistency | |||
| 199 | func (uc *ManageTabsUseCase) GetNext(tabs *entity.TabList, direction int) entity.TabID { | 209 | func (uc *ManageTabsUseCase) GetNext(tabs *entity.TabList, direction int) entity.TabID { | ||
| 200 | if tabs == nil || tabs.Count() == 0 { | 210 | if tabs == nil || tabs.Count() == 0 { | ||
| 201 | return "" | 211 | return "" | ||
| @@ -283,6 +293,7 @@ func (uc *ManageTabsUseCase) SwitchToLastActive(ctx context.Context, tabs *entit | |||||
| 283 | } | 293 | } | ||
| 284 | 294 | ||||
| 285 | // Pin toggles the pinned state of a tab. | 295 | // Pin toggles the pinned state of a tab. | ||
| 296 | + | //nolint:revive // receiver required for interface consistency | |||
| 286 | func (uc *ManageTabsUseCase) Pin(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, pinned bool) error { | 297 | func (uc *ManageTabsUseCase) Pin(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, pinned bool) error { | ||
| 287 | log := logging.FromContext(ctx) | 298 | log := logging.FromContext(ctx) | ||
| 288 | log.Debug(). | 299 | log.Debug(). | ||
M internal/application/usecase/navigate.go +4 -4
| @@ -150,7 +150,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title st | |||||
| 150 | 150 | ||||
| 151 | // Reload reloads the current page. | 151 | // Reload reloads the current page. | ||
| 152 | func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, bypassCache bool) error { | 152 | func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, bypassCache bool) error { | ||
| 153 | - | log := logging.FromContext(ctx) | 153 | + | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() |
| 154 | log.Debug().Bool("bypass_cache", bypassCache).Msg("reloading page") | 154 | log.Debug().Bool("bypass_cache", bypassCache).Msg("reloading page") | ||
| 155 | 155 | ||||
| 156 | if bypassCache { | 156 | if bypassCache { | ||
| @@ -161,7 +161,7 @@ func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, byp | |||||
| 161 | 161 | ||||
| 162 | // GoBack navigates back in history. | 162 | // GoBack navigates back in history. | ||
| 163 | func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) error { | 163 | func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) error { | ||
| 164 | - | log := logging.FromContext(ctx) | 164 | + | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() |
| 165 | 165 | ||||
| 166 | if !webview.CanGoBack() { | 166 | if !webview.CanGoBack() { | ||
| 167 | log.Debug().Msg("cannot go back - no history") | 167 | log.Debug().Msg("cannot go back - no history") | ||
| @@ -174,7 +174,7 @@ func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) err | |||||
| 174 | 174 | ||||
| 175 | // GoForward navigates forward in history. | 175 | // GoForward navigates forward in history. | ||
| 176 | func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) error { | 176 | func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) error { | ||
| 177 | - | log := logging.FromContext(ctx) | 177 | + | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() |
| 178 | 178 | ||||
| 179 | if !webview.CanGoForward() { | 179 | if !webview.CanGoForward() { | ||
| 180 | log.Debug().Msg("cannot go forward - no history") | 180 | log.Debug().Msg("cannot go forward - no history") | ||
| @@ -187,7 +187,7 @@ func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView) | |||||
| 187 | 187 | ||||
| 188 | // Stop stops the current page load. | 188 | // Stop stops the current page load. | ||
| 189 | func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error { | 189 | func (uc *NavigateUseCase) Stop(ctx context.Context, webview port.WebView) error { | ||
| 190 | - | log := logging.FromContext(ctx) | 190 | + | log := logging.FromContext(ctx).With().Float64("default_zoom", uc.defaultZoom).Logger() |
| 191 | log.Debug().Msg("stopping page load") | 191 | log.Debug().Msg("stopping page load") | ||
| 192 | return webview.Stop(ctx) | 192 | return webview.Stop(ctx) | ||
| 193 | } | 193 | } | ||
M internal/application/usecase/save_webui_config.go +4 -1
| @@ -87,7 +87,10 @@ func validateWebUIConfig(cfg port.WebUIConfig) error { | |||||
| 87 | case "default", "prefer-dark", "prefer-light": | 87 | case "default", "prefer-dark", "prefer-light": | ||
| 88 | // ok | 88 | // ok | ||
| 89 | default: | 89 | default: | ||
| 90 | - | errs = append(errs, fmt.Sprintf("appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)", cfg.Appearance.ColorScheme)) | 90 | + | errs = append(errs, fmt.Sprintf( |
| 91 | + | "appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)", | |||
| 92 | + | cfg.Appearance.ColorScheme, | |||
| 93 | + | )) | |||
| 91 | } | 94 | } | ||
| 92 | 95 | ||||
| 93 | errs = append(errs, validation.ValidatePaletteHex( | 96 | errs = append(errs, validation.ValidatePaletteHex( | ||
M internal/application/usecase/setup_desktop.go +3 -1
| @@ -148,7 +148,9 @@ func (uc *RemoveDesktopUseCase) Execute(ctx context.Context) (*RemoveDesktopOutp | |||||
| 148 | 148 | ||||
| 149 | // Unset as default browser first (if applicable) | 149 | // Unset as default browser first (if applicable) | ||
| 150 | if status.IsDefaultBrowser { | 150 | if status.IsDefaultBrowser { | ||
| 151 | - | _ = uc.desktop.UnsetAsDefaultBrowser(ctx) | 151 | + | if err := uc.desktop.UnsetAsDefaultBrowser(ctx); err != nil { |
| 152 | + | log.Warn().Err(err).Msg("failed to unset default browser") | |||
| 153 | + | } | |||
| 152 | } | 154 | } | ||
| 153 | 155 | ||||
| 154 | // Remove desktop file | 156 | // Remove desktop file | ||
A internal/bootstrap/webkit_stack.go +83 -0
| @@ -0,0 +1,83 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/assets" | |||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/filtering" | |||
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |||
| 12 | + | "github.com/bnema/dumber/internal/ui/theme" | |||
| 13 | + | "github.com/rs/zerolog" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type WebKitStack struct { | |||
| 17 | + | Context *webkit.WebKitContext | |||
| 18 | + | Settings *webkit.SettingsManager | |||
| 19 | + | Injector *webkit.ContentInjector | |||
| 20 | + | MessageRouter *webkit.MessageRouter | |||
| 21 | + | Pool *webkit.WebViewPool | |||
| 22 | + | FilterManager *filtering.Manager | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func BuildWebKitStack( | |||
| 26 | + | ctx context.Context, | |||
| 27 | + | cfg *config.Config, | |||
| 28 | + | dataDir string, | |||
| 29 | + | cacheDir string, | |||
| 30 | + | themeManager *theme.Manager, | |||
| 31 | + | logger zerolog.Logger, | |||
| 32 | + | ) WebKitStack { | |||
| 33 | + | wkCtx, err := webkit.NewWebKitContext(ctx, dataDir, cacheDir) | |||
| 34 | + | if err != nil { | |||
| 35 | + | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | filterStoreDir := filepath.Join(dataDir, "filters", "store") | |||
| 39 | + | filterJSONDir := filepath.Join(dataDir, "filters", "json") | |||
| 40 | + | filterManager, err := filtering.NewManager(filtering.ManagerConfig{ | |||
| 41 | + | StoreDir: filterStoreDir, | |||
| 42 | + | JSONDir: filterJSONDir, | |||
| 43 | + | Enabled: cfg.ContentFiltering.Enabled, | |||
| 44 | + | AutoUpdate: cfg.ContentFiltering.AutoUpdate, | |||
| 45 | + | }) | |||
| 46 | + | if err != nil { | |||
| 47 | + | logger.Warn().Err(err).Msg("failed to create filter manager, continuing without content filtering") | |||
| 48 | + | } else { | |||
| 49 | + | if err := filterManager.Initialize(ctx); err != nil { | |||
| 50 | + | logger.Warn().Err(err).Msg("failed to initialize filters, will load async") | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | schemeHandler := webkit.NewDumbSchemeHandler(ctx) | |||
| 55 | + | schemeHandler.SetAssets(assets.WebUIAssets) | |||
| 56 | + | schemeHandler.RegisterWithContext(wkCtx) | |||
| 57 | + | ||||
| 58 | + | settings := webkit.NewSettingsManager(ctx, cfg) | |||
| 59 | + | injector := webkit.NewContentInjector(themeManager.PrefersDark()) | |||
| 60 | + | ||||
| 61 | + | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector) | |||
| 62 | + | themeCSSText := themeManager.GetWebUIThemeCSS() | |||
| 63 | + | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil { | |||
| 64 | + | logger.Warn().Err(err).Msg("failed to prepare WebUI theme CSS") | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | messageRouter := webkit.NewMessageRouter(ctx) | |||
| 68 | + | poolCfg := webkit.DefaultPoolConfig() | |||
| 69 | + | pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter) | |||
| 70 | + | ||||
| 71 | + | if filterManager != nil { | |||
| 72 | + | pool.SetFilterApplier(filterManager) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | return WebKitStack{ | |||
| 76 | + | Context: wkCtx, | |||
| 77 | + | Settings: settings, | |||
| 78 | + | Injector: injector, | |||
| 79 | + | MessageRouter: messageRouter, | |||
| 80 | + | Pool: pool, | |||
| 81 | + | FilterManager: filterManager, | |||
| 82 | + | } | |||
| 83 | + | } | |||
M internal/cli/app.go +7 -9
| @@ -35,11 +35,9 @@ type App struct { | |||||
| 35 | 35 | ||||
| 36 | // NewApp creates a new CLI application with all dependencies. | 36 | // NewApp creates a new CLI application with all dependencies. | ||
| 37 | func NewApp() (*App, error) { | 37 | func NewApp() (*App, error) { | ||
| 38 | + | const dataDirPerm = 0o755 | |||
| 38 | // Load config | 39 | // Load config | ||
| 39 | - | cfg, err := loadConfig() | 40 | + | cfg := loadConfig() |
| 40 | - | if err != nil { | |||
| 41 | - | return nil, fmt.Errorf("load config: %w", err) | |||
| 42 | - | } | |||
| 43 | 41 | ||||
| 44 | // Create theme from config | 42 | // Create theme from config | ||
| 45 | theme := styles.NewTheme(cfg) | 43 | theme := styles.NewTheme(cfg) | ||
| @@ -79,7 +77,7 @@ func NewApp() (*App, error) { | |||||
| 79 | dbPath := filepath.Dir(dbFile) | 77 | dbPath := filepath.Dir(dbFile) | ||
| 80 | 78 | ||||
| 81 | // Ensure data directory exists | 79 | // Ensure data directory exists | ||
| 82 | - | if err := os.MkdirAll(dbPath, 0755); err != nil { | 80 | + | if err = os.MkdirAll(dbPath, dataDirPerm); err != nil { |
| 83 | return nil, fmt.Errorf("create data dir: %w", err) | 81 | return nil, fmt.Errorf("create data dir: %w", err) | ||
| 84 | } | 82 | } | ||
| 85 | 83 | ||||
| @@ -125,17 +123,17 @@ func (a *App) Ctx() context.Context { | |||||
| 125 | } | 123 | } | ||
| 126 | 124 | ||||
| 127 | // loadConfig loads configuration from standard locations. | 125 | // loadConfig loads configuration from standard locations. | ||
| 128 | - | func loadConfig() (*config.Config, error) { | 126 | + | func loadConfig() *config.Config { |
| 129 | mgr, err := config.NewManager() | 127 | mgr, err := config.NewManager() | ||
| 130 | if err != nil { | 128 | if err != nil { | ||
| 131 | // Return default config if manager fails | 129 | // Return default config if manager fails | ||
| 132 | - | return config.DefaultConfig(), nil | 130 | + | return config.DefaultConfig() |
| 133 | } | 131 | } | ||
| 134 | 132 | ||||
| 135 | if err := mgr.Load(); err != nil { | 133 | if err := mgr.Load(); err != nil { | ||
| 136 | // Return default config if loading fails | 134 | // Return default config if loading fails | ||
| 137 | - | return config.DefaultConfig(), nil | 135 | + | return config.DefaultConfig() |
| 138 | } | 136 | } | ||
| 139 | 137 | ||||
| 140 | - | return mgr.Get(), nil | 138 | + | return mgr.Get() |
| 141 | } | 139 | } | ||
M internal/cli/cmd/about.go +1 -1
| @@ -19,7 +19,7 @@ func init() { | |||||
| 19 | rootCmd.AddCommand(aboutCmd) | 19 | rootCmd.AddCommand(aboutCmd) | ||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | - | func runAbout(cmd *cobra.Command, args []string) error { | 22 | + | func runAbout(_ *cobra.Command, _ []string) error { |
| 23 | app := GetApp() | 23 | app := GetApp() | ||
| 24 | if app == nil { | 24 | if app == nil { | ||
| 25 | return fmt.Errorf("app not initialized") | 25 | return fmt.Errorf("app not initialized") | ||
M internal/cli/cmd/dmenu.go +1 -1
| @@ -44,7 +44,7 @@ func init() { | |||||
| 44 | dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)") | 44 | dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)") | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | - | func runDmenu(cmd *cobra.Command, args []string) error { | 47 | + | func runDmenu(_ *cobra.Command, _ []string) error { |
| 48 | app := GetApp() | 48 | app := GetApp() | ||
| 49 | if app == nil { | 49 | if app == nil { | ||
| 50 | return fmt.Errorf("app not initialized") | 50 | return fmt.Errorf("app not initialized") | ||
M internal/cli/cmd/doctor.go +3 -9
| @@ -39,7 +39,7 @@ func init() { | |||||
| 39 | doctorCmd.Flags().BoolVar(&doctorOnlyRuntime, "runtime", false, "Only run runtime checks (GTK4/WebKitGTK)") | 39 | doctorCmd.Flags().BoolVar(&doctorOnlyRuntime, "runtime", false, "Only run runtime checks (GTK4/WebKitGTK)") | ||
| 40 | doctorCmd.Flags().BoolVar(&doctorOnlyMedia, "media", false, "Only run media checks (GStreamer/VA-API)") | 40 | doctorCmd.Flags().BoolVar(&doctorOnlyMedia, "media", false, "Only run media checks (GStreamer/VA-API)") | ||
| 41 | } | 41 | } | ||
| 42 | - | func runDoctor(cmd *cobra.Command, args []string) error { | 42 | + | func runDoctor(_ *cobra.Command, _ []string) error { |
| 43 | app := GetApp() | 43 | app := GetApp() | ||
| 44 | if app == nil { | 44 | if app == nil { | ||
| 45 | return fmt.Errorf("app not initialized") | 45 | return fmt.Errorf("app not initialized") | ||
| @@ -49,14 +49,8 @@ func runDoctor(cmd *cobra.Command, args []string) error { | |||||
| 49 | return fmt.Errorf("--runtime and --media are mutually exclusive") | 49 | return fmt.Errorf("--runtime and --media are mutually exclusive") | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | - | runRuntime := true | 52 | + | runRuntime := !doctorOnlyMedia |
| 53 | - | runMedia := true | 53 | + | runMedia := !doctorOnlyRuntime |
| 54 | - | if doctorOnlyRuntime { | |||
| 55 | - | runMedia = false | |||
| 56 | - | } | |||
| 57 | - | if doctorOnlyMedia { | |||
| 58 | - | runRuntime = false | |||
| 59 | - | } | |||
| 60 | 54 | ||||
| 61 | report := styles.DoctorReport{OverallOK: true} | 55 | report := styles.DoctorReport{OverallOK: true} | ||
| 62 | 56 | ||||
M internal/cli/cmd/history.go +6 -4
| @@ -16,6 +16,8 @@ var ( | |||||
| 16 | historyMax int | 16 | historyMax int | ||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | + | const defaultHistoryMax = 50 | |||
| 20 | + | ||||
| 19 | var historyCmd = &cobra.Command{ | 21 | var historyCmd = &cobra.Command{ | ||
| 20 | Use: "history", | 22 | Use: "history", | ||
| 21 | Short: "Browse and manage history", | 23 | Short: "Browse and manage history", | ||
| @@ -27,10 +29,10 @@ func init() { | |||||
| 27 | rootCmd.AddCommand(historyCmd) | 29 | rootCmd.AddCommand(historyCmd) | ||
| 28 | 30 | ||||
| 29 | historyCmd.Flags().BoolVar(&historyJSON, "json", false, "output as JSON") | 31 | historyCmd.Flags().BoolVar(&historyJSON, "json", false, "output as JSON") | ||
| 30 | - | historyCmd.Flags().IntVar(&historyMax, "max", 50, "maximum entries to show (for --json)") | 32 | + | historyCmd.Flags().IntVar(&historyMax, "max", defaultHistoryMax, "maximum entries to show (for --json)") |
| 31 | } | 33 | } | ||
| 32 | 34 | ||||
| 33 | - | func runHistory(cmd *cobra.Command, args []string) error { | 35 | + | func runHistory(_ *cobra.Command, _ []string) error { |
| 34 | app := GetApp() | 36 | app := GetApp() | ||
| 35 | if app == nil { | 37 | if app == nil { | ||
| 36 | return fmt.Errorf("app not initialized") | 38 | return fmt.Errorf("app not initialized") | ||
| @@ -98,7 +100,7 @@ func init() { | |||||
| 98 | historyCmd.AddCommand(statsCmd) | 100 | historyCmd.AddCommand(statsCmd) | ||
| 99 | } | 101 | } | ||
| 100 | 102 | ||||
| 101 | - | func runStats(cmd *cobra.Command, args []string) error { | 103 | + | func runStats(_ *cobra.Command, _ []string) error { |
| 102 | app := GetApp() | 104 | app := GetApp() | ||
| 103 | if app == nil { | 105 | if app == nil { | ||
| 104 | return fmt.Errorf("app not initialized") | 106 | return fmt.Errorf("app not initialized") | ||
| @@ -124,7 +126,7 @@ func init() { | |||||
| 124 | historyCmd.AddCommand(clearCmd) | 126 | historyCmd.AddCommand(clearCmd) | ||
| 125 | } | 127 | } | ||
| 126 | 128 | ||||
| 127 | - | func runClear(cmd *cobra.Command, args []string) error { | 129 | + | func runClear(_ *cobra.Command, _ []string) error { |
| 128 | app := GetApp() | 130 | app := GetApp() | ||
| 129 | if app == nil { | 131 | if app == nil { | ||
| 130 | return fmt.Errorf("app not initialized") | 132 | return fmt.Errorf("app not initialized") | ||
M internal/cli/cmd/logs.go +15 -9
| @@ -24,6 +24,8 @@ var ( | |||||
| 24 | logsClearAll bool | 24 | logsClearAll bool | ||
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | + | const defaultLogsLines = 50 | |||
| 28 | + | ||||
| 27 | var logsCmd = &cobra.Command{ | 29 | var logsCmd = &cobra.Command{ | ||
| 28 | Use: "logs [session]", | 30 | Use: "logs [session]", | ||
| 29 | Short: "View application logs", | 31 | Short: "View application logs", | ||
| @@ -44,7 +46,7 @@ func init() { | |||||
| 44 | rootCmd.AddCommand(logsCmd) | 46 | rootCmd.AddCommand(logsCmd) | ||
| 45 | 47 | ||||
| 46 | logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "follow log output in real-time") | 48 | logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "follow log output in real-time") | ||
| 47 | - | logsCmd.Flags().IntVarP(&logsLines, "lines", "n", 50, "number of lines to show") | 49 | + | logsCmd.Flags().IntVarP(&logsLines, "lines", "n", defaultLogsLines, "number of lines to show") |
| 48 | } | 50 | } | ||
| 49 | 51 | ||||
| 50 | // SessionInfo holds metadata about a log session. | 52 | // SessionInfo holds metadata about a log session. | ||
| @@ -57,7 +59,7 @@ type SessionInfo struct { | |||||
| 57 | ModTime time.Time | 59 | ModTime time.Time | ||
| 58 | } | 60 | } | ||
| 59 | 61 | ||||
| 60 | - | func runLogs(cmd *cobra.Command, args []string) error { | 62 | + | func runLogs(_ *cobra.Command, args []string) error { |
| 61 | app := GetApp() | 63 | app := GetApp() | ||
| 62 | if app == nil { | 64 | if app == nil { | ||
| 63 | return fmt.Errorf("app not initialized") | 65 | return fmt.Errorf("app not initialized") | ||
| @@ -188,11 +190,11 @@ func findSession(logDir, query string) (*SessionInfo, error) { | |||||
| 188 | return nil, fmt.Errorf("no sessions found") | 190 | return nil, fmt.Errorf("no sessions found") | ||
| 189 | } | 191 | } | ||
| 190 | 192 | ||||
| 191 | - | query = strings.ToLower(query) | 193 | + | queryLower := strings.ToLower(query) |
| 192 | 194 | ||||
| 193 | // Try exact short ID match first | 195 | // Try exact short ID match first | ||
| 194 | for _, s := range sessions { | 196 | for _, s := range sessions { | ||
| 195 | - | if strings.ToLower(s.ShortID) == query { | 197 | + | if strings.EqualFold(s.ShortID, queryLower) { |
| 196 | return &s, nil | 198 | return &s, nil | ||
| 197 | } | 199 | } | ||
| 198 | } | 200 | } | ||
| @@ -200,7 +202,7 @@ func findSession(logDir, query string) (*SessionInfo, error) { | |||||
| 200 | // Try partial match on full session ID | 202 | // Try partial match on full session ID | ||
| 201 | var matches []SessionInfo | 203 | var matches []SessionInfo | ||
| 202 | for _, s := range sessions { | 204 | for _, s := range sessions { | ||
| 203 | - | if strings.Contains(strings.ToLower(s.SessionID), query) { | 205 | + | if strings.Contains(strings.ToLower(s.SessionID), queryLower) { |
| 204 | matches = append(matches, s) | 206 | matches = append(matches, s) | ||
| 205 | } | 207 | } | ||
| 206 | } | 208 | } | ||
| @@ -221,12 +223,16 @@ func findSession(logDir, query string) (*SessionInfo, error) { | |||||
| 221 | } | 223 | } | ||
| 222 | 224 | ||||
| 223 | // showSession displays the last N lines of a session log. | 225 | // showSession displays the last N lines of a session log. | ||
| 224 | - | func showSession(logPath string, lines int, theme *styles.Theme) error { | 226 | + | func showSession(logPath string, lines int, theme *styles.Theme) (retErr error) { |
| 225 | file, err := os.Open(logPath) | 227 | file, err := os.Open(logPath) | ||
| 226 | if err != nil { | 228 | if err != nil { | ||
| 227 | return fmt.Errorf("open log file: %w", err) | 229 | return fmt.Errorf("open log file: %w", err) | ||
| 228 | } | 230 | } | ||
| 229 | - | defer file.Close() | 231 | + | defer func() { |
| 232 | + | if closeErr := file.Close(); closeErr != nil && retErr == nil { | |||
| 233 | + | retErr = fmt.Errorf("close log file: %w", closeErr) | |||
| 234 | + | } | |||
| 235 | + | }() | |||
| 230 | 236 | ||||
| 231 | // Read all lines | 237 | // Read all lines | ||
| 232 | var allLines []string | 238 | var allLines []string | ||
| @@ -258,7 +264,7 @@ func tailSession(logPath string, theme *styles.Theme) error { | |||||
| 258 | if err != nil { | 264 | if err != nil { | ||
| 259 | return fmt.Errorf("open log file: %w", err) | 265 | return fmt.Errorf("open log file: %w", err) | ||
| 260 | } | 266 | } | ||
| 261 | - | defer file.Close() | 267 | + | defer func() { _ = file.Close() }() |
| 262 | 268 | ||||
| 263 | // Seek to end | 269 | // Seek to end | ||
| 264 | _, _ = file.Seek(0, io.SeekEnd) | 270 | _, _ = file.Seek(0, io.SeekEnd) | ||
| @@ -366,7 +372,7 @@ func init() { | |||||
| 366 | logsClearCmd.Flags().BoolVar(&logsClearAll, "all", false, "remove all session logs") | 372 | logsClearCmd.Flags().BoolVar(&logsClearAll, "all", false, "remove all session logs") | ||
| 367 | } | 373 | } | ||
| 368 | 374 | ||||
| 369 | - | func runLogsClear(cmd *cobra.Command, args []string) error { | 375 | + | func runLogsClear(_ *cobra.Command, _ []string) error { |
| 370 | app := GetApp() | 376 | app := GetApp() | ||
| 371 | if app == nil { | 377 | if app == nil { | ||
| 372 | return fmt.Errorf("app not initialized") | 378 | return fmt.Errorf("app not initialized") | ||
M internal/cli/cmd/purge.go +123 -96
| @@ -21,6 +21,27 @@ var ( | |||||
| 21 | purgeFilters bool | 21 | purgeFilters bool | ||
| 22 | ) | 22 | ) | ||
| 23 | 23 | ||||
| 24 | + | const dumberAppName = "dumber" | |||
| 25 | + | ||||
| 26 | + | type purgeDir struct { | |||
| 27 | + | path string | |||
| 28 | + | desc string | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | type purgeDirWithSize struct { | |||
| 32 | + | path string | |||
| 33 | + | desc string | |||
| 34 | + | size int64 | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | type purgeDesktopStatus struct { | |||
| 38 | + | desktopInstalled bool | |||
| 39 | + | iconInstalled bool | |||
| 40 | + | isDefault bool | |||
| 41 | + | desktopPath string | |||
| 42 | + | iconPath string | |||
| 43 | + | } | |||
| 44 | + | ||||
| 24 | var purgeCmd = &cobra.Command{ | 45 | var purgeCmd = &cobra.Command{ | ||
| 25 | Use: "purge", | 46 | Use: "purge", | ||
| 26 | Short: "Remove all dumber data and configuration", | 47 | Short: "Remove all dumber data and configuration", | ||
| @@ -46,7 +67,7 @@ func init() { | |||||
| 46 | purgeCmd.Flags().BoolVar(&purgeFilters, "filters", false, "only remove content filter cache") | 67 | purgeCmd.Flags().BoolVar(&purgeFilters, "filters", false, "only remove content filter cache") | ||
| 47 | } | 68 | } | ||
| 48 | 69 | ||||
| 49 | - | func runPurge(cmd *cobra.Command, args []string) error { | 70 | + | func runPurge(_ *cobra.Command, _ []string) error { |
| 50 | home, err := os.UserHomeDir() | 71 | home, err := os.UserHomeDir() | ||
| 51 | if err != nil { | 72 | if err != nil { | ||
| 52 | return fmt.Errorf("get home dir: %w", err) | 73 | return fmt.Errorf("get home dir: %w", err) | ||
| @@ -57,70 +78,90 @@ func runPurge(cmd *cobra.Command, args []string) error { | |||||
| 57 | return runPurgeFilters(home) | 78 | return runPurgeFilters(home) | ||
| 58 | } | 79 | } | ||
| 59 | 80 | ||||
| 60 | - | // Build list of directories to remove | 81 | + | dirs := purgeDirList(home) |
| 61 | - | dirs := []struct { | 82 | + | existing := collectExistingDirs(dirs) |
| 62 | - | path string | 83 | + | |
| 63 | - | desc string | 84 | + | var desktopStatus *purgeDesktopStatus |
| 64 | - | }{ | 85 | + | // Check desktop integration status if --desktop flag is set |
| 65 | - | {getXDGPath("XDG_CONFIG_HOME", home, ".config", "dumber"), "config"}, | 86 | + | if purgeDesktop { |
| 66 | - | {getXDGPath("XDG_DATA_HOME", home, ".local/share", "dumber"), "data"}, | 87 | + | desktopStatus = getDesktopStatus() |
| 67 | - | {getXDGPath("XDG_STATE_HOME", home, ".local/state", "dumber"), "state"}, | |||
| 68 | - | {getXDGPath("XDG_CACHE_HOME", home, ".cache", "dumber"), "cache"}, | |||
| 69 | } | 88 | } | ||
| 70 | 89 | ||||
| 71 | - | // Check which directories exist | 90 | + | // Check if there's anything to remove |
| 72 | - | var existing []struct { | 91 | + | if len(existing) == 0 && desktopStatus == nil { |
| 73 | - | path string | 92 | + | fmt.Println("No dumber directories found.") |
| 74 | - | desc string | 93 | + | return nil |
| 75 | - | size int64 | |||
| 76 | } | 94 | } | ||
| 77 | 95 | ||||
| 78 | - | for _, d := range dirs { | 96 | + | describePurgeTargets(existing, desktopStatus) |
| 79 | - | if info, err := os.Stat(d.path); err == nil && info.IsDir() { | 97 | + | |
| 80 | - | size := getDirSize(d.path) | 98 | + | // Confirm unless --force |
| 81 | - | existing = append(existing, struct { | 99 | + | if !confirmPurge() { |
| 82 | - | path string | 100 | + | fmt.Println("Aborted.") |
| 83 | - | desc string | 101 | + | return nil |
| 84 | - | size int64 | 102 | + | } |
| 85 | - | }{d.path, d.desc, size}) | 103 | + | |
| 104 | + | // Remove directories | |||
| 105 | + | errors := removeDirs(existing) | |||
| 106 | + | ||||
| 107 | + | // Remove desktop integration | |||
| 108 | + | if desktopStatus != nil { | |||
| 109 | + | errors = removeDesktopIntegration(errors) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | if len(errors) > 0 { | |||
| 113 | + | fmt.Println() | |||
| 114 | + | fmt.Println("Errors:") | |||
| 115 | + | for _, e := range errors { | |||
| 116 | + | fmt.Printf(" \u2717 %s\n", e) | |||
| 86 | } | 117 | } | ||
| 118 | + | return fmt.Errorf("failed to remove some items") | |||
| 87 | } | 119 | } | ||
| 88 | 120 | ||||
| 89 | - | // Check desktop integration status if --desktop flag is set | 121 | + | fmt.Println() |
| 90 | - | var desktopStatus *struct { | 122 | + | fmt.Println("Purge complete.") |
| 91 | - | desktopInstalled bool | 123 | + | return nil |
| 92 | - | iconInstalled bool | 124 | + | } |
| 93 | - | isDefault bool | 125 | + | |
| 94 | - | desktopPath string | 126 | + | func purgeDirList(home string) []purgeDir { |
| 95 | - | iconPath string | 127 | + | return []purgeDir{ |
| 128 | + | {getXDGPath("XDG_CONFIG_HOME", home, ".config"), "config"}, | |||
| 129 | + | {getXDGPath("XDG_DATA_HOME", home, ".local/share"), "data"}, | |||
| 130 | + | {getXDGPath("XDG_STATE_HOME", home, ".local/state"), "state"}, | |||
| 131 | + | {getXDGPath("XDG_CACHE_HOME", home, ".cache"), "cache"}, | |||
| 96 | } | 132 | } | ||
| 97 | - | if purgeDesktop { | 133 | + | } |
| 98 | - | adapter := desktop.New() | 134 | + | |
| 99 | - | status, err := adapter.GetStatus(context.Background()) | 135 | + | func collectExistingDirs(dirs []purgeDir) []purgeDirWithSize { |
| 100 | - | if err == nil && (status.DesktopFileInstalled || status.IconInstalled) { | 136 | + | var existing []purgeDirWithSize |
| 101 | - | desktopStatus = &struct { | 137 | + | for _, d := range dirs { |
| 102 | - | desktopInstalled bool | 138 | + | if info, err := os.Stat(d.path); err == nil && info.IsDir() { |
| 103 | - | iconInstalled bool | 139 | + | existing = append(existing, purgeDirWithSize{ |
| 104 | - | isDefault bool | 140 | + | path: d.path, |
| 105 | - | desktopPath string | 141 | + | desc: d.desc, |
| 106 | - | iconPath string | 142 | + | size: getDirSize(d.path), |
| 107 | - | }{ | 143 | + | }) |
| 108 | - | desktopInstalled: status.DesktopFileInstalled, | |||
| 109 | - | iconInstalled: status.IconInstalled, | |||
| 110 | - | isDefault: status.IsDefaultBrowser, | |||
| 111 | - | desktopPath: status.DesktopFilePath, | |||
| 112 | - | iconPath: status.IconFilePath, | |||
| 113 | - | } | |||
| 114 | } | 144 | } | ||
| 115 | } | 145 | } | ||
| 146 | + | return existing | |||
| 147 | + | } | |||
| 116 | 148 | ||||
| 117 | - | // Check if there's anything to remove | 149 | + | func getDesktopStatus() *purgeDesktopStatus { |
| 118 | - | if len(existing) == 0 && desktopStatus == nil { | 150 | + | adapter := desktop.New() |
| 119 | - | fmt.Println("No dumber directories found.") | 151 | + | status, err := adapter.GetStatus(context.Background()) |
| 152 | + | if err != nil || (!status.DesktopFileInstalled && !status.IconInstalled) { | |||
| 120 | return nil | 153 | return nil | ||
| 121 | } | 154 | } | ||
| 155 | + | return &purgeDesktopStatus{ | |||
| 156 | + | desktopInstalled: status.DesktopFileInstalled, | |||
| 157 | + | iconInstalled: status.IconInstalled, | |||
| 158 | + | isDefault: status.IsDefaultBrowser, | |||
| 159 | + | desktopPath: status.DesktopFilePath, | |||
| 160 | + | iconPath: status.IconFilePath, | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 122 | 163 | ||||
| 123 | - | // Show what will be deleted | 164 | + | func describePurgeTargets(existing []purgeDirWithSize, desktopStatus *purgeDesktopStatus) { |
| 124 | fmt.Println("The following will be removed:") | 165 | fmt.Println("The following will be removed:") | ||
| 125 | fmt.Println() | 166 | fmt.Println() | ||
| 126 | for _, d := range existing { | 167 | for _, d := range existing { | ||
| @@ -138,21 +179,20 @@ func runPurge(cmd *cobra.Command, args []string) error { | |||||
| 138 | } | 179 | } | ||
| 139 | } | 180 | } | ||
| 140 | fmt.Println() | 181 | fmt.Println() | ||
| 182 | + | } | |||
| 141 | 183 | ||||
| 142 | - | // Confirm unless --force | 184 | + | func confirmPurge() bool { |
| 143 | - | if !purgeForce { | 185 | + | if purgeForce { |
| 144 | - | fmt.Print("Are you sure? [y/N]: ") | 186 | + | return true |
| 145 | - | reader := bufio.NewReader(os.Stdin) | |||
| 146 | - | response, _ := reader.ReadString('\n') | |||
| 147 | - | response = strings.TrimSpace(strings.ToLower(response)) | |||
| 148 | - | ||||
| 149 | - | if response != "y" && response != "yes" { | |||
| 150 | - | fmt.Println("Aborted.") | |||
| 151 | - | return nil | |||
| 152 | - | } | |||
| 153 | } | 187 | } | ||
| 188 | + | fmt.Print("Are you sure? [y/N]: ") | |||
| 189 | + | reader := bufio.NewReader(os.Stdin) | |||
| 190 | + | response, _ := reader.ReadString('\n') | |||
| 191 | + | response = strings.TrimSpace(strings.ToLower(response)) | |||
| 192 | + | return response == "y" || response == "yes" | |||
| 193 | + | } | |||
| 154 | 194 | ||||
| 155 | - | // Remove directories | 195 | + | func removeDirs(existing []purgeDirWithSize) []string { |
| 156 | var errors []string | 196 | var errors []string | ||
| 157 | for _, d := range existing { | 197 | for _, d := range existing { | ||
| 158 | if err := os.RemoveAll(d.path); err != nil { | 198 | if err := os.RemoveAll(d.path); err != nil { | ||
| @@ -161,48 +201,35 @@ func runPurge(cmd *cobra.Command, args []string) error { | |||||
| 161 | fmt.Printf("\u2713 Removed %s\n", d.path) | 201 | fmt.Printf("\u2713 Removed %s\n", d.path) | ||
| 162 | } | 202 | } | ||
| 163 | } | 203 | } | ||
| 204 | + | return errors | |||
| 205 | + | } | |||
| 164 | 206 | ||||
| 165 | - | // Remove desktop integration | 207 | + | func removeDesktopIntegration(errors []string) []string { |
| 166 | - | if desktopStatus != nil { | 208 | + | adapter := desktop.New() |
| 167 | - | adapter := desktop.New() | 209 | + | uc := usecase.NewRemoveDesktopUseCase(adapter) |
| 168 | - | uc := usecase.NewRemoveDesktopUseCase(adapter) | 210 | + | result, err := uc.Execute(context.Background()) |
| 169 | - | result, err := uc.Execute(context.Background()) | 211 | + | if err != nil { |
| 170 | - | if err != nil { | 212 | + | return append(errors, fmt.Sprintf("desktop integration: %v", err)) |
| 171 | - | errors = append(errors, fmt.Sprintf("desktop integration: %v", err)) | |||
| 172 | - | } else { | |||
| 173 | - | if result.WasDesktopInstalled { | |||
| 174 | - | fmt.Printf("\u2713 Removed %s\n", result.RemovedDesktopPath) | |||
| 175 | - | } | |||
| 176 | - | if result.WasIconInstalled { | |||
| 177 | - | fmt.Printf("\u2713 Removed %s\n", result.RemovedIconPath) | |||
| 178 | - | } | |||
| 179 | - | if result.WasDefault { | |||
| 180 | - | fmt.Println(" \u26a0 Was default browser - please set a new default") | |||
| 181 | - | } | |||
| 182 | - | } | |||
| 183 | } | 213 | } | ||
| 184 | - | 214 | + | if result.WasDesktopInstalled { | |
| 185 | - | if len(errors) > 0 { | 215 | + | fmt.Printf("\u2713 Removed %s\n", result.RemovedDesktopPath) |
| 186 | - | fmt.Println() | |||
| 187 | - | fmt.Println("Errors:") | |||
| 188 | - | for _, e := range errors { | |||
| 189 | - | fmt.Printf(" \u2717 %s\n", e) | |||
| 190 | - | } | |||
| 191 | - | return fmt.Errorf("failed to remove some items") | |||
| 192 | } | 216 | } | ||
| 193 | - | 217 | + | if result.WasIconInstalled { | |
| 194 | - | fmt.Println() | 218 | + | fmt.Printf("\u2713 Removed %s\n", result.RemovedIconPath) |
| 195 | - | fmt.Println("Purge complete.") | 219 | + | } |
| 196 | - | return nil | 220 | + | if result.WasDefault { |
| 221 | + | fmt.Println(" \u26a0 Was default browser - please set a new default") | |||
| 222 | + | } | |||
| 223 | + | return errors | |||
| 197 | } | 224 | } | ||
| 198 | 225 | ||||
| 199 | // getXDGPath returns the XDG path for dumber. | 226 | // getXDGPath returns the XDG path for dumber. | ||
| 200 | - | func getXDGPath(envVar, home, defaultSuffix, appName string) string { | 227 | + | func getXDGPath(envVar, home, defaultSuffix string) string { |
| 201 | base := os.Getenv(envVar) | 228 | base := os.Getenv(envVar) | ||
| 202 | if base == "" { | 229 | if base == "" { | ||
| 203 | base = filepath.Join(home, defaultSuffix) | 230 | base = filepath.Join(home, defaultSuffix) | ||
| 204 | } | 231 | } | ||
| 205 | - | return filepath.Join(base, appName) | 232 | + | return filepath.Join(base, dumberAppName) |
| 206 | } | 233 | } | ||
| 207 | 234 | ||||
| 208 | // getDirSize calculates the total size of a directory. | 235 | // getDirSize calculates the total size of a directory. | ||
| @@ -236,7 +263,7 @@ func formatSize(bytes int64) string { | |||||
| 236 | 263 | ||||
| 237 | // runPurgeFilters removes only the content filter cache. | 264 | // runPurgeFilters removes only the content filter cache. | ||
| 238 | func runPurgeFilters(home string) error { | 265 | func runPurgeFilters(home string) error { | ||
| 239 | - | dataDir := getXDGPath("XDG_DATA_HOME", home, ".local/share", "dumber") | 266 | + | dataDir := getXDGPath("XDG_DATA_HOME", home, ".local/share") |
| 240 | filterDirs := []struct { | 267 | filterDirs := []struct { | ||
| 241 | path string | 268 | path string | ||
| 242 | desc string | 269 | desc string | ||
M internal/cli/cmd/root.go +3 -3
| @@ -18,7 +18,7 @@ var ( | |||||
| 18 | Use: "dumber", | 18 | Use: "dumber", | ||
| 19 | Short: "A minimal web browser", | 19 | Short: "A minimal web browser", | ||
| 20 | Long: `Dumber is a minimal, keyboard-driven web browser built with GTK4 and WebKitGTK.`, | 20 | Long: `Dumber is a minimal, keyboard-driven web browser built with GTK4 and WebKitGTK.`, | ||
| 21 | - | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { | 21 | + | PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { |
| 22 | // Skip initialization for help commands | 22 | // Skip initialization for help commands | ||
| 23 | if cmd.Name() == "help" || cmd.Name() == "completion" { | 23 | if cmd.Name() == "help" || cmd.Name() == "completion" { | ||
| 24 | return nil | 24 | return nil | ||
| @@ -33,7 +33,7 @@ var ( | |||||
| 33 | app.BuildInfo = buildInfo | 33 | app.BuildInfo = buildInfo | ||
| 34 | return nil | 34 | return nil | ||
| 35 | }, | 35 | }, | ||
| 36 | - | PersistentPostRun: func(cmd *cobra.Command, args []string) { | 36 | + | PersistentPostRun: func(_ *cobra.Command, _ []string) { |
| 37 | if app != nil { | 37 | if app != nil { | ||
| 38 | _ = app.Close() | 38 | _ = app.Close() | ||
| 39 | } | 39 | } | ||
| @@ -65,7 +65,7 @@ If a URL is provided, navigate to it. Otherwise, open the homepage. | |||||
| 65 | Examples: | 65 | Examples: | ||
| 66 | dumber browse # Open browser to homepage | 66 | dumber browse # Open browser to homepage | ||
| 67 | dumber browse example.com # Open browser to URL`, | 67 | dumber browse example.com # Open browser to URL`, | ||
| 68 | - | Run: func(cmd *cobra.Command, args []string) { | 68 | + | Run: func(_ *cobra.Command, _ []string) { |
| 69 | // This is handled by main.go before cobra runs | 69 | // This is handled by main.go before cobra runs | ||
| 70 | }, | 70 | }, | ||
| 71 | } | 71 | } | ||
M internal/cli/cmd/setup.go +2 -2
| @@ -55,7 +55,7 @@ func init() { | |||||
| 55 | setupCmd.AddCommand(setupDefaultCmd) | 55 | setupCmd.AddCommand(setupDefaultCmd) | ||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | - | func runSetupInstall(cmd *cobra.Command, args []string) error { | 58 | + | func runSetupInstall(_ *cobra.Command, _ []string) error { |
| 59 | app := GetApp() | 59 | app := GetApp() | ||
| 60 | if app == nil { | 60 | if app == nil { | ||
| 61 | return fmt.Errorf("app not initialized") | 61 | return fmt.Errorf("app not initialized") | ||
| @@ -105,7 +105,7 @@ func runSetupInstall(cmd *cobra.Command, args []string) error { | |||||
| 105 | return nil | 105 | return nil | ||
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | - | func runSetupDefault(cmd *cobra.Command, args []string) error { | 108 | + | func runSetupDefault(_ *cobra.Command, _ []string) error { |
| 109 | app := GetApp() | 109 | app := GetApp() | ||
| 110 | if app == nil { | 110 | if app == nil { | ||
| 111 | return fmt.Errorf("app not initialized") | 111 | return fmt.Errorf("app not initialized") | ||
M internal/cli/model/cleanup.go +4 -1
| @@ -37,6 +37,9 @@ type cleanupCompleteMsg struct { | |||||
| 37 | 37 | ||||
| 38 | // Init implements tea.Model. | 38 | // Init implements tea.Model. | ||
| 39 | func (m CleanupModel) Init() tea.Cmd { | 39 | func (m CleanupModel) Init() tea.Cmd { | ||
| 40 | + | if m.theme == nil { | |||
| 41 | + | return nil | |||
| 42 | + | } | |||
| 40 | return nil | 43 | return nil | ||
| 41 | } | 44 | } | ||
| 42 | 45 | ||||
| @@ -69,7 +72,7 @@ func (m CleanupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||||
| 69 | m.cleanup = cleanup | 72 | m.cleanup = cleanup | ||
| 70 | 73 | ||||
| 71 | if m.cleanup.Done() { | 74 | if m.cleanup.Done() { | ||
| 72 | - | if m.cleanup.Cancelled { | 75 | + | if m.cleanup.Canceled { |
| 73 | return m, tea.Quit | 76 | return m, tea.Quit | ||
| 74 | } | 77 | } | ||
| 75 | if m.cleanup.Confirmed { | 78 | if m.cleanup.Confirmed { | ||
M internal/cli/model/dmenu.go +3 -1
| @@ -64,6 +64,8 @@ type dmenuLoadedMsg struct { | |||||
| 64 | err error | 64 | err error | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | + | const defaultDmenuHistoryLimit = 500 | |||
| 68 | + | ||||
| 67 | // Init implements tea.Model. | 69 | // Init implements tea.Model. | ||
| 68 | func (m DmenuModel) Init() tea.Cmd { | 70 | func (m DmenuModel) Init() tea.Cmd { | ||
| 69 | return tea.Batch( | 71 | return tea.Batch( | ||
| @@ -76,7 +78,7 @@ func (m DmenuModel) Init() tea.Cmd { | |||||
| 76 | func (m DmenuModel) loadEntries() tea.Msg { | 78 | func (m DmenuModel) loadEntries() tea.Msg { | ||
| 77 | ctx := context.Background() | 79 | ctx := context.Background() | ||
| 78 | 80 | ||||
| 79 | - | entries, err := m.historyUC.GetRecent(ctx, 500, 0) | 81 | + | entries, err := m.historyUC.GetRecent(ctx, defaultDmenuHistoryLimit, 0) |
| 80 | if err != nil { | 82 | if err != nil { | ||
| 81 | return dmenuLoadedMsg{err: err} | 83 | return dmenuLoadedMsg{err: err} | ||
| 82 | } | 84 | } | ||
M internal/cli/model/history.go +195 -151
| @@ -166,178 +166,216 @@ func (m HistoryModel) loadDomainStats() tea.Msg { | |||||
| 166 | 166 | ||||
| 167 | // Update implements tea.Model. | 167 | // Update implements tea.Model. | ||
| 168 | func (m HistoryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | 168 | func (m HistoryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 169 | - | var cmds []tea.Cmd | |||
| 170 | - | ||||
| 171 | - | // Handle cleanup modal | |||
| 172 | if m.cleanup != nil { | 169 | if m.cleanup != nil { | ||
| 173 | - | cleanup, cmd := m.cleanup.Update(msg) | 170 | + | return m.handleCleanupModal(msg) |
| 174 | - | m.cleanup = &cleanup | |||
| 175 | - | if m.cleanup.Done() { | |||
| 176 | - | if m.cleanup.Confirmed { | |||
| 177 | - | // Perform cleanup | |||
| 178 | - | cmds = append(cmds, m.performCleanup(m.cleanup.SelectedRange())) | |||
| 179 | - | } | |||
| 180 | - | m.cleanup = nil | |||
| 181 | - | } | |||
| 182 | - | return m, cmd | |||
| 183 | } | 171 | } | ||
| 184 | - | ||||
| 185 | - | // Handle confirm dialog | |||
| 186 | if m.confirm != nil { | 172 | if m.confirm != nil { | ||
| 187 | - | confirm, cmd := m.confirm.Update(msg) | 173 | + | return m.handleConfirmModal(msg) |
| 188 | - | m.confirm = &confirm | |||
| 189 | - | if m.confirm.Done() { | |||
| 190 | - | if m.confirm.Result() { | |||
| 191 | - | // Perform the confirmed action (delete current entry) | |||
| 192 | - | cmds = append(cmds, m.deleteCurrentEntry()) | |||
| 193 | - | } | |||
| 194 | - | m.confirm = nil | |||
| 195 | - | } | |||
| 196 | - | return m, cmd | |||
| 197 | } | 174 | } | ||
| 198 | 175 | ||||
| 199 | switch msg := msg.(type) { | 176 | switch msg := msg.(type) { | ||
| 200 | case tea.WindowSizeMsg: | 177 | case tea.WindowSizeMsg: | ||
| 201 | - | m.width = msg.Width | 178 | + | return m.handleWindowSize(msg) |
| 202 | - | m.height = msg.Height | |||
| 203 | - | m.help.Width = msg.Width | |||
| 204 | - | m.updateList() | |||
| 205 | - | ||||
| 206 | case tea.KeyMsg: | 179 | case tea.KeyMsg: | ||
| 207 | - | // Search mode handling | 180 | + | return m.handleKeyMsg(msg) |
| 208 | - | if m.searchMode { | |||
| 209 | - | switch msg.String() { | |||
| 210 | - | case "esc": | |||
| 211 | - | m.searchMode = false | |||
| 212 | - | m.search.Blur() | |||
| 213 | - | return m, nil | |||
| 214 | - | case "enter": | |||
| 215 | - | m.searchMode = false | |||
| 216 | - | m.search.Blur() | |||
| 217 | - | // Perform search | |||
| 218 | - | return m, m.performSearch(m.search.Value()) | |||
| 219 | - | default: | |||
| 220 | - | var cmd tea.Cmd | |||
| 221 | - | m.search, cmd = m.search.Update(msg) | |||
| 222 | - | return m, cmd | |||
| 223 | - | } | |||
| 224 | - | } | |||
| 225 | 181 | ||||
| 226 | - | // Normal mode handling | 182 | + | case historyLoadedMsg: |
| 227 | - | switch { | 183 | + | return m.handleHistoryLoaded(msg) |
| 228 | - | case key.Matches(msg, m.keys.Quit): | |||
| 229 | - | return m, tea.Quit | |||
| 230 | - | ||||
| 231 | - | case key.Matches(msg, m.keys.Help): | |||
| 232 | - | m.showHelp = !m.showHelp | |||
| 233 | - | ||||
| 234 | - | case key.Matches(msg, m.keys.Search): | |||
| 235 | - | m.searchMode = true | |||
| 236 | - | m.search.Focus() | |||
| 237 | - | return m, textinput.Blink | |||
| 238 | - | ||||
| 239 | - | case key.Matches(msg, m.keys.Tab1): | |||
| 240 | - | m.tabs.SetActive(0) | |||
| 241 | - | m.updateList() | |||
| 242 | - | ||||
| 243 | - | case key.Matches(msg, m.keys.Tab2): | |||
| 244 | - | m.tabs.SetActive(1) | |||
| 245 | - | m.updateList() | |||
| 246 | - | ||||
| 247 | - | case key.Matches(msg, m.keys.Tab3): | |||
| 248 | - | m.tabs.SetActive(2) | |||
| 249 | - | m.updateList() | |||
| 250 | - | ||||
| 251 | - | case key.Matches(msg, m.keys.Tab4): | |||
| 252 | - | m.tabs.SetActive(3) | |||
| 253 | - | m.updateList() | |||
| 254 | - | ||||
| 255 | - | case key.Matches(msg, m.keys.NextTab): | |||
| 256 | - | // Cycle to next tab (wraps around) | |||
| 257 | - | next := (m.tabs.Active + 1) % 4 | |||
| 258 | - | m.tabs.SetActive(next) | |||
| 259 | - | m.updateList() | |||
| 260 | - | ||||
| 261 | - | case key.Matches(msg, m.keys.PrevTab): | |||
| 262 | - | // Cycle to previous tab (wraps around) | |||
| 263 | - | prev := (m.tabs.Active + 3) % 4 // +3 is same as -1 mod 4 | |||
| 264 | - | m.tabs.SetActive(prev) | |||
| 265 | - | m.updateList() | |||
| 266 | - | ||||
| 267 | - | case key.Matches(msg, m.keys.Open): | |||
| 268 | - | // Open selected URL in browser | |||
| 269 | - | if item := m.list.SelectedItem(); item != nil { | |||
| 270 | - | if hi, ok := item.(styles.HistoryItem); ok { | |||
| 271 | - | cmds = append(cmds, m.openURL(hi.URL)) | |||
| 272 | - | } | |||
| 273 | - | } | |||
| 274 | 184 | ||||
| 275 | - | case key.Matches(msg, m.keys.Delete): | 185 | + | case domainStatsMsg: |
| 276 | - | // Show delete confirmation | 186 | + | return m.handleDomainStats(msg) |
| 277 | - | if item := m.list.SelectedItem(); item != nil { | |||
| 278 | - | confirm := styles.NewConfirm(m.theme, "Delete this entry?") | |||
| 279 | - | m.confirm = &confirm | |||
| 280 | - | } | |||
| 281 | 187 | ||||
| 282 | - | case key.Matches(msg, m.keys.DeleteDomain): | 188 | + | case historyDeletedMsg: |
| 283 | - | // Delete all from domain | 189 | + | return m.handleHistoryDeleted(msg) |
| 284 | - | if item := m.list.SelectedItem(); item != nil { | |||
| 285 | - | if hi, ok := item.(styles.HistoryItem); ok { | |||
| 286 | - | confirm := styles.NewConfirm(m.theme, "Delete all from "+hi.Domain+"?") | |||
| 287 | - | m.confirm = &confirm | |||
| 288 | - | } | |||
| 289 | - | } | |||
| 290 | 190 | ||||
| 291 | - | case key.Matches(msg, m.keys.Cleanup): | 191 | + | case historyCleanedMsg: |
| 292 | - | // Show cleanup modal | 192 | + | return m.handleHistoryCleaned(msg) |
| 293 | - | cleanup := styles.NewCleanup(m.theme) | 193 | + | } |
| 294 | - | m.cleanup = &cleanup | |||
| 295 | - | ||||
| 296 | - | case key.Matches(msg, m.keys.Filter): | |||
| 297 | - | // Cycle through domain filters | |||
| 298 | - | m.cycleDomainFilter() | |||
| 299 | - | m.updateList() | |||
| 300 | - | ||||
| 301 | - | default: | |||
| 302 | - | // Pass to list | |||
| 303 | - | var cmd tea.Cmd | |||
| 304 | - | m.list, cmd = m.list.Update(msg) | |||
| 305 | - | cmds = append(cmds, cmd) | |||
| 306 | - | } | |||
| 307 | 194 | ||||
| 308 | - | case historyLoadedMsg: | 195 | + | return m, nil |
| 309 | - | if msg.err != nil { | 196 | + | } |
| 310 | - | m.err = msg.err | |||
| 311 | - | } else { | |||
| 312 | - | m.entries = msg.entries | |||
| 313 | - | m.updateList() | |||
| 314 | - | } | |||
| 315 | 197 | ||||
| 316 | - | case domainStatsMsg: | 198 | + | func (m HistoryModel) handleCleanupModal(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 317 | - | if msg.err != nil { | 199 | + | cleanup, cmd := m.cleanup.Update(msg) |
| 318 | - | m.err = msg.err | 200 | + | m.cleanup = &cleanup |
| 319 | - | } else { | 201 | + | if m.cleanup.Done() { |
| 320 | - | m.topDomains = msg.domains | 202 | + | if m.cleanup.Confirmed { |
| 203 | + | cmd = m.performCleanup(m.cleanup.SelectedRange()) | |||
| 321 | } | 204 | } | ||
| 205 | + | m.cleanup = nil | |||
| 206 | + | } | |||
| 207 | + | return m, cmd | |||
| 208 | + | } | |||
| 322 | 209 | ||||
| 323 | - | case historyDeletedMsg: | 210 | + | func (m HistoryModel) handleConfirmModal(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 324 | - | if msg.err != nil { | 211 | + | confirm, cmd := m.confirm.Update(msg) |
| 325 | - | m.err = msg.err | 212 | + | m.confirm = &confirm |
| 326 | - | } else { | 213 | + | if m.confirm.Done() { |
| 327 | - | // Reload history | 214 | + | if m.confirm.Result() { |
| 328 | - | cmds = append(cmds, m.loadHistory) | 215 | + | cmd = m.deleteCurrentEntry() |
| 329 | } | 216 | } | ||
| 217 | + | m.confirm = nil | |||
| 218 | + | } | |||
| 219 | + | return m, cmd | |||
| 220 | + | } | |||
| 330 | 221 | ||||
| 331 | - | case historyCleanedMsg: | 222 | + | func (m HistoryModel) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { |
| 332 | - | if msg.err != nil { | 223 | + | m.width = msg.Width |
| 333 | - | m.err = msg.err | 224 | + | m.height = msg.Height |
| 334 | - | } else { | 225 | + | m.help.Width = msg.Width |
| 335 | - | // Reload history | 226 | + | m.updateList() |
| 336 | - | cmds = append(cmds, m.loadHistory) | 227 | + | return m, nil |
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func (m HistoryModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |||
| 231 | + | if m.searchMode { | |||
| 232 | + | return m.handleSearchKey(msg) | |||
| 233 | + | } | |||
| 234 | + | return m.handleNormalKey(msg) | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func (m HistoryModel) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |||
| 238 | + | switch msg.String() { | |||
| 239 | + | case "esc": | |||
| 240 | + | m.searchMode = false | |||
| 241 | + | m.search.Blur() | |||
| 242 | + | return m, nil | |||
| 243 | + | case "enter": | |||
| 244 | + | m.searchMode = false | |||
| 245 | + | m.search.Blur() | |||
| 246 | + | return m, m.performSearch(m.search.Value()) | |||
| 247 | + | default: | |||
| 248 | + | var cmd tea.Cmd | |||
| 249 | + | m.search, cmd = m.search.Update(msg) | |||
| 250 | + | return m, cmd | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (m HistoryModel) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |||
| 255 | + | if updated, cmd, handled := m.handleGlobalKeys(msg); handled { | |||
| 256 | + | return updated, cmd | |||
| 257 | + | } | |||
| 258 | + | if updated, handled := m.handleTabSwitchKeys(msg); handled { | |||
| 259 | + | return updated, nil | |||
| 260 | + | } | |||
| 261 | + | if updated, cmd, handled := m.handleEntryActionKeys(msg); handled { | |||
| 262 | + | return updated, cmd | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | var cmd tea.Cmd | |||
| 266 | + | m.list, cmd = m.list.Update(msg) | |||
| 267 | + | return m, cmd | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func (m HistoryModel) handleGlobalKeys(msg tea.KeyMsg) (HistoryModel, tea.Cmd, bool) { | |||
| 271 | + | switch { | |||
| 272 | + | case key.Matches(msg, m.keys.Quit): | |||
| 273 | + | return m, tea.Quit, true | |||
| 274 | + | case key.Matches(msg, m.keys.Help): | |||
| 275 | + | m.showHelp = !m.showHelp | |||
| 276 | + | return m, nil, true | |||
| 277 | + | case key.Matches(msg, m.keys.Search): | |||
| 278 | + | m.searchMode = true | |||
| 279 | + | m.search.Focus() | |||
| 280 | + | return m, textinput.Blink, true | |||
| 281 | + | default: | |||
| 282 | + | return m, nil, false | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func (m HistoryModel) handleTabSwitchKeys(msg tea.KeyMsg) (HistoryModel, bool) { | |||
| 287 | + | switch { | |||
| 288 | + | case key.Matches(msg, m.keys.Tab1): | |||
| 289 | + | m.tabs.SetActive(0) | |||
| 290 | + | case key.Matches(msg, m.keys.Tab2): | |||
| 291 | + | m.tabs.SetActive(1) | |||
| 292 | + | case key.Matches(msg, m.keys.Tab3): | |||
| 293 | + | m.tabs.SetActive(2) | |||
| 294 | + | case key.Matches(msg, m.keys.Tab4): | |||
| 295 | + | m.tabs.SetActive(3) | |||
| 296 | + | case key.Matches(msg, m.keys.NextTab): | |||
| 297 | + | next := (m.tabs.Active + 1) % 4 | |||
| 298 | + | m.tabs.SetActive(next) | |||
| 299 | + | case key.Matches(msg, m.keys.PrevTab): | |||
| 300 | + | prev := (m.tabs.Active + 3) % 4 // +3 is same as -1 mod 4 | |||
| 301 | + | m.tabs.SetActive(prev) | |||
| 302 | + | default: | |||
| 303 | + | return m, false | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | m.updateList() | |||
| 307 | + | return m, true | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func (m HistoryModel) handleEntryActionKeys(msg tea.KeyMsg) (HistoryModel, tea.Cmd, bool) { | |||
| 311 | + | switch { | |||
| 312 | + | case key.Matches(msg, m.keys.Open): | |||
| 313 | + | if item := m.list.SelectedItem(); item != nil { | |||
| 314 | + | if hi, ok := item.(styles.HistoryItem); ok { | |||
| 315 | + | return m, m.openURL(hi.URL), true | |||
| 316 | + | } | |||
| 317 | + | } | |||
| 318 | + | return m, nil, true | |||
| 319 | + | case key.Matches(msg, m.keys.Delete): | |||
| 320 | + | if m.list.SelectedItem() != nil { | |||
| 321 | + | confirm := styles.NewConfirm(m.theme, "Delete this entry?") | |||
| 322 | + | m.confirm = &confirm | |||
| 337 | } | 323 | } | ||
| 324 | + | return m, nil, true | |||
| 325 | + | case key.Matches(msg, m.keys.DeleteDomain): | |||
| 326 | + | if item := m.list.SelectedItem(); item != nil { | |||
| 327 | + | if hi, ok := item.(styles.HistoryItem); ok { | |||
| 328 | + | confirm := styles.NewConfirm(m.theme, "Delete all from "+hi.Domain+"?") | |||
| 329 | + | m.confirm = &confirm | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | return m, nil, true | |||
| 333 | + | case key.Matches(msg, m.keys.Cleanup): | |||
| 334 | + | cleanup := styles.NewCleanup(m.theme) | |||
| 335 | + | m.cleanup = &cleanup | |||
| 336 | + | return m, nil, true | |||
| 337 | + | case key.Matches(msg, m.keys.Filter): | |||
| 338 | + | m.cycleDomainFilter() | |||
| 339 | + | m.updateList() | |||
| 340 | + | return m, nil, true | |||
| 341 | + | default: | |||
| 342 | + | return m, nil, false | |||
| 338 | } | 343 | } | ||
| 344 | + | } | |||
| 339 | 345 | ||||
| 340 | - | return m, tea.Batch(cmds...) | 346 | + | func (m HistoryModel) handleHistoryLoaded(msg historyLoadedMsg) (tea.Model, tea.Cmd) { |
| 347 | + | if msg.err != nil { | |||
| 348 | + | m.err = msg.err | |||
| 349 | + | return m, nil | |||
| 350 | + | } | |||
| 351 | + | m.entries = msg.entries | |||
| 352 | + | m.updateList() | |||
| 353 | + | return m, nil | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func (m HistoryModel) handleDomainStats(msg domainStatsMsg) (tea.Model, tea.Cmd) { | |||
| 357 | + | if msg.err != nil { | |||
| 358 | + | m.err = msg.err | |||
| 359 | + | return m, nil | |||
| 360 | + | } | |||
| 361 | + | m.topDomains = msg.domains | |||
| 362 | + | return m, nil | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func (m HistoryModel) handleHistoryDeleted(msg historyDeletedMsg) (tea.Model, tea.Cmd) { | |||
| 366 | + | if msg.err != nil { | |||
| 367 | + | m.err = msg.err | |||
| 368 | + | return m, nil | |||
| 369 | + | } | |||
| 370 | + | return m, m.loadHistory | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func (m HistoryModel) handleHistoryCleaned(msg historyCleanedMsg) (tea.Model, tea.Cmd) { | |||
| 374 | + | if msg.err != nil { | |||
| 375 | + | m.err = msg.err | |||
| 376 | + | return m, nil | |||
| 377 | + | } | |||
| 378 | + | return m, m.loadHistory | |||
| 341 | } | 379 | } | ||
| 342 | 380 | ||||
| 343 | // updateList updates the list with entries for the current tab. | 381 | // updateList updates the list with entries for the current tab. | ||
| @@ -482,6 +520,9 @@ func (m HistoryModel) performCleanup(cleanupRange styles.CleanupRange) tea.Cmd { | |||||
| 482 | // openURL opens a URL in the default browser. | 520 | // openURL opens a URL in the default browser. | ||
| 483 | func (m HistoryModel) openURL(urlStr string) tea.Cmd { | 521 | func (m HistoryModel) openURL(urlStr string) tea.Cmd { | ||
| 484 | return func() tea.Msg { | 522 | return func() tea.Msg { | ||
| 523 | + | log := logging.FromContext(m.ctx) | |||
| 524 | + | log.Debug().Str("url", urlStr).Msg("opening URL in browser") | |||
| 525 | + | ||||
| 485 | // Use xdg-open on Linux | 526 | // Use xdg-open on Linux | ||
| 486 | _ = exec.Command("xdg-open", urlStr).Start() | 527 | _ = exec.Command("xdg-open", urlStr).Start() | ||
| 487 | return nil | 528 | return nil | ||
| @@ -614,6 +655,9 @@ func (m HistoryListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||||
| 614 | 655 | ||||
| 615 | // View implements tea.Model. | 656 | // View implements tea.Model. | ||
| 616 | func (m HistoryListModel) View() string { | 657 | func (m HistoryListModel) View() string { | ||
| 658 | + | if m.err != nil { | |||
| 659 | + | return "" | |||
| 660 | + | } | |||
| 617 | return "" // Output handled externally | 661 | return "" // Output handled externally | ||
| 618 | } | 662 | } | ||
| 619 | 663 | ||||
M internal/cli/model/stats.go +17 -9
| @@ -209,19 +209,23 @@ func (m StatsModel) renderDailyActivity() string { | |||||
| 209 | 209 | ||||
| 210 | // Build sparkline using block characters | 210 | // Build sparkline using block characters | ||
| 211 | blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} | 211 | blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} | ||
| 212 | + | const ( | |||
| 213 | + | sparklineMaxDays = 30 | |||
| 214 | + | sparklineMaxIndex = 7 | |||
| 215 | + | ) | |||
| 212 | var result string | 216 | var result string | ||
| 213 | 217 | ||||
| 214 | // Show last 30 days or available data | 218 | // Show last 30 days or available data | ||
| 215 | days := m.analytics.DailyVisits | 219 | days := m.analytics.DailyVisits | ||
| 216 | - | if len(days) > 30 { | 220 | + | if len(days) > sparklineMaxDays { |
| 217 | - | days = days[len(days)-30:] | 221 | + | days = days[len(days)-sparklineMaxDays:] |
| 218 | } | 222 | } | ||
| 219 | 223 | ||||
| 220 | for _, d := range days { | 224 | for _, d := range days { | ||
| 221 | // Scale to block index (0-7) | 225 | // Scale to block index (0-7) | ||
| 222 | - | idx := int(float64(d.Visits) / float64(maxVisits) * 7) | 226 | + | idx := int(float64(d.Visits) / float64(maxVisits) * sparklineMaxIndex) |
| 223 | - | if idx > 7 { | 227 | + | if idx > sparklineMaxIndex { |
| 224 | - | idx = 7 | 228 | + | idx = sparklineMaxIndex |
| 225 | } | 229 | } | ||
| 226 | result += string(blocks[idx]) | 230 | result += string(blocks[idx]) | ||
| 227 | } | 231 | } | ||
| @@ -231,11 +235,15 @@ func (m StatsModel) renderDailyActivity() string { | |||||
| 231 | 235 | ||||
| 232 | // formatNumber formats a number for display. | 236 | // formatNumber formats a number for display. | ||
| 233 | func formatNumber(n int64) string { | 237 | func formatNumber(n int64) string { | ||
| 238 | + | const ( | |||
| 239 | + | formatMillion = 1_000_000 | |||
| 240 | + | formatThousand = 1_000 | |||
| 241 | + | ) | |||
| 234 | switch { | 242 | switch { | ||
| 235 | - | case n >= 1000000: | 243 | + | case n >= formatMillion: |
| 236 | - | return fmt.Sprintf("%.1fM", float64(n)/1000000) | 244 | + | return fmt.Sprintf("%.1fM", float64(n)/formatMillion) |
| 237 | - | case n >= 1000: | 245 | + | case n >= formatThousand: |
| 238 | - | return fmt.Sprintf("%.1fK", float64(n)/1000) | 246 | + | return fmt.Sprintf("%.1fK", float64(n)/formatThousand) |
| 239 | default: | 247 | default: | ||
| 240 | return fmt.Sprintf("%d", n) | 248 | return fmt.Sprintf("%d", n) | ||
| 241 | } | 249 | } | ||
M internal/cli/styles/about.go +6 -1
| @@ -55,7 +55,12 @@ func (r *AboutRenderer) renderInfoLines(info build.Info) string { | |||||
| 55 | fmt.Sprintf("%s %s %s", iconStyle.Render(IconGo), keyStyle.Render("Go"), valStyle.Render(info.GoVersion)), | 55 | fmt.Sprintf("%s %s %s", iconStyle.Render(IconGo), keyStyle.Render("Go"), valStyle.Render(info.GoVersion)), | ||
| 56 | "", | 56 | "", | ||
| 57 | fmt.Sprintf("%s %s", iconStyle.Render(IconGithub), keyStyle.Render(build.RepoURL())), | 57 | fmt.Sprintf("%s %s", iconStyle.Render(IconGithub), keyStyle.Render(build.RepoURL())), | ||
| 58 | - | fmt.Sprintf("%s %s %s", iconStyle.Render(IconHeart), keyStyle.Render("Made with love by"), valStyle.Render(strings.Join(build.Contributors(), ", "))), | 58 | + | fmt.Sprintf( |
| 59 | + | "%s %s %s", | |||
| 60 | + | iconStyle.Render(IconHeart), | |||
| 61 | + | keyStyle.Render("Made with love by"), | |||
| 62 | + | valStyle.Render(strings.Join(build.Contributors(), ", ")), | |||
| 63 | + | ), | |||
| 59 | } | 64 | } | ||
| 60 | 65 | ||||
| 61 | return strings.Join(lines, "\n") | 66 | return strings.Join(lines, "\n") | ||
M internal/cli/styles/badge.go +18 -8
| @@ -41,7 +41,11 @@ func (t *Theme) MutedBadge(text string) string { | |||||
| 41 | 41 | ||||
| 42 | // StatusBadge renders a status badge with custom colors. | 42 | // StatusBadge renders a status badge with custom colors. | ||
| 43 | func (t *Theme) StatusBadge(text string, fg, bg lipgloss.Color) string { | 43 | func (t *Theme) StatusBadge(text string, fg, bg lipgloss.Color) string { | ||
| 44 | - | style := lipgloss.NewStyle(). | 44 | + | style := lipgloss.NewStyle() |
| 45 | + | if t != nil { | |||
| 46 | + | style = t.Badge | |||
| 47 | + | } | |||
| 48 | + | style = style. | |||
| 45 | Foreground(fg). | 49 | Foreground(fg). | ||
| 46 | Background(bg). | 50 | Background(bg). | ||
| 47 | Padding(0, 1) | 51 | Padding(0, 1) | ||
| @@ -52,6 +56,12 @@ func (t *Theme) StatusBadge(text string, fg, bg lipgloss.Color) string { | |||||
| 52 | func RelativeTime(tm time.Time) string { | 56 | func RelativeTime(tm time.Time) string { | ||
| 53 | now := time.Now() | 57 | now := time.Now() | ||
| 54 | diff := now.Sub(tm) | 58 | diff := now.Sub(tm) | ||
| 59 | + | const ( | |||
| 60 | + | hoursPerDay = 24 | |||
| 61 | + | daysPerWeek = 7 | |||
| 62 | + | daysPerMonth = 30 | |||
| 63 | + | daysPerYear = 365 | |||
| 64 | + | ) | |||
| 55 | 65 | ||||
| 56 | switch { | 66 | switch { | ||
| 57 | case diff < time.Minute: | 67 | case diff < time.Minute: | ||
| @@ -62,26 +72,26 @@ func RelativeTime(tm time.Time) string { | |||||
| 62 | return "1m ago" | 72 | return "1m ago" | ||
| 63 | } | 73 | } | ||
| 64 | return fmt.Sprintf("%dm ago", mins) | 74 | return fmt.Sprintf("%dm ago", mins) | ||
| 65 | - | case diff < 24*time.Hour: | 75 | + | case diff < hoursPerDay*time.Hour: |
| 66 | hours := int(diff.Hours()) | 76 | hours := int(diff.Hours()) | ||
| 67 | if hours == 1 { | 77 | if hours == 1 { | ||
| 68 | return "1h ago" | 78 | return "1h ago" | ||
| 69 | } | 79 | } | ||
| 70 | return fmt.Sprintf("%dh ago", hours) | 80 | return fmt.Sprintf("%dh ago", hours) | ||
| 71 | - | case diff < 7*24*time.Hour: | 81 | + | case diff < time.Duration(daysPerWeek*hoursPerDay)*time.Hour: |
| 72 | - | days := int(diff.Hours() / 24) | 82 | + | days := int(diff.Hours() / hoursPerDay) |
| 73 | if days == 1 { | 83 | if days == 1 { | ||
| 74 | return "1d ago" | 84 | return "1d ago" | ||
| 75 | } | 85 | } | ||
| 76 | return fmt.Sprintf("%dd ago", days) | 86 | return fmt.Sprintf("%dd ago", days) | ||
| 77 | - | case diff < 30*24*time.Hour: | 87 | + | case diff < time.Duration(daysPerMonth*hoursPerDay)*time.Hour: |
| 78 | - | weeks := int(diff.Hours() / (24 * 7)) | 88 | + | weeks := int(diff.Hours() / (hoursPerDay * daysPerWeek)) |
| 79 | if weeks == 1 { | 89 | if weeks == 1 { | ||
| 80 | return "1w ago" | 90 | return "1w ago" | ||
| 81 | } | 91 | } | ||
| 82 | return fmt.Sprintf("%dw ago", weeks) | 92 | return fmt.Sprintf("%dw ago", weeks) | ||
| 83 | - | case diff < 365*24*time.Hour: | 93 | + | case diff < time.Duration(daysPerYear*hoursPerDay)*time.Hour: |
| 84 | - | months := int(diff.Hours() / (24 * 30)) | 94 | + | months := int(diff.Hours() / (hoursPerDay * daysPerMonth)) |
| 85 | if months == 1 { | 95 | if months == 1 { | ||
| 86 | return "1mo ago" | 96 | return "1mo ago" | ||
| 87 | } | 97 | } | ||
M internal/cli/styles/cleanup.go +6 -3
| @@ -67,7 +67,7 @@ type CleanupModel struct { | |||||
| 67 | Ranges []CleanupRangeInfo | 67 | Ranges []CleanupRangeInfo | ||
| 68 | Selected int | 68 | Selected int | ||
| 69 | Confirmed bool | 69 | Confirmed bool | ||
| 70 | - | Cancelled bool | 70 | + | Canceled bool |
| 71 | theme *Theme | 71 | theme *Theme | ||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| @@ -100,6 +100,9 @@ func NewCleanup(theme *Theme) CleanupModel { | |||||
| 100 | 100 | ||||
| 101 | // Init implements tea.Model. | 101 | // Init implements tea.Model. | ||
| 102 | func (m CleanupModel) Init() tea.Cmd { | 102 | func (m CleanupModel) Init() tea.Cmd { | ||
| 103 | + | if m.theme == nil { | |||
| 104 | + | return nil | |||
| 105 | + | } | |||
| 103 | return nil | 106 | return nil | ||
| 104 | } | 107 | } | ||
| 105 | 108 | ||||
| @@ -121,7 +124,7 @@ func (m CleanupModel) Update(msg tea.Msg) (CleanupModel, tea.Cmd) { | |||||
| 121 | case key.Matches(msg, keys.Confirm): | 124 | case key.Matches(msg, keys.Confirm): | ||
| 122 | m.Confirmed = true | 125 | m.Confirmed = true | ||
| 123 | case key.Matches(msg, keys.Cancel): | 126 | case key.Matches(msg, keys.Cancel): | ||
| 124 | - | m.Cancelled = true | 127 | + | m.Canceled = true |
| 125 | } | 128 | } | ||
| 126 | } | 129 | } | ||
| 127 | 130 | ||||
| @@ -198,7 +201,7 @@ func (m CleanupModel) View() string { | |||||
| 198 | 201 | ||||
| 199 | // Done returns true if the modal is complete. | 202 | // Done returns true if the modal is complete. | ||
| 200 | func (m CleanupModel) Done() bool { | 203 | func (m CleanupModel) Done() bool { | ||
| 201 | - | return m.Confirmed || m.Cancelled | 204 | + | return m.Confirmed || m.Canceled |
| 202 | } | 205 | } | ||
| 203 | 206 | ||||
| 204 | // SelectedRange returns the selected cleanup range. | 207 | // SelectedRange returns the selected cleanup range. | ||
M internal/cli/styles/confirm.go +6 -3
| @@ -11,7 +11,7 @@ type ConfirmModel struct { | |||||
| 11 | Message string | 11 | Message string | ||
| 12 | Yes bool // Current selection | 12 | Yes bool // Current selection | ||
| 13 | Confirmed bool // User pressed enter | 13 | Confirmed bool // User pressed enter | ||
| 14 | - | Cancelled bool // User pressed escape | 14 | + | Canceled bool // User pressed escape |
| 15 | theme *Theme | 15 | theme *Theme | ||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| @@ -48,6 +48,9 @@ func NewConfirm(theme *Theme, message string) ConfirmModel { | |||||
| 48 | 48 | ||||
| 49 | // Init implements tea.Model. | 49 | // Init implements tea.Model. | ||
| 50 | func (m ConfirmModel) Init() tea.Cmd { | 50 | func (m ConfirmModel) Init() tea.Cmd { | ||
| 51 | + | if m.theme == nil { | |||
| 52 | + | return nil | |||
| 53 | + | } | |||
| 51 | return nil | 54 | return nil | ||
| 52 | } | 55 | } | ||
| 53 | 56 | ||||
| @@ -69,7 +72,7 @@ func (m ConfirmModel) Update(msg tea.Msg) (ConfirmModel, tea.Cmd) { | |||||
| 69 | case key.Matches(msg, keys.Confirm): | 72 | case key.Matches(msg, keys.Confirm): | ||
| 70 | m.Confirmed = true | 73 | m.Confirmed = true | ||
| 71 | case key.Matches(msg, keys.Cancel): | 74 | case key.Matches(msg, keys.Cancel): | ||
| 72 | - | m.Cancelled = true | 75 | + | m.Canceled = true |
| 73 | } | 76 | } | ||
| 74 | } | 77 | } | ||
| 75 | 78 | ||||
| @@ -110,7 +113,7 @@ func (m ConfirmModel) View() string { | |||||
| 110 | 113 | ||||
| 111 | // Done returns true if the dialog is complete. | 114 | // Done returns true if the dialog is complete. | ||
| 112 | func (m ConfirmModel) Done() bool { | 115 | func (m ConfirmModel) Done() bool { | ||
| 113 | - | return m.Confirmed || m.Cancelled | 116 | + | return m.Confirmed || m.Canceled |
| 114 | } | 117 | } | ||
| 115 | 118 | ||||
| 116 | // Result returns true if user confirmed "Yes". | 119 | // Result returns true if user confirmed "Yes". | ||
M internal/cli/styles/doctor.go +24 -8
| @@ -7,6 +7,11 @@ import ( | |||||
| 7 | "github.com/charmbracelet/lipgloss" | 7 | "github.com/charmbracelet/lipgloss" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | const ( | |||
| 11 | + | statusYes = "Yes" | |||
| 12 | + | statusNo = "No" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 10 | type DoctorRenderer struct { | 15 | type DoctorRenderer struct { | ||
| 11 | theme *Theme | 16 | theme *Theme | ||
| 12 | } | 17 | } | ||
| @@ -90,7 +95,12 @@ func (r *DoctorRenderer) renderRuntime(rt DoctorRuntimeReport) string { | |||||
| 90 | lines := make([]string, 0, len(rt.Checks)+2) | 95 | lines := make([]string, 0, len(rt.Checks)+2) | ||
| 91 | 96 | ||||
| 92 | if strings.TrimSpace(rt.Prefix) != "" { | 97 | if strings.TrimSpace(rt.Prefix) != "" { | ||
| 93 | - | lines = append(lines, fmt.Sprintf("%s %s %s", r.theme.Subtle.Render("Prefix"), r.theme.Normal.Render(rt.Prefix), r.theme.Subtle.Render("(runtime override)"))) | 98 | + | lines = append(lines, fmt.Sprintf( |
| 99 | + | "%s %s %s", | |||
| 100 | + | r.theme.Subtle.Render("Prefix"), | |||
| 101 | + | r.theme.Normal.Render(rt.Prefix), | |||
| 102 | + | r.theme.Subtle.Render("(runtime override)"), | |||
| 103 | + | )) | |||
| 94 | } | 104 | } | ||
| 95 | 105 | ||||
| 96 | for _, c := range rt.Checks { | 106 | for _, c := range rt.Checks { | ||
| @@ -133,33 +143,39 @@ func (r *DoctorRenderer) renderMedia(m DoctorMediaReport) string { | |||||
| 133 | 143 | ||||
| 134 | gstIcon := IconCheck | 144 | gstIcon := IconCheck | ||
| 135 | gstStyle := r.theme.SuccessStyle | 145 | gstStyle := r.theme.SuccessStyle | ||
| 136 | - | gstText := "Yes" | 146 | + | gstText := statusYes |
| 137 | if !m.GStreamerAvailable { | 147 | if !m.GStreamerAvailable { | ||
| 138 | gstIcon = IconX | 148 | gstIcon = IconX | ||
| 139 | gstStyle = r.theme.ErrorStyle | 149 | gstStyle = r.theme.ErrorStyle | ||
| 140 | - | gstText = "No" | 150 | + | gstText = statusNo |
| 141 | } | 151 | } | ||
| 142 | lines = append(lines, fmt.Sprintf("%s %s %s", gstStyle.Render(gstIcon), r.theme.Subtle.Render("GStreamer"), gstStyle.Render(gstText))) | 152 | lines = append(lines, fmt.Sprintf("%s %s %s", gstStyle.Render(gstIcon), r.theme.Subtle.Render("GStreamer"), gstStyle.Render(gstText))) | ||
| 143 | 153 | ||||
| 144 | hwIcon := IconWarning | 154 | hwIcon := IconWarning | ||
| 145 | hwStyle := r.theme.WarningStyle | 155 | hwStyle := r.theme.WarningStyle | ||
| 146 | - | hwText := "No" | 156 | + | hwText := statusNo |
| 147 | if m.HWAccelAvailable { | 157 | if m.HWAccelAvailable { | ||
| 148 | hwIcon = IconCheck | 158 | hwIcon = IconCheck | ||
| 149 | hwStyle = r.theme.SuccessStyle | 159 | hwStyle = r.theme.SuccessStyle | ||
| 150 | - | hwText = "Yes" | 160 | + | hwText = statusYes |
| 151 | } | 161 | } | ||
| 152 | lines = append(lines, fmt.Sprintf("%s %s %s", hwStyle.Render(hwIcon), r.theme.Subtle.Render("HW decode"), hwStyle.Render(hwText))) | 162 | lines = append(lines, fmt.Sprintf("%s %s %s", hwStyle.Render(hwIcon), r.theme.Subtle.Render("HW decode"), hwStyle.Render(hwText))) | ||
| 153 | 163 | ||||
| 154 | av1Icon := IconWarning | 164 | av1Icon := IconWarning | ||
| 155 | av1Style := r.theme.WarningStyle | 165 | av1Style := r.theme.WarningStyle | ||
| 156 | - | av1Text := "No" | 166 | + | av1Text := statusNo |
| 157 | if m.AV1HWAvailable { | 167 | if m.AV1HWAvailable { | ||
| 158 | av1Icon = IconCheck | 168 | av1Icon = IconCheck | ||
| 159 | av1Style = r.theme.SuccessStyle | 169 | av1Style = r.theme.SuccessStyle | ||
| 160 | - | av1Text = "Yes" | 170 | + | av1Text = statusYes |
| 161 | } | 171 | } | ||
| 162 | - | lines = append(lines, fmt.Sprintf("%s %s %s %s", av1Style.Render(av1Icon), r.theme.Subtle.Render("AV1"), av1Style.Render(av1Text), r.theme.Subtle.Render("(preferred)"))) | 172 | + | lines = append(lines, fmt.Sprintf( |
| 173 | + | "%s %s %s %s", | |||
| 174 | + | av1Style.Render(av1Icon), | |||
| 175 | + | r.theme.Subtle.Render("AV1"), | |||
| 176 | + | av1Style.Render(av1Text), | |||
| 177 | + | r.theme.Subtle.Render("(preferred)"), | |||
| 178 | + | )) | |||
| 163 | 179 | ||||
| 164 | plugins := []string{ | 180 | plugins := []string{ | ||
| 165 | fmt.Sprintf("%s VA (stateless): %s", r.theme.Subtle.Render("•"), pluginStatus(r.theme, m.HasVAPlugin, "recommended")), | 181 | fmt.Sprintf("%s VA (stateless): %s", r.theme.Subtle.Render("•"), pluginStatus(r.theme, m.HasVAPlugin, "recommended")), | ||
M internal/cli/styles/list.go +27 -4
| @@ -55,16 +55,25 @@ func NewHistoryDelegate(theme *Theme) HistoryDelegate { | |||||
| 55 | 55 | ||||
| 56 | // Height returns the height of each item. | 56 | // Height returns the height of each item. | ||
| 57 | func (d HistoryDelegate) Height() int { | 57 | func (d HistoryDelegate) Height() int { | ||
| 58 | + | if d.Theme == nil { | |||
| 59 | + | return 2 | |||
| 60 | + | } | |||
| 58 | return 2 | 61 | return 2 | ||
| 59 | } | 62 | } | ||
| 60 | 63 | ||||
| 61 | // Spacing returns the spacing between items. | 64 | // Spacing returns the spacing between items. | ||
| 62 | func (d HistoryDelegate) Spacing() int { | 65 | func (d HistoryDelegate) Spacing() int { | ||
| 66 | + | if d.Theme == nil { | |||
| 67 | + | return 0 | |||
| 68 | + | } | |||
| 63 | return 0 | 69 | return 0 | ||
| 64 | } | 70 | } | ||
| 65 | 71 | ||||
| 66 | // Update handles item-level events. | 72 | // Update handles item-level events. | ||
| 67 | func (d HistoryDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { | 73 | func (d HistoryDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { | ||
| 74 | + | if d.Theme == nil { | |||
| 75 | + | return nil | |||
| 76 | + | } | |||
| 68 | return nil | 77 | return nil | ||
| 69 | } | 78 | } | ||
| 70 | 79 | ||||
| @@ -77,17 +86,22 @@ func (d HistoryDelegate) Render(w io.Writer, m list.Model, index int, item list. | |||||
| 77 | 86 | ||||
| 78 | t := d.Theme | 87 | t := d.Theme | ||
| 79 | isSelected := index == m.Index() | 88 | isSelected := index == m.Index() | ||
| 89 | + | const ( | |||
| 90 | + | maxTitleLength = 60 | |||
| 91 | + | maxURLLength = 50 | |||
| 92 | + | ellipsisLength = 3 | |||
| 93 | + | ) | |||
| 80 | 94 | ||||
| 81 | // Title line | 95 | // Title line | ||
| 82 | title := hi.TitleValue() | 96 | title := hi.TitleValue() | ||
| 83 | - | if len(title) > 60 { | 97 | + | if len(title) > maxTitleLength { |
| 84 | - | title = title[:57] + "..." | 98 | + | title = title[:maxTitleLength-ellipsisLength] + "..." |
| 85 | } | 99 | } | ||
| 86 | 100 | ||||
| 87 | // URL/description line | 101 | // URL/description line | ||
| 88 | url := hi.URL | 102 | url := hi.URL | ||
| 89 | - | if len(url) > 50 { | 103 | + | if len(url) > maxURLLength { |
| 90 | - | url = url[:47] + "..." | 104 | + | url = url[:maxURLLength-ellipsisLength] + "..." |
| 91 | } | 105 | } | ||
| 92 | 106 | ||||
| 93 | // Metadata badges | 107 | // Metadata badges | ||
| @@ -179,16 +193,25 @@ type SimpleDelegate struct { | |||||
| 179 | 193 | ||||
| 180 | // Height returns the height of each item. | 194 | // Height returns the height of each item. | ||
| 181 | func (d SimpleDelegate) Height() int { | 195 | func (d SimpleDelegate) Height() int { | ||
| 196 | + | if d.Theme == nil { | |||
| 197 | + | return 1 | |||
| 198 | + | } | |||
| 182 | return 1 | 199 | return 1 | ||
| 183 | } | 200 | } | ||
| 184 | 201 | ||||
| 185 | // Spacing returns the spacing between items. | 202 | // Spacing returns the spacing between items. | ||
| 186 | func (d SimpleDelegate) Spacing() int { | 203 | func (d SimpleDelegate) Spacing() int { | ||
| 204 | + | if d.Theme == nil { | |||
| 205 | + | return 0 | |||
| 206 | + | } | |||
| 187 | return 0 | 207 | return 0 | ||
| 188 | } | 208 | } | ||
| 189 | 209 | ||||
| 190 | // Update handles item-level events. | 210 | // Update handles item-level events. | ||
| 191 | func (d SimpleDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { | 211 | func (d SimpleDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { | ||
| 212 | + | if d.Theme == nil { | |||
| 213 | + | return nil | |||
| 214 | + | } | |||
| 192 | return nil | 215 | return nil | ||
| 193 | } | 216 | } | ||
| 194 | 217 | ||||
M internal/cli/styles/table.go +8 -4
| @@ -69,11 +69,15 @@ func (d DomainStatsRow) ToRow() table.Row { | |||||
| 69 | 69 | ||||
| 70 | // formatInt formats an integer for display. | 70 | // formatInt formats an integer for display. | ||
| 71 | func formatInt(n int) string { | 71 | func formatInt(n int) string { | ||
| 72 | + | const ( | |||
| 73 | + | formatMillion = 1_000_000 | |||
| 74 | + | formatThousand = 1_000 | |||
| 75 | + | ) | |||
| 72 | switch { | 76 | switch { | ||
| 73 | - | case n >= 1000000: | 77 | + | case n >= formatMillion: |
| 74 | - | return formatFloat(float64(n)/1000000) + "M" | 78 | + | return formatFloat(float64(n)/formatMillion) + "M" |
| 75 | - | case n >= 1000: | 79 | + | case n >= formatThousand: |
| 76 | - | return formatFloat(float64(n)/1000) + "K" | 80 | + | return formatFloat(float64(n)/formatThousand) + "K" |
| 77 | default: | 81 | default: | ||
| 78 | return intToString(n) | 82 | return intToString(n) | ||
| 79 | } | 83 | } | ||
M internal/cli/styles/tabs.go +2 -1
| @@ -64,7 +64,8 @@ func (m TabsModel) View() string { | |||||
| 64 | row += tab | 64 | row += tab | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | - | return m.theme.TabBar.Width(80).Render(row) | 67 | + | const tabBarWidth = 80 |
| 68 | + | return m.theme.TabBar.Width(tabBarWidth).Render(row) | |||
| 68 | } | 69 | } | ||
| 69 | 70 | ||||
| 70 | // ViewCompact renders a compact tab bar without separators. | 71 | // ViewCompact renders a compact tab bar without separators. | ||
M internal/cli/styles/theme.go +3 -0
| @@ -218,5 +218,8 @@ func (t *Theme) buildStyles() { | |||||
| 218 | 218 | ||||
| 219 | // Renderer returns a lipgloss renderer for the current output. | 219 | // Renderer returns a lipgloss renderer for the current output. | ||
| 220 | func (t *Theme) Renderer() *lipgloss.Renderer { | 220 | func (t *Theme) Renderer() *lipgloss.Renderer { | ||
| 221 | + | if t == nil { | |||
| 222 | + | return lipgloss.DefaultRenderer() | |||
| 223 | + | } | |||
| 221 | return lipgloss.DefaultRenderer() | 224 | return lipgloss.DefaultRenderer() | ||
| 222 | } | 225 | } | ||
M internal/domain/validation/fonts.go +3 -2
| @@ -2,7 +2,8 @@ package validation | |||||
| 2 | 2 | ||||
| 3 | import "strings" | 3 | import "strings" | ||
| 4 | 4 | ||||
| 5 | - | func ValidateFontFamily(field string, value string) []string { | 5 | + | func ValidateFontFamily(field, value string) []string { |
| 6 | + | const maxFontFamilyLength = 200 | |||
| 6 | value = strings.TrimSpace(value) | 7 | value = strings.TrimSpace(value) | ||
| 7 | var errs []string | 8 | var errs []string | ||
| 8 | 9 | ||||
| @@ -15,7 +16,7 @@ func ValidateFontFamily(field string, value string) []string { | |||||
| 15 | errs = append(errs, field+" must not contain newlines") | 16 | errs = append(errs, field+" must not contain newlines") | ||
| 16 | } | 17 | } | ||
| 17 | 18 | ||||
| 18 | - | if len(value) > 200 { | 19 | + | if len(value) > maxFontFamilyLength { |
| 19 | errs = append(errs, field+" is too long") | 20 | errs = append(errs, field+" is too long") | ||
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
M internal/domain/validation/shortcuts.go +2 -1
| @@ -43,12 +43,13 @@ func ValidateShortcutURL(value string) []string { | |||||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | func ValidateShortcutDescription(value string) []string { | 45 | func ValidateShortcutDescription(value string) []string { | ||
| 46 | + | const maxShortcutDescriptionLength = 200 | |||
| 46 | var errs []string | 47 | var errs []string | ||
| 47 | value = strings.TrimSpace(value) | 48 | value = strings.TrimSpace(value) | ||
| 48 | if strings.ContainsAny(value, "\r\n") { | 49 | if strings.ContainsAny(value, "\r\n") { | ||
| 49 | errs = append(errs, "shortcut description must not contain newlines") | 50 | errs = append(errs, "shortcut description must not contain newlines") | ||
| 50 | } | 51 | } | ||
| 51 | - | if len(value) > 200 { | 52 | + | if len(value) > maxShortcutDescriptionLength { |
| 52 | errs = append(errs, "shortcut description is too long") | 53 | errs = append(errs, "shortcut description is too long") | ||
| 53 | } | 54 | } | ||
| 54 | return errs | 55 | return errs | ||
M internal/infrastructure/config/loader.go +92 -46
| @@ -75,26 +75,50 @@ func (m *Manager) Load() error { | |||||
| 75 | m.mu.Lock() | 75 | m.mu.Lock() | ||
| 76 | defer m.mu.Unlock() | 76 | defer m.mu.Unlock() | ||
| 77 | 77 | ||||
| 78 | - | // Ensure directories exist | |||
| 79 | if err := EnsureDirectories(); err != nil { | 78 | if err := EnsureDirectories(); err != nil { | ||
| 80 | return fmt.Errorf("failed to ensure directories: %w", err) | 79 | return fmt.Errorf("failed to ensure directories: %w", err) | ||
| 81 | } | 80 | } | ||
| 82 | 81 | ||||
| 83 | - | // Set defaults | |||
| 84 | m.setDefaults() | 82 | m.setDefaults() | ||
| 85 | 83 | ||||
| 86 | - | // Read config file if it exists | 84 | + | if err := m.readConfigFile(); err != nil { |
| 85 | + | return err | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | config, err := m.unmarshalConfig() | |||
| 89 | + | if err != nil { | |||
| 90 | + | return err | |||
| 91 | + | } | |||
| 92 | + | if err := ensureDatabasePath(config); err != nil { | |||
| 93 | + | return err | |||
| 94 | + | } | |||
| 95 | + | normalizeConfig(config) | |||
| 96 | + | ||||
| 97 | + | if err := validateConfig(config); err != nil { | |||
| 98 | + | return fmt.Errorf("configuration validation failed: %w", err) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | m.config = config | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (m *Manager) readConfigFile() error { | |||
| 87 | if err := m.viper.ReadInConfig(); err != nil { | 106 | if err := m.viper.ReadInConfig(); err != nil { | ||
| 88 | var configFileNotFoundError viper.ConfigFileNotFoundError | 107 | var configFileNotFoundError viper.ConfigFileNotFoundError | ||
| 89 | if errors.As(err, &configFileNotFoundError) { | 108 | if errors.As(err, &configFileNotFoundError) { | ||
| 90 | - | // Config file not found, create default one | 109 | + | if createErr := m.createDefaultConfig(); createErr != nil { |
| 91 | - | if err := m.createDefaultConfig(); err != nil { | |||
| 92 | configDir, _ := GetConfigDir() | 110 | configDir, _ := GetConfigDir() | ||
| 93 | - | return fmt.Errorf("failed to create default config at %s: %w\nTry creating the directory manually or check permissions", configDir, err) | 111 | + | return fmt.Errorf( |
| 112 | + | "failed to create default config at %s: %w\nTry creating the directory manually or check permissions", | |||
| 113 | + | configDir, | |||
| 114 | + | createErr, | |||
| 115 | + | ) | |||
| 94 | } | 116 | } | ||
| 95 | - | // Re-read the newly created config file | 117 | + | if rereadErr := m.viper.ReadInConfig(); rereadErr != nil { |
| 96 | - | if err := m.viper.ReadInConfig(); err != nil { | 118 | + | return fmt.Errorf( |
| 97 | - | return fmt.Errorf("failed to read newly created config file: %w\nThe config file was created but couldn't be read. Please check the file format", err) | 119 | + | "failed to read newly created config file: %w\nThe config file was created but couldn't be read. Please check the file format", |
| 120 | + | rereadErr, | |||
| 121 | + | ) | |||
| 98 | } | 122 | } | ||
| 99 | } else { | 123 | } else { | ||
| 100 | configFile := m.viper.ConfigFileUsed() | 124 | configFile := m.viper.ConfigFileUsed() | ||
| @@ -105,24 +129,35 @@ func (m *Manager) Load() error { | |||||
| 105 | return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err) | 129 | return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err) | ||
| 106 | } | 130 | } | ||
| 107 | } | 131 | } | ||
| 132 | + | return nil | |||
| 133 | + | } | |||
| 108 | 134 | ||||
| 109 | - | // Unmarshal into config struct | 135 | + | func (m *Manager) unmarshalConfig() (*Config, error) { |
| 110 | config := &Config{} | 136 | config := &Config{} | ||
| 111 | if err := m.viper.Unmarshal(config); err != nil { | 137 | if err := m.viper.Unmarshal(config); err != nil { | ||
| 112 | configFile := m.viper.ConfigFileUsed() | 138 | configFile := m.viper.ConfigFileUsed() | ||
| 113 | - | return fmt.Errorf("failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches", configFile, err) | 139 | + | return nil, fmt.Errorf( |
| 140 | + | "failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches", | |||
| 141 | + | configFile, | |||
| 142 | + | err, | |||
| 143 | + | ) | |||
| 114 | } | 144 | } | ||
| 145 | + | return config, nil | |||
| 146 | + | } | |||
| 115 | 147 | ||||
| 116 | - | // Set database path if not specified | 148 | + | func ensureDatabasePath(config *Config) error { |
| 117 | - | if config.Database.Path == "" { | 149 | + | if config.Database.Path != "" { |
| 118 | - | dbPath, err := GetDatabaseFile() | 150 | + | return nil |
| 119 | - | if err != nil { | 151 | + | } |
| 120 | - | return fmt.Errorf("failed to get database path: %w", err) | 152 | + | dbPath, err := GetDatabaseFile() |
| 121 | - | } | 153 | + | if err != nil { |
| 122 | - | config.Database.Path = dbPath | 154 | + | return fmt.Errorf("failed to get database path: %w", err) |
| 123 | } | 155 | } | ||
| 156 | + | config.Database.Path = dbPath | |||
| 157 | + | return nil | |||
| 158 | + | } | |||
| 124 | 159 | ||||
| 125 | - | // Normalize/validate rendering mode using switch statement | 160 | + | func normalizeConfig(config *Config) { |
| 126 | switch strings.ToLower(string(config.RenderingMode)) { | 161 | switch strings.ToLower(string(config.RenderingMode)) { | ||
| 127 | case "", string(RenderingModeAuto): | 162 | case "", string(RenderingModeAuto): | ||
| 128 | config.RenderingMode = RenderingModeAuto | 163 | config.RenderingMode = RenderingModeAuto | ||
| @@ -134,19 +169,14 @@ func (m *Manager) Load() error { | |||||
| 134 | config.RenderingMode = RenderingModeAuto | 169 | config.RenderingMode = RenderingModeAuto | ||
| 135 | } | 170 | } | ||
| 136 | 171 | ||||
| 137 | - | // Validate ColorScheme setting using switch statement | |||
| 138 | switch config.Appearance.ColorScheme { | 172 | switch config.Appearance.ColorScheme { | ||
| 139 | - | case "prefer-dark", "prefer-light", ThemeDefault: | 173 | + | case ThemePreferDark, ThemePreferLight, ThemeDefault: |
| 140 | - | // Valid values - no changes needed | |||
| 141 | case "": | 174 | case "": | ||
| 142 | - | // Empty string is treated the same as "default" | |||
| 143 | config.Appearance.ColorScheme = ThemeDefault | 175 | config.Appearance.ColorScheme = ThemeDefault | ||
| 144 | default: | 176 | default: | ||
| 145 | - | // Invalid value - reset to default | |||
| 146 | config.Appearance.ColorScheme = ThemeDefault | 177 | config.Appearance.ColorScheme = ThemeDefault | ||
| 147 | } | 178 | } | ||
| 148 | 179 | ||||
| 149 | - | // Validate Media.HardwareDecodingMode using switch statement | |||
| 150 | switch strings.ToLower(string(config.Media.HardwareDecodingMode)) { | 180 | switch strings.ToLower(string(config.Media.HardwareDecodingMode)) { | ||
| 151 | case "", string(HardwareDecodingAuto): | 181 | case "", string(HardwareDecodingAuto): | ||
| 152 | config.Media.HardwareDecodingMode = HardwareDecodingAuto | 182 | config.Media.HardwareDecodingMode = HardwareDecodingAuto | ||
| @@ -158,16 +188,7 @@ func (m *Manager) Load() error { | |||||
| 158 | config.Media.HardwareDecodingMode = HardwareDecodingAuto | 188 | config.Media.HardwareDecodingMode = HardwareDecodingAuto | ||
| 159 | } | 189 | } | ||
| 160 | 190 | ||||
| 161 | - | // Normalize runtime prefix | |||
| 162 | config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix) | 191 | config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix) | ||
| 163 | - | ||||
| 164 | - | // Validate all config values | |||
| 165 | - | if err := validateConfig(config); err != nil { | |||
| 166 | - | return fmt.Errorf("configuration validation failed: %w", err) | |||
| 167 | - | } | |||
| 168 | - | ||||
| 169 | - | m.config = config | |||
| 170 | - | return nil | |||
| 171 | } | 192 | } | ||
| 172 | 193 | ||||
| 173 | // Get returns the current configuration (thread-safe). | 194 | // Get returns the current configuration (thread-safe). | ||
| @@ -258,16 +279,32 @@ func (m *Manager) setDefaults() { | |||||
| 258 | 279 | ||||
| 259 | // Note: Database.Path is set dynamically in Load(), no defaults needed | 280 | // Note: Database.Path is set dynamically in Load(), no defaults needed | ||
| 260 | 281 | ||||
| 261 | - | // History defaults | 282 | + | m.setHistoryDefaults(defaults) |
| 283 | + | m.setSearchDefaults(defaults) | |||
| 284 | + | m.setDmenuDefaults(defaults) | |||
| 285 | + | m.setLoggingDefaults(defaults) | |||
| 286 | + | m.setDebugDefaults(defaults) | |||
| 287 | + | m.setAppearanceDefaults(defaults) | |||
| 288 | + | m.setRenderingDefaults(defaults) | |||
| 289 | + | m.setWorkspaceDefaults(defaults) | |||
| 290 | + | m.setContentFilteringDefaults(defaults) | |||
| 291 | + | m.setOmniboxDefaults(defaults) | |||
| 292 | + | m.setMediaDefaults(defaults) | |||
| 293 | + | m.setRuntimeDefaults(defaults) | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func (m *Manager) setHistoryDefaults(defaults *Config) { | |||
| 262 | m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries) | 297 | m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries) | ||
| 263 | m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays) | 298 | m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays) | ||
| 264 | m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays) | 299 | m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays) | ||
| 300 | + | } | |||
| 265 | 301 | ||||
| 266 | - | // Search shortcuts defaults | 302 | + | func (m *Manager) setSearchDefaults(defaults *Config) { |
| 267 | m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts) | 303 | m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts) | ||
| 268 | m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine) | 304 | m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine) | ||
| 305 | + | } | |||
| 269 | 306 | ||||
| 270 | - | // Dmenu defaults | 307 | + | func (m *Manager) setDmenuDefaults(defaults *Config) { |
| 271 | m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems) | 308 | m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems) | ||
| 272 | m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount) | 309 | m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount) | ||
| 273 | m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited) | 310 | m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited) | ||
| @@ -276,19 +313,22 @@ func (m *Manager) setDefaults() { | |||||
| 276 | m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix) | 313 | m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix) | ||
| 277 | m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat) | 314 | m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat) | ||
| 278 | m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount) | 315 | m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount) | ||
| 316 | + | } | |||
| 279 | 317 | ||||
| 280 | - | // Logging defaults | 318 | + | func (m *Manager) setLoggingDefaults(defaults *Config) { |
| 281 | m.viper.SetDefault("logging.level", defaults.Logging.Level) | 319 | m.viper.SetDefault("logging.level", defaults.Logging.Level) | ||
| 282 | m.viper.SetDefault("logging.format", defaults.Logging.Format) | 320 | m.viper.SetDefault("logging.format", defaults.Logging.Format) | ||
| 283 | m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge) | 321 | m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge) | ||
| 284 | m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir) | 322 | m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir) | ||
| 285 | m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog) | 323 | m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog) | ||
| 286 | m.viper.SetDefault("logging.capture_console", defaults.Logging.CaptureConsole) | 324 | m.viper.SetDefault("logging.capture_console", defaults.Logging.CaptureConsole) | ||
| 325 | + | } | |||
| 287 | 326 | ||||
| 288 | - | // Debug defaults | 327 | + | func (m *Manager) setDebugDefaults(defaults *Config) { |
| 289 | m.viper.SetDefault("debug.enable_devtools", defaults.Debug.EnableDevTools) | 328 | m.viper.SetDefault("debug.enable_devtools", defaults.Debug.EnableDevTools) | ||
| 329 | + | } | |||
| 290 | 330 | ||||
| 291 | - | // Appearance defaults | 331 | + | func (m *Manager) setAppearanceDefaults(defaults *Config) { |
| 292 | m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont) | 332 | m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont) | ||
| 293 | m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont) | 333 | m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont) | ||
| 294 | m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont) | 334 | m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont) | ||
| @@ -296,13 +336,15 @@ func (m *Manager) setDefaults() { | |||||
| 296 | m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette) | 336 | m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette) | ||
| 297 | m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette) | 337 | m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette) | ||
| 298 | m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme) | 338 | m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme) | ||
| 339 | + | } | |||
| 299 | 340 | ||||
| 300 | - | // Rendering defaults | 341 | + | func (m *Manager) setRenderingDefaults(defaults *Config) { |
| 301 | m.viper.SetDefault("rendering_mode", string(RenderingModeGPU)) | 342 | m.viper.SetDefault("rendering_mode", string(RenderingModeGPU)) | ||
| 302 | m.viper.SetDefault("default_webpage_zoom", defaults.DefaultWebpageZoom) | 343 | m.viper.SetDefault("default_webpage_zoom", defaults.DefaultWebpageZoom) | ||
| 303 | m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale) | 344 | m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale) | ||
| 345 | + | } | |||
| 304 | 346 | ||||
| 305 | - | // Workspace defaults | 347 | + | func (m *Manager) setWorkspaceDefaults(defaults *Config) { |
| 306 | m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut) | 348 | m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut) | ||
| 307 | m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds) | 349 | m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds) | ||
| 308 | m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions) | 350 | m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions) | ||
| @@ -328,20 +370,24 @@ func (m *Manager) setDefaults() { | |||||
| 328 | m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth) | 370 | m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth) | ||
| 329 | m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor) | 371 | m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor) | ||
| 330 | m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration) | 372 | m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration) | ||
| 373 | + | } | |||
| 331 | 374 | ||||
| 332 | - | // Content filtering | 375 | + | func (m *Manager) setContentFilteringDefaults(defaults *Config) { |
| 333 | m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled) | 376 | m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled) | ||
| 334 | m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate) | 377 | m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate) | ||
| 378 | + | } | |||
| 335 | 379 | ||||
| 336 | - | // Omnibox defaults | 380 | + | func (m *Manager) setOmniboxDefaults(defaults *Config) { |
| 337 | m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior) | 381 | m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior) | ||
| 382 | + | } | |||
| 338 | 383 | ||||
| 339 | - | // Media defaults | 384 | + | func (m *Manager) setMediaDefaults(defaults *Config) { |
| 340 | m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode)) | 385 | m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode)) | ||
| 341 | m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1) | 386 | m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1) | ||
| 342 | m.viper.SetDefault("media.show_diagnostics", defaults.Media.ShowDiagnosticsOnStartup) | 387 | m.viper.SetDefault("media.show_diagnostics", defaults.Media.ShowDiagnosticsOnStartup) | ||
| 388 | + | } | |||
| 343 | 389 | ||||
| 344 | - | // Runtime defaults | 390 | + | func (m *Manager) setRuntimeDefaults(defaults *Config) { |
| 345 | m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix) | 391 | m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix) | ||
| 346 | } | 392 | } | ||
| 347 | 393 | ||||
M internal/infrastructure/config/schema.go +24 -17
| @@ -17,7 +17,7 @@ type Config struct { | |||||
| 17 | DefaultWebpageZoom float64 `mapstructure:"default_webpage_zoom" yaml:"default_webpage_zoom" toml:"default_webpage_zoom"` | 17 | DefaultWebpageZoom float64 `mapstructure:"default_webpage_zoom" yaml:"default_webpage_zoom" toml:"default_webpage_zoom"` | ||
| 18 | // DefaultUIScale sets the default UI scale for GTK widgets (1.0 = 100%, 2.0 = 200%) | 18 | // DefaultUIScale sets the default UI scale for GTK widgets (1.0 = 100%, 2.0 = 200%) | ||
| 19 | DefaultUIScale float64 `mapstructure:"default_ui_scale" yaml:"default_ui_scale" toml:"default_ui_scale"` | 19 | DefaultUIScale float64 `mapstructure:"default_ui_scale" yaml:"default_ui_scale" toml:"default_ui_scale"` | ||
| 20 | - | // Workspace defines workspace, pane, and tab handling behaviour. | 20 | + | // Workspace defines workspace, pane, and tab handling behavior. |
| 21 | Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"` | 21 | Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"` | ||
| 22 | // ContentFiltering controls ad blocking and content filtering | 22 | // ContentFiltering controls ad blocking and content filtering | ||
| 23 | ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"` | 23 | ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"` | ||
| @@ -53,6 +53,13 @@ const ( | |||||
| 53 | // ThemeDefault is the default theme setting (follows system). | 53 | // ThemeDefault is the default theme setting (follows system). | ||
| 54 | const ThemeDefault = "default" | 54 | const ThemeDefault = "default" | ||
| 55 | 55 | ||||
| 56 | + | const ( | |||
| 57 | + | // ThemePreferDark explicitly selects dark theme. | |||
| 58 | + | ThemePreferDark = "prefer-dark" | |||
| 59 | + | // ThemePreferLight explicitly selects light theme. | |||
| 60 | + | ThemePreferLight = "prefer-light" | |||
| 61 | + | ) | |||
| 62 | + | ||||
| 56 | // MediaConfig holds video playback and hardware acceleration preferences. | 63 | // MediaConfig holds video playback and hardware acceleration preferences. | ||
| 57 | type MediaConfig struct { | 64 | type MediaConfig struct { | ||
| 58 | // HardwareDecodingMode controls hardware vs software video decoding | 65 | // HardwareDecodingMode controls hardware vs software video decoding | ||
| @@ -173,27 +180,27 @@ type DebugConfig struct { | |||||
| 173 | EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"` | 180 | EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"` | ||
| 174 | } | 181 | } | ||
| 175 | 182 | ||||
| 176 | - | // WorkspaceConfig captures layout, pane, and tab behaviour preferences. | 183 | + | // WorkspaceConfig captures layout, pane, and tab behavior preferences. |
| 177 | type WorkspaceConfig struct { | 184 | type WorkspaceConfig struct { | ||
| 178 | - | // PaneMode defines modal pane behaviour and bindings. | 185 | + | // PaneMode defines modal pane behavior and bindings. |
| 179 | PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"` | 186 | PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"` | ||
| 180 | - | // TabMode defines modal tab behaviour and bindings (Alt+T). | 187 | + | // TabMode defines modal tab behavior and bindings (Alt+T). |
| 181 | TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"` | 188 | TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"` | ||
| 182 | // Shortcuts holds global (non-modal) keyboard shortcuts. | 189 | // Shortcuts holds global (non-modal) keyboard shortcuts. | ||
| 183 | Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"` | 190 | Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"` | ||
| 184 | // TabBarPosition determines tab bar placement: "top" or "bottom". | 191 | // TabBarPosition determines tab bar placement: "top" or "bottom". | ||
| 185 | TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"` | 192 | TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"` | ||
| 186 | // HideTabBarWhenSingleTab hides the tab bar when only one tab exists. | 193 | // HideTabBarWhenSingleTab hides the tab bar when only one tab exists. | ||
| 187 | - | HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` | 194 | + | HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line |
| 188 | // Popups configures default popup placement rules. | 195 | // Popups configures default popup placement rules. | ||
| 189 | Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"` | 196 | Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"` | ||
| 190 | // Styling configures workspace visual appearance. | 197 | // Styling configures workspace visual appearance. | ||
| 191 | Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"` | 198 | Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"` | ||
| 192 | } | 199 | } | ||
| 193 | 200 | ||||
| 194 | - | // PaneModeConfig defines modal behaviour for pane management. | 201 | + | // PaneModeConfig defines modal behavior for pane management. |
| 195 | type PaneModeConfig struct { | 202 | type PaneModeConfig struct { | ||
| 196 | - | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` | 203 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line |
| 197 | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | 204 | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | ||
| 198 | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | 205 | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | ||
| 199 | } | 206 | } | ||
| @@ -210,9 +217,9 @@ func (p *PaneModeConfig) GetKeyBindings() map[string]string { | |||||
| 210 | return keyToAction | 217 | return keyToAction | ||
| 211 | } | 218 | } | ||
| 212 | 219 | ||||
| 213 | - | // TabModeConfig defines modal behaviour for tab management (Zellij-style). | 220 | + | // TabModeConfig defines modal behavior for tab management (Zellij-style). |
| 214 | type TabModeConfig struct { | 221 | type TabModeConfig struct { | ||
| 215 | - | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` | 222 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line |
| 216 | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | 223 | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | ||
| 217 | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | 224 | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | ||
| 218 | } | 225 | } | ||
| @@ -263,15 +270,15 @@ type PopupBehaviorConfig struct { | |||||
| 263 | OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" toml:"open_in_new_pane" json:"open_in_new_pane"` | 270 | OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" toml:"open_in_new_pane" json:"open_in_new_pane"` | ||
| 264 | 271 | ||||
| 265 | // FollowPaneContext determines if popup placement follows parent pane context | 272 | // FollowPaneContext determines if popup placement follows parent pane context | ||
| 266 | - | FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" toml:"follow_pane_context" json:"follow_pane_context"` | 273 | + | FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" toml:"follow_pane_context" json:"follow_pane_context"` //nolint:lll // struct tags must stay on one line |
| 267 | 274 | ||||
| 268 | // BlankTargetBehavior determines how target="_blank" links are opened | 275 | // BlankTargetBehavior determines how target="_blank" links are opened | ||
| 269 | // Accepted values: "split", "stacked" (default), "tabbed" | 276 | // Accepted values: "split", "stacked" (default), "tabbed" | ||
| 270 | // This is separate from Behavior which controls JavaScript popups | 277 | // This is separate from Behavior which controls JavaScript popups | ||
| 271 | - | BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" toml:"blank_target_behavior" json:"blank_target_behavior"` | 278 | + | BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" toml:"blank_target_behavior" json:"blank_target_behavior"` //nolint:lll // struct tags must stay on one line |
| 272 | 279 | ||||
| 273 | // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents | 280 | // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents | ||
| 274 | - | EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" toml:"enable_smart_detection" json:"enable_smart_detection"` | 281 | + | EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" toml:"enable_smart_detection" json:"enable_smart_detection"` //nolint:lll // struct tags must stay on one line |
| 275 | 282 | ||||
| 276 | // OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects | 283 | // OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects | ||
| 277 | OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" toml:"oauth_auto_close" json:"oauth_auto_close"` | 284 | OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" toml:"oauth_auto_close" json:"oauth_auto_close"` | ||
| @@ -285,17 +292,17 @@ type WorkspaceStylingConfig struct { | |||||
| 285 | BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"` | 292 | BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"` | ||
| 286 | 293 | ||||
| 287 | // PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay) | 294 | // PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay) | ||
| 288 | - | PaneModeBorderWidth int `mapstructure:"pane_mode_border_width" yaml:"pane_mode_border_width" toml:"pane_mode_border_width" json:"pane_mode_border_width"` | 295 | + | PaneModeBorderWidth int `mapstructure:"pane_mode_border_width" yaml:"pane_mode_border_width" toml:"pane_mode_border_width" json:"pane_mode_border_width"` //nolint:lll // struct tags must stay on one line |
| 289 | // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable) | 296 | // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable) | ||
| 290 | // Defaults to "#4A90E2" (blue) if not set | 297 | // Defaults to "#4A90E2" (blue) if not set | ||
| 291 | - | PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" toml:"pane_mode_border_color" json:"pane_mode_border_color"` | 298 | + | PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" toml:"pane_mode_border_color" json:"pane_mode_border_color"` //nolint:lll // struct tags must stay on one line |
| 292 | 299 | ||||
| 293 | // TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay) | 300 | // TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay) | ||
| 294 | - | TabModeBorderWidth int `mapstructure:"tab_mode_border_width" yaml:"tab_mode_border_width" toml:"tab_mode_border_width" json:"tab_mode_border_width"` | 301 | + | TabModeBorderWidth int `mapstructure:"tab_mode_border_width" yaml:"tab_mode_border_width" toml:"tab_mode_border_width" json:"tab_mode_border_width"` //nolint:lll // struct tags must stay on one line |
| 295 | // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable) | 302 | // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable) | ||
| 296 | // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor | 303 | // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor | ||
| 297 | - | TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" toml:"tab_mode_border_color" json:"tab_mode_border_color"` | 304 | + | TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" toml:"tab_mode_border_color" json:"tab_mode_border_color"` //nolint:lll // struct tags must stay on one line |
| 298 | 305 | ||||
| 299 | // TransitionDuration in milliseconds for border animations | 306 | // TransitionDuration in milliseconds for border animations | ||
| 300 | - | TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` | 307 | + | TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line |
| 301 | } | 308 | } | ||
M internal/infrastructure/config/validation.go +119 -59
| @@ -11,7 +11,30 @@ import ( | |||||
| 11 | func validateConfig(config *Config) error { | 11 | func validateConfig(config *Config) error { | ||
| 12 | var validationErrors []string | 12 | var validationErrors []string | ||
| 13 | 13 | ||||
| 14 | - | // Validate numeric ranges | 14 | + | validationErrors = append(validationErrors, validateHistory(config)...) |
| 15 | + | validationErrors = append(validationErrors, validateDmenu(config)...) | |||
| 16 | + | validationErrors = append(validationErrors, validateAppearance(config)...) | |||
| 17 | + | validationErrors = append(validationErrors, validateSearchEngine(config)...) | |||
| 18 | + | validationErrors = append(validationErrors, validatePopups(config)...) | |||
| 19 | + | validationErrors = append(validationErrors, validateWorkspaceStyling(config)...) | |||
| 20 | + | validationErrors = append(validationErrors, validatePaneMode(config)...) | |||
| 21 | + | validationErrors = append(validationErrors, validateTabBar(config)...) | |||
| 22 | + | validationErrors = append(validationErrors, validateTabMode(config)...) | |||
| 23 | + | validationErrors = append(validationErrors, validateLogging(config)...) | |||
| 24 | + | validationErrors = append(validationErrors, validateOmnibox(config)...) | |||
| 25 | + | validationErrors = append(validationErrors, validateRendering(config)...) | |||
| 26 | + | validationErrors = append(validationErrors, validateColorScheme(config)...) | |||
| 27 | + | ||||
| 28 | + | // If there are validation errors, return them | |||
| 29 | + | if len(validationErrors) > 0 { | |||
| 30 | + | return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - ")) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | return nil | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func validateHistory(config *Config) []string { | |||
| 37 | + | var validationErrors []string | |||
| 15 | if config.History.MaxEntries < 0 { | 38 | if config.History.MaxEntries < 0 { | ||
| 16 | validationErrors = append(validationErrors, "history.max_entries must be non-negative") | 39 | validationErrors = append(validationErrors, "history.max_entries must be non-negative") | ||
| 17 | } | 40 | } | ||
| @@ -21,19 +44,27 @@ func validateConfig(config *Config) error { | |||||
| 21 | if config.History.CleanupIntervalDays < 0 { | 44 | if config.History.CleanupIntervalDays < 0 { | ||
| 22 | validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative") | 45 | validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative") | ||
| 23 | } | 46 | } | ||
| 47 | + | return validationErrors | |||
| 48 | + | } | |||
| 24 | 49 | ||||
| 50 | + | func validateDmenu(config *Config) []string { | |||
| 25 | if config.Dmenu.MaxHistoryItems < 0 { | 51 | if config.Dmenu.MaxHistoryItems < 0 { | ||
| 26 | - | validationErrors = append(validationErrors, "dmenu.max_history_items must be non-negative") | 52 | + | return []string{"dmenu.max_history_items must be non-negative"} |
| 27 | } | 53 | } | ||
| 54 | + | return nil | |||
| 55 | + | } | |||
| 28 | 56 | ||||
| 57 | + | func validateAppearance(config *Config) []string { | |||
| 58 | + | var validationErrors []string | |||
| 29 | if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 { | 59 | if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 { | ||
| 30 | validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72") | 60 | validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72") | ||
| 31 | } | 61 | } | ||
| 32 | - | ||||
| 33 | validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...) | 62 | validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...) | ||
| 34 | validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...) | 63 | validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...) | ||
| 35 | - | validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...) | 64 | + | validationErrors = append( |
| 36 | - | 65 | + | validationErrors, | |
| 66 | + | domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)..., | |||
| 67 | + | ) | |||
| 37 | validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex( | 68 | validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex( | ||
| 38 | "appearance.light_palette", | 69 | "appearance.light_palette", | ||
| 39 | config.Appearance.LightPalette.Background, | 70 | config.Appearance.LightPalette.Background, | ||
| @@ -54,48 +85,60 @@ func validateConfig(config *Config) error { | |||||
| 54 | config.Appearance.DarkPalette.Accent, | 85 | config.Appearance.DarkPalette.Accent, | ||
| 55 | config.Appearance.DarkPalette.Border, | 86 | config.Appearance.DarkPalette.Border, | ||
| 56 | )...) | 87 | )...) | ||
| 57 | - | ||||
| 58 | if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 { | 88 | if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 { | ||
| 59 | validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0") | 89 | validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0") | ||
| 60 | } | 90 | } | ||
| 61 | if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 { | 91 | if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 { | ||
| 62 | validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0") | 92 | validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0") | ||
| 63 | } | 93 | } | ||
| 94 | + | return validationErrors | |||
| 95 | + | } | |||
| 64 | 96 | ||||
| 65 | - | // Validate default search engine | 97 | + | func validateSearchEngine(config *Config) []string { |
| 66 | if config.DefaultSearchEngine == "" { | 98 | if config.DefaultSearchEngine == "" { | ||
| 67 | - | validationErrors = append(validationErrors, "default_search_engine cannot be empty") | 99 | + | return []string{"default_search_engine cannot be empty"} |
| 68 | - | } else if !strings.Contains(config.DefaultSearchEngine, "%s") { | |||
| 69 | - | validationErrors = append(validationErrors, "default_search_engine must contain %s placeholder for the search query") | |||
| 70 | } | 100 | } | ||
| 101 | + | if !strings.Contains(config.DefaultSearchEngine, "%s") { | |||
| 102 | + | return []string{"default_search_engine must contain %s placeholder for the search query"} | |||
| 103 | + | } | |||
| 104 | + | return nil | |||
| 105 | + | } | |||
| 71 | 106 | ||||
| 72 | - | // Validate popup behavior using switch statement (per CLAUDE.md) | 107 | + | func validatePopups(config *Config) []string { |
| 108 | + | var validationErrors []string | |||
| 73 | switch config.Workspace.Popups.Behavior { | 109 | switch config.Workspace.Popups.Behavior { | ||
| 74 | case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed: | 110 | case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed: | ||
| 75 | - | // Valid | |||
| 76 | default: | 111 | default: | ||
| 77 | - | validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.behavior must be one of: split, stacked, tabbed, windowed (got: %s)", config.Workspace.Popups.Behavior)) | 112 | + | validationErrors = append(validationErrors, fmt.Sprintf( |
| 113 | + | "workspace.popups.behavior must be one of: split, stacked, tabbed, windowed (got: %s)", | |||
| 114 | + | config.Workspace.Popups.Behavior, | |||
| 115 | + | )) | |||
| 78 | } | 116 | } | ||
| 79 | 117 | ||||
| 80 | - | // Validate popup placement for split behavior using switch statement | |||
| 81 | if config.Workspace.Popups.Behavior == PopupBehaviorSplit { | 118 | if config.Workspace.Popups.Behavior == PopupBehaviorSplit { | ||
| 82 | switch config.Workspace.Popups.Placement { | 119 | switch config.Workspace.Popups.Placement { | ||
| 83 | case "right", "left", "top", "bottom": | 120 | case "right", "left", "top", "bottom": | ||
| 84 | - | // Valid | |||
| 85 | default: | 121 | default: | ||
| 86 | - | validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.placement must be one of: right, left, top, bottom (got: %s)", config.Workspace.Popups.Placement)) | 122 | + | validationErrors = append(validationErrors, fmt.Sprintf( |
| 123 | + | "workspace.popups.placement must be one of: right, left, top, bottom (got: %s)", | |||
| 124 | + | config.Workspace.Popups.Placement, | |||
| 125 | + | )) | |||
| 87 | } | 126 | } | ||
| 88 | } | 127 | } | ||
| 89 | 128 | ||||
| 90 | - | // Validate blank target behavior using switch statement | |||
| 91 | switch config.Workspace.Popups.BlankTargetBehavior { | 129 | switch config.Workspace.Popups.BlankTargetBehavior { | ||
| 92 | case "split", "stacked", "tabbed": | 130 | case "split", "stacked", "tabbed": | ||
| 93 | - | // Valid | |||
| 94 | default: | 131 | default: | ||
| 95 | - | validationErrors = append(validationErrors, fmt.Sprintf("workspace.popups.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)", config.Workspace.Popups.BlankTargetBehavior)) | 132 | + | validationErrors = append(validationErrors, fmt.Sprintf( |
| 133 | + | "workspace.popups.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)", | |||
| 134 | + | config.Workspace.Popups.BlankTargetBehavior, | |||
| 135 | + | )) | |||
| 96 | } | 136 | } | ||
| 137 | + | return validationErrors | |||
| 138 | + | } | |||
| 97 | 139 | ||||
| 98 | - | // Validate workspace styling values | 140 | + | func validateWorkspaceStyling(config *Config) []string { |
| 141 | + | var validationErrors []string | |||
| 99 | if config.Workspace.Styling.BorderWidth < 0 { | 142 | if config.Workspace.Styling.BorderWidth < 0 { | ||
| 100 | validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative") | 143 | validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative") | ||
| 101 | } | 144 | } | ||
| @@ -108,18 +151,18 @@ func validateConfig(config *Config) error { | |||||
| 108 | if config.Workspace.Styling.TransitionDuration < 0 { | 151 | if config.Workspace.Styling.TransitionDuration < 0 { | ||
| 109 | validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative") | 152 | validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative") | ||
| 110 | } | 153 | } | ||
| 154 | + | return validationErrors | |||
| 155 | + | } | |||
| 111 | 156 | ||||
| 112 | - | // Validate pane mode timeout | 157 | + | func validatePaneMode(config *Config) []string { |
| 158 | + | var validationErrors []string | |||
| 113 | if config.Workspace.PaneMode.TimeoutMilliseconds < 0 { | 159 | if config.Workspace.PaneMode.TimeoutMilliseconds < 0 { | ||
| 114 | validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative") | 160 | validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative") | ||
| 115 | } | 161 | } | ||
| 116 | - | ||||
| 117 | - | // Validate pane mode actions | |||
| 118 | if len(config.Workspace.PaneMode.Actions) == 0 { | 162 | if len(config.Workspace.PaneMode.Actions) == 0 { | ||
| 119 | validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty") | 163 | validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty") | ||
| 120 | } | 164 | } | ||
| 121 | 165 | ||||
| 122 | - | // Check for duplicate keys and empty action key lists in pane mode | |||
| 123 | seenKeys := make(map[string]string) | 166 | seenKeys := make(map[string]string) | ||
| 124 | for action, keys := range config.Workspace.PaneMode.Actions { | 167 | for action, keys := range config.Workspace.PaneMode.Actions { | ||
| 125 | if len(keys) == 0 { | 168 | if len(keys) == 0 { | ||
| @@ -127,31 +170,37 @@ func validateConfig(config *Config) error { | |||||
| 127 | } | 170 | } | ||
| 128 | for _, key := range keys { | 171 | for _, key := range keys { | ||
| 129 | if existingAction, exists := seenKeys[key]; exists { | 172 | if existingAction, exists := seenKeys[key]; exists { | ||
| 130 | - | validationErrors = append(validationErrors, fmt.Sprintf("duplicate key binding '%s' found in pane_mode actions '%s' and '%s'", key, existingAction, action)) | 173 | + | validationErrors = append(validationErrors, fmt.Sprintf( |
| 174 | + | "duplicate key binding '%s' found in pane_mode actions '%s' and '%s'", | |||
| 175 | + | key, | |||
| 176 | + | existingAction, | |||
| 177 | + | action, | |||
| 178 | + | )) | |||
| 131 | } | 179 | } | ||
| 132 | seenKeys[key] = action | 180 | seenKeys[key] = action | ||
| 133 | } | 181 | } | ||
| 134 | } | 182 | } | ||
| 183 | + | return validationErrors | |||
| 184 | + | } | |||
| 135 | 185 | ||||
| 136 | - | // Validate tab bar position using switch statement | 186 | + | func validateTabBar(config *Config) []string { |
| 137 | switch config.Workspace.TabBarPosition { | 187 | switch config.Workspace.TabBarPosition { | ||
| 138 | case "top", "bottom": | 188 | case "top", "bottom": | ||
| 139 | - | // Valid | 189 | + | return nil |
| 140 | default: | 190 | default: | ||
| 141 | - | validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition)) | 191 | + | return []string{fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition)} |
| 142 | } | 192 | } | ||
| 193 | + | } | |||
| 143 | 194 | ||||
| 144 | - | // Validate tab mode timeout | 195 | + | func validateTabMode(config *Config) []string { |
| 196 | + | var validationErrors []string | |||
| 145 | if config.Workspace.TabMode.TimeoutMilliseconds < 0 { | 197 | if config.Workspace.TabMode.TimeoutMilliseconds < 0 { | ||
| 146 | validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative") | 198 | validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative") | ||
| 147 | } | 199 | } | ||
| 148 | - | ||||
| 149 | - | // Validate tab mode actions | |||
| 150 | if len(config.Workspace.TabMode.Actions) == 0 { | 200 | if len(config.Workspace.TabMode.Actions) == 0 { | ||
| 151 | validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty") | 201 | validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty") | ||
| 152 | } | 202 | } | ||
| 153 | 203 | ||||
| 154 | - | // Check for duplicate keys and empty action key lists in tab mode | |||
| 155 | tabSeenKeys := make(map[string]string) | 204 | tabSeenKeys := make(map[string]string) | ||
| 156 | for action, keys := range config.Workspace.TabMode.Actions { | 205 | for action, keys := range config.Workspace.TabMode.Actions { | ||
| 157 | if len(keys) == 0 { | 206 | if len(keys) == 0 { | ||
| @@ -159,61 +208,72 @@ func validateConfig(config *Config) error { | |||||
| 159 | } | 208 | } | ||
| 160 | for _, key := range keys { | 209 | for _, key := range keys { | ||
| 161 | if existingAction, exists := tabSeenKeys[key]; exists { | 210 | if existingAction, exists := tabSeenKeys[key]; exists { | ||
| 162 | - | validationErrors = append(validationErrors, fmt.Sprintf("duplicate key binding '%s' found in tab_mode actions '%s' and '%s'", key, existingAction, action)) | 211 | + | validationErrors = append(validationErrors, fmt.Sprintf( |
| 212 | + | "duplicate key binding '%s' found in tab_mode actions '%s' and '%s'", | |||
| 213 | + | key, | |||
| 214 | + | existingAction, | |||
| 215 | + | action, | |||
| 216 | + | )) | |||
| 163 | } | 217 | } | ||
| 164 | tabSeenKeys[key] = action | 218 | tabSeenKeys[key] = action | ||
| 165 | } | 219 | } | ||
| 166 | } | 220 | } | ||
| 221 | + | return validationErrors | |||
| 222 | + | } | |||
| 167 | 223 | ||||
| 168 | - | // Validate logging values | 224 | + | func validateLogging(config *Config) []string { |
| 225 | + | var validationErrors []string | |||
| 169 | if config.Logging.MaxAge < 0 { | 226 | if config.Logging.MaxAge < 0 { | ||
| 170 | validationErrors = append(validationErrors, "logging.max_age must be non-negative") | 227 | validationErrors = append(validationErrors, "logging.max_age must be non-negative") | ||
| 171 | } | 228 | } | ||
| 172 | - | ||||
| 173 | - | // Validate logging level using switch statement | |||
| 174 | switch config.Logging.Level { | 229 | switch config.Logging.Level { | ||
| 175 | case "trace", "debug", "info", "warn", "error", "fatal", "": | 230 | case "trace", "debug", "info", "warn", "error", "fatal", "": | ||
| 176 | - | // Valid (empty uses default) | |||
| 177 | default: | 231 | default: | ||
| 178 | - | validationErrors = append(validationErrors, fmt.Sprintf("logging.level must be one of: trace, debug, info, warn, error, fatal (got: %s)", config.Logging.Level)) | 232 | + | validationErrors = append(validationErrors, fmt.Sprintf( |
| 233 | + | "logging.level must be one of: trace, debug, info, warn, error, fatal (got: %s)", | |||
| 234 | + | config.Logging.Level, | |||
| 235 | + | )) | |||
| 179 | } | 236 | } | ||
| 180 | - | ||||
| 181 | - | // Validate logging format using switch statement | |||
| 182 | switch config.Logging.Format { | 237 | switch config.Logging.Format { | ||
| 183 | case "text", "json", "console", "": | 238 | case "text", "json", "console", "": | ||
| 184 | - | // Valid (empty uses default) | |||
| 185 | default: | 239 | default: | ||
| 186 | - | validationErrors = append(validationErrors, fmt.Sprintf("logging.format must be one of: text, json, console (got: %s)", config.Logging.Format)) | 240 | + | validationErrors = append(validationErrors, fmt.Sprintf( |
| 241 | + | "logging.format must be one of: text, json, console (got: %s)", | |||
| 242 | + | config.Logging.Format, | |||
| 243 | + | )) | |||
| 187 | } | 244 | } | ||
| 245 | + | return validationErrors | |||
| 246 | + | } | |||
| 188 | 247 | ||||
| 189 | - | // Validate omnibox initial behavior using switch statement | 248 | + | func validateOmnibox(config *Config) []string { |
| 190 | switch config.Omnibox.InitialBehavior { | 249 | switch config.Omnibox.InitialBehavior { | ||
| 191 | case "recent", "most_visited", "none": | 250 | case "recent", "most_visited", "none": | ||
| 192 | - | // Valid | 251 | + | return nil |
| 193 | default: | 252 | default: | ||
| 194 | - | validationErrors = append(validationErrors, fmt.Sprintf("omnibox.initial_behavior must be one of: recent, most_visited, none (got: %s)", config.Omnibox.InitialBehavior)) | 253 | + | return []string{fmt.Sprintf( |
| 254 | + | "omnibox.initial_behavior must be one of: recent, most_visited, none (got: %s)", | |||
| 255 | + | config.Omnibox.InitialBehavior, | |||
| 256 | + | )} | |||
| 195 | } | 257 | } | ||
| 258 | + | } | |||
| 196 | 259 | ||||
| 197 | - | // Validate rendering mode using switch statement | 260 | + | func validateRendering(config *Config) []string { |
| 198 | switch config.RenderingMode { | 261 | switch config.RenderingMode { | ||
| 199 | case RenderingModeAuto, RenderingModeGPU, RenderingModeCPU, "": | 262 | case RenderingModeAuto, RenderingModeGPU, RenderingModeCPU, "": | ||
| 200 | - | // Valid (empty uses default) | 263 | + | return nil |
| 201 | default: | 264 | default: | ||
| 202 | - | validationErrors = append(validationErrors, fmt.Sprintf("rendering_mode must be one of: auto, gpu, cpu (got: %s)", config.RenderingMode)) | 265 | + | return []string{fmt.Sprintf("rendering_mode must be one of: auto, gpu, cpu (got: %s)", config.RenderingMode)} |
| 203 | } | 266 | } | ||
| 267 | + | } | |||
| 204 | 268 | ||||
| 205 | - | // Validate color scheme using switch statement | 269 | + | func validateColorScheme(config *Config) []string { |
| 206 | switch config.Appearance.ColorScheme { | 270 | switch config.Appearance.ColorScheme { | ||
| 207 | - | case "prefer-dark", "prefer-light", ThemeDefault, "": | 271 | + | case ThemePreferDark, ThemePreferLight, ThemeDefault, "": |
| 208 | - | // Valid (empty uses default) | 272 | + | return nil |
| 209 | default: | 273 | default: | ||
| 210 | - | validationErrors = append(validationErrors, fmt.Sprintf("appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)", config.Appearance.ColorScheme)) | 274 | + | return []string{fmt.Sprintf( |
| 275 | + | "appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)", | |||
| 276 | + | config.Appearance.ColorScheme, | |||
| 277 | + | )} | |||
| 211 | } | 278 | } | ||
| 212 | - | ||||
| 213 | - | // If there are validation errors, return them | |||
| 214 | - | if len(validationErrors) > 0 { | |||
| 215 | - | return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - ")) | |||
| 216 | - | } | |||
| 217 | - | ||||
| 218 | - | return nil | |||
| 219 | } | 279 | } | ||
M internal/infrastructure/config/watcher.go +2 -2
| @@ -18,7 +18,7 @@ func (m *Manager) Watch() error { | |||||
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | m.viper.WatchConfig() | 20 | m.viper.WatchConfig() | ||
| 21 | - | m.viper.OnConfigChange(func(e fsnotify.Event) { | 21 | + | m.viper.OnConfigChange(func(_ fsnotify.Event) { |
| 22 | // Reload config | 22 | // Reload config | ||
| 23 | if err := m.reload(); err != nil { | 23 | if err := m.reload(); err != nil { | ||
| 24 | fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err) | 24 | fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err) | ||
| @@ -83,7 +83,7 @@ func (m *Manager) reload() error { | |||||
| 83 | 83 | ||||
| 84 | // Validate ColorScheme setting using switch statement | 84 | // Validate ColorScheme setting using switch statement | ||
| 85 | switch config.Appearance.ColorScheme { | 85 | switch config.Appearance.ColorScheme { | ||
| 86 | - | case "prefer-dark", "prefer-light", ThemeDefault: | 86 | + | case ThemePreferDark, ThemePreferLight, ThemeDefault: |
| 87 | // Valid values - no changes needed | 87 | // Valid values - no changes needed | ||
| 88 | case "": | 88 | case "": | ||
| 89 | // Empty string is treated the same as "default" | 89 | // Empty string is treated the same as "default" | ||
M internal/infrastructure/deps/env.go +1 -1
| @@ -15,7 +15,7 @@ func ApplyPrefixEnv(prefix string) { | |||||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | for key, values := range prefixEnv(prefix) { | 17 | for key, values := range prefixEnv(prefix) { | ||
| 18 | - | os.Setenv(key, prependPathList(os.Getenv(key), values...)) | 18 | + | _ = os.Setenv(key, prependPathList(os.Getenv(key), values...)) |
| 19 | } | 19 | } | ||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
M internal/infrastructure/deps/pkgconfig.go +8 -1
| @@ -15,7 +15,14 @@ func NewPkgConfigProbe() *PkgConfigProbe { | |||||
| 15 | return &PkgConfigProbe{} | 15 | return &PkgConfigProbe{} | ||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | - | func (p *PkgConfigProbe) PkgConfigModVersion(ctx context.Context, pkgName string, prefix string) (string, error) { | 18 | + | func (p *PkgConfigProbe) PkgConfigModVersion(ctx context.Context, pkgName, prefix string) (string, error) { |
| 19 | + | if p == nil { | |||
| 20 | + | return "", &port.PkgConfigError{ | |||
| 21 | + | Kind: port.PkgConfigErrorKindCommandMissing, | |||
| 22 | + | Package: pkgName, | |||
| 23 | + | Err: port.ErrPkgConfigMissing, | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 19 | pc, err := exec.LookPath("pkg-config") | 26 | pc, err := exec.LookPath("pkg-config") | ||
| 20 | if err != nil { | 27 | if err != nil { | ||
| 21 | return "", &port.PkgConfigError{ | 28 | return "", &port.PkgConfigError{ | ||
M internal/infrastructure/desktop/adapter.go +10 -4
| @@ -103,8 +103,8 @@ func getExecutablePath() (string, error) { | |||||
| 103 | execPath, err := os.Executable() | 103 | execPath, err := os.Executable() | ||
| 104 | if err == nil { | 104 | if err == nil { | ||
| 105 | // Resolve symlinks | 105 | // Resolve symlinks | ||
| 106 | - | resolved, err := filepath.EvalSymlinks(execPath) | 106 | + | resolved, symlinkErr := filepath.EvalSymlinks(execPath) |
| 107 | - | if err == nil { | 107 | + | if symlinkErr == nil { |
| 108 | execPath = resolved | 108 | execPath = resolved | ||
| 109 | } | 109 | } | ||
| 110 | return execPath, nil | 110 | return execPath, nil | ||
| @@ -130,7 +130,7 @@ func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus | |||||
| 130 | } | 130 | } | ||
| 131 | status.DesktopFilePath = desktopPath | 131 | status.DesktopFilePath = desktopPath | ||
| 132 | 132 | ||||
| 133 | - | if _, err := os.Stat(desktopPath); err == nil { | 133 | + | if _, statErr := os.Stat(desktopPath); statErr == nil { |
| 134 | status.DesktopFileInstalled = true | 134 | status.DesktopFileInstalled = true | ||
| 135 | } | 135 | } | ||
| 136 | 136 | ||||
| @@ -141,7 +141,7 @@ func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus | |||||
| 141 | } | 141 | } | ||
| 142 | status.IconFilePath = iconPath | 142 | status.IconFilePath = iconPath | ||
| 143 | 143 | ||||
| 144 | - | if _, err := os.Stat(iconPath); err == nil { | 144 | + | if _, iconStatErr := os.Stat(iconPath); iconStatErr == nil { |
| 145 | status.IconInstalled = true | 145 | status.IconInstalled = true | ||
| 146 | } | 146 | } | ||
| 147 | 147 | ||||
| @@ -244,6 +244,9 @@ func (a *Adapter) RemoveDesktopFile(ctx context.Context) error { | |||||
| 244 | 244 | ||||
| 245 | // InstallIcon writes the icon file to XDG icons directory. | 245 | // InstallIcon writes the icon file to XDG icons directory. | ||
| 246 | func (a *Adapter) InstallIcon(ctx context.Context, svgData []byte) (string, error) { | 246 | func (a *Adapter) InstallIcon(ctx context.Context, svgData []byte) (string, error) { | ||
| 247 | + | if a == nil { | |||
| 248 | + | return "", fmt.Errorf("desktop adapter is nil") | |||
| 249 | + | } | |||
| 247 | log := logging.FromContext(ctx) | 250 | log := logging.FromContext(ctx) | ||
| 248 | 251 | ||||
| 249 | iconPath, err := getIconFilePath() | 252 | iconPath, err := getIconFilePath() | ||
| @@ -269,6 +272,9 @@ func (a *Adapter) InstallIcon(ctx context.Context, svgData []byte) (string, erro | |||||
| 269 | 272 | ||||
| 270 | // RemoveIcon removes the icon file from XDG icons directory. | 273 | // RemoveIcon removes the icon file from XDG icons directory. | ||
| 271 | func (a *Adapter) RemoveIcon(ctx context.Context) error { | 274 | func (a *Adapter) RemoveIcon(ctx context.Context) error { | ||
| 275 | + | if a == nil { | |||
| 276 | + | return fmt.Errorf("desktop adapter is nil") | |||
| 277 | + | } | |||
| 272 | log := logging.FromContext(ctx) | 278 | log := logging.FromContext(ctx) | ||
| 273 | 279 | ||||
| 274 | iconPath, err := getIconFilePath() | 280 | iconPath, err := getIconFilePath() | ||
M internal/infrastructure/favicon/cache.go +0 -1
| @@ -192,7 +192,6 @@ func (c *Cache) loadFromDisk(domain string) []byte { | |||||
| 192 | return nil | 192 | return nil | ||
| 193 | } | 193 | } | ||
| 194 | 194 | ||||
| 195 | - | //nolint:gosec // path is constructed from sanitized domain | |||
| 196 | data, err := os.ReadFile(path) | 195 | data, err := os.ReadFile(path) | ||
| 197 | if err != nil { | 196 | if err != nil { | ||
| 198 | return nil | 197 | return nil | ||
M internal/infrastructure/favicon/fetcher.go +5 -1
| @@ -56,7 +56,11 @@ func (f *Fetcher) Fetch(ctx context.Context, domain string) ([]byte, error) { | |||||
| 56 | log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo") | 56 | log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo") | ||
| 57 | return nil, err | 57 | return nil, err | ||
| 58 | } | 58 | } | ||
| 59 | - | defer resp.Body.Close() | 59 | + | defer func() { |
| 60 | + | if closeErr := resp.Body.Close(); closeErr != nil { | |||
| 61 | + | log.Debug().Err(closeErr).Str("domain", domain).Msg("failed to close favicon response body") | |||
| 62 | + | } | |||
| 63 | + | }() | |||
| 60 | 64 | ||||
| 61 | if resp.StatusCode != http.StatusOK { | 65 | if resp.StatusCode != http.StatusOK { | ||
| 62 | log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status") | 66 | log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status") | ||
M internal/infrastructure/filtering/downloader.go +26 -10
| @@ -13,6 +13,11 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/logging" | 13 | "github.com/bnema/dumber/internal/logging" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | + | const ( | |||
| 17 | + | cacheDirPerm = 0o755 | |||
| 18 | + | manifestFilePerm = 0o644 | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 16 | // Downloader handles downloading filter files from GitHub releases. | 21 | // Downloader handles downloading filter files from GitHub releases. | ||
| 17 | type Downloader struct { | 22 | type Downloader struct { | ||
| 18 | baseURL string | 23 | baseURL string | ||
| @@ -60,7 +65,7 @@ func (d *Downloader) FetchManifest(ctx context.Context) (*Manifest, error) { | |||||
| 60 | url := fmt.Sprintf("%s/%s", d.baseURL, FilterFiles.Manifest) | 65 | url := fmt.Sprintf("%s/%s", d.baseURL, FilterFiles.Manifest) | ||
| 61 | log.Debug().Str("url", url).Msg("fetching manifest") | 66 | log.Debug().Str("url", url).Msg("fetching manifest") | ||
| 62 | 67 | ||||
| 63 | - | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | 68 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) |
| 64 | if err != nil { | 69 | if err != nil { | ||
| 65 | return nil, fmt.Errorf("failed to create request: %w", err) | 70 | return nil, fmt.Errorf("failed to create request: %w", err) | ||
| 66 | } | 71 | } | ||
| @@ -69,7 +74,11 @@ func (d *Downloader) FetchManifest(ctx context.Context) (*Manifest, error) { | |||||
| 69 | if err != nil { | 74 | if err != nil { | ||
| 70 | return nil, fmt.Errorf("failed to fetch manifest: %w", err) | 75 | return nil, fmt.Errorf("failed to fetch manifest: %w", err) | ||
| 71 | } | 76 | } | ||
| 72 | - | defer resp.Body.Close() | 77 | + | defer func() { |
| 78 | + | if closeErr := resp.Body.Close(); closeErr != nil { | |||
| 79 | + | log.Debug().Err(closeErr).Msg("failed to close manifest response body") | |||
| 80 | + | } | |||
| 81 | + | }() | |||
| 73 | 82 | ||||
| 74 | if resp.StatusCode != http.StatusOK { | 83 | if resp.StatusCode != http.StatusOK { | ||
| 75 | return nil, fmt.Errorf("manifest fetch failed with status %d", resp.StatusCode) | 84 | return nil, fmt.Errorf("manifest fetch failed with status %d", resp.StatusCode) | ||
| @@ -105,7 +114,7 @@ func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(Downlo | |||||
| 105 | Logger() | 114 | Logger() | ||
| 106 | 115 | ||||
| 107 | // Ensure cache directory exists | 116 | // Ensure cache directory exists | ||
| 108 | - | if err := os.MkdirAll(d.cacheDir, 0o755); err != nil { | 117 | + | if err := os.MkdirAll(d.cacheDir, cacheDirPerm); err != nil { |
| 109 | return nil, fmt.Errorf("failed to create cache dir: %w", err) | 118 | return nil, fmt.Errorf("failed to create cache dir: %w", err) | ||
| 110 | } | 119 | } | ||
| 111 | 120 | ||||
| @@ -155,7 +164,7 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string, | |||||
| 155 | url := fmt.Sprintf("%s/%s", d.baseURL, filename) | 164 | url := fmt.Sprintf("%s/%s", d.baseURL, filename) | ||
| 156 | localPath := filepath.Join(d.cacheDir, filename) | 165 | localPath := filepath.Join(d.cacheDir, filename) | ||
| 157 | 166 | ||||
| 158 | - | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | 167 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) |
| 159 | if err != nil { | 168 | if err != nil { | ||
| 160 | return "", 0, fmt.Errorf("failed to create request: %w", err) | 169 | return "", 0, fmt.Errorf("failed to create request: %w", err) | ||
| 161 | } | 170 | } | ||
| @@ -164,7 +173,11 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string, | |||||
| 164 | if err != nil { | 173 | if err != nil { | ||
| 165 | return "", 0, fmt.Errorf("failed to download %s: %w", filename, err) | 174 | return "", 0, fmt.Errorf("failed to download %s: %w", filename, err) | ||
| 166 | } | 175 | } | ||
| 167 | - | defer resp.Body.Close() | 176 | + | defer func() { |
| 177 | + | if closeErr := resp.Body.Close(); closeErr != nil { | |||
| 178 | + | logging.FromContext(ctx).Debug().Err(closeErr).Str("file", filename).Msg("failed to close filter response body") | |||
| 179 | + | } | |||
| 180 | + | }() | |||
| 168 | 181 | ||||
| 169 | if resp.StatusCode != http.StatusOK { | 182 | if resp.StatusCode != http.StatusOK { | ||
| 170 | return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode) | 183 | return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode) | ||
| @@ -179,15 +192,18 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string, | |||||
| 179 | 192 | ||||
| 180 | bytesWritten, err := io.Copy(file, resp.Body) | 193 | bytesWritten, err := io.Copy(file, resp.Body) | ||
| 181 | if err != nil { | 194 | if err != nil { | ||
| 182 | - | file.Close() | 195 | + | _ = file.Close() |
| 183 | - | os.Remove(tmpPath) | 196 | + | _ = os.Remove(tmpPath) |
| 184 | return "", 0, fmt.Errorf("failed to write %s: %w", filename, err) | 197 | return "", 0, fmt.Errorf("failed to write %s: %w", filename, err) | ||
| 185 | } | 198 | } | ||
| 186 | - | file.Close() | 199 | + | if err := file.Close(); err != nil { |
| 200 | + | _ = os.Remove(tmpPath) | |||
| 201 | + | return "", 0, fmt.Errorf("failed to close temp file: %w", err) | |||
| 202 | + | } | |||
| 187 | 203 | ||||
| 188 | // Atomic rename | 204 | // Atomic rename | ||
| 189 | if err := os.Rename(tmpPath, localPath); err != nil { | 205 | if err := os.Rename(tmpPath, localPath); err != nil { | ||
| 190 | - | os.Remove(tmpPath) | 206 | + | _ = os.Remove(tmpPath) |
| 191 | return "", 0, fmt.Errorf("failed to rename temp file: %w", err) | 207 | return "", 0, fmt.Errorf("failed to rename temp file: %w", err) | ||
| 192 | } | 208 | } | ||
| 193 | 209 | ||||
| @@ -207,7 +223,7 @@ func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error { | |||||
| 207 | } | 223 | } | ||
| 208 | 224 | ||||
| 209 | path := filepath.Join(d.cacheDir, FilterFiles.Manifest) | 225 | path := filepath.Join(d.cacheDir, FilterFiles.Manifest) | ||
| 210 | - | if err := os.WriteFile(path, data, 0o644); err != nil { | 226 | + | if err := os.WriteFile(path, data, manifestFilePerm); err != nil { |
| 211 | return fmt.Errorf("failed to write manifest: %w", err) | 227 | return fmt.Errorf("failed to write manifest: %w", err) | ||
| 212 | } | 228 | } | ||
| 213 | 229 | ||||
M internal/infrastructure/filtering/manager.go +12 -5
| @@ -13,6 +13,12 @@ import ( | |||||
| 13 | "github.com/bnema/puregotk-webkit/webkit" | 13 | "github.com/bnema/puregotk-webkit/webkit" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | + | const ( | |||
| 17 | + | storeDirPerm = 0o755 | |||
| 18 | + | jsonDirPerm = 0o755 | |||
| 19 | + | mergedFilePerm = 0o644 | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 16 | // Manager orchestrates the content filter lifecycle. | 22 | // Manager orchestrates the content filter lifecycle. | ||
| 17 | // It handles downloading, compiling, loading, and applying filters to WebViews. | 23 | // It handles downloading, compiling, loading, and applying filters to WebViews. | ||
| 18 | type Manager struct { | 24 | type Manager struct { | ||
| @@ -47,20 +53,21 @@ type ManagerConfig struct { | |||||
| 47 | // NewManager creates a new filter Manager. | 53 | // NewManager creates a new filter Manager. | ||
| 48 | func NewManager(cfg ManagerConfig) (*Manager, error) { | 54 | func NewManager(cfg ManagerConfig) (*Manager, error) { | ||
| 49 | // Ensure directories exist | 55 | // Ensure directories exist | ||
| 50 | - | if err := os.MkdirAll(cfg.StoreDir, 0o755); err != nil { | 56 | + | if err := os.MkdirAll(cfg.StoreDir, storeDirPerm); err != nil { |
| 51 | return nil, fmt.Errorf("failed to create store dir: %w", err) | 57 | return nil, fmt.Errorf("failed to create store dir: %w", err) | ||
| 52 | } | 58 | } | ||
| 53 | - | if err := os.MkdirAll(cfg.JSONDir, 0o755); err != nil { | 59 | + | if err := os.MkdirAll(cfg.JSONDir, jsonDirPerm); err != nil { |
| 54 | return nil, fmt.Errorf("failed to create json dir: %w", err) | 60 | return nil, fmt.Errorf("failed to create json dir: %w", err) | ||
| 55 | } | 61 | } | ||
| 56 | 62 | ||||
| 57 | // Use provided implementations or create defaults | 63 | // Use provided implementations or create defaults | ||
| 58 | store := cfg.Store | 64 | store := cfg.Store | ||
| 59 | if store == nil { | 65 | if store == nil { | ||
| 60 | - | store = NewStore(cfg.StoreDir) | 66 | + | newStore := NewStore(cfg.StoreDir) |
| 61 | - | if store == nil { | 67 | + | if newStore == nil { |
| 62 | return nil, fmt.Errorf("failed to create filter store at %s", cfg.StoreDir) | 68 | return nil, fmt.Errorf("failed to create filter store at %s", cfg.StoreDir) | ||
| 63 | } | 69 | } | ||
| 70 | + | store = newStore | |||
| 64 | } | 71 | } | ||
| 65 | 72 | ||||
| 66 | downloader := cfg.Downloader | 73 | downloader := cfg.Downloader | ||
| @@ -243,7 +250,7 @@ func (m *Manager) mergeJSONFiles(ctx context.Context, paths []string) (string, e | |||||
| 243 | 250 | ||||
| 244 | // Write merged file | 251 | // Write merged file | ||
| 245 | mergedPath := filepath.Join(m.jsonDir, "merged.json") | 252 | mergedPath := filepath.Join(m.jsonDir, "merged.json") | ||
| 246 | - | if err := os.WriteFile(mergedPath, merged, 0o644); err != nil { | 253 | + | if err := os.WriteFile(mergedPath, merged, mergedFilePerm); err != nil { |
| 247 | return "", fmt.Errorf("failed to write merged file: %w", err) | 254 | return "", fmt.Errorf("failed to write merged file: %w", err) | ||
| 248 | } | 255 | } | ||
| 249 | 256 | ||||
M internal/infrastructure/filtering/manager_test.go +5 -5
| @@ -74,9 +74,9 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) { | |||||
| 74 | // Track status changes | 74 | // Track status changes | ||
| 75 | var statuses []filtering.FilterStatus | 75 | var statuses []filtering.FilterStatus | ||
| 76 | var mu sync.Mutex | 76 | var mu sync.Mutex | ||
| 77 | - | mgr.SetStatusCallback(func(s filtering.FilterStatus) { | 77 | + | mgr.SetStatusCallback(func(status filtering.FilterStatus) { |
| 78 | mu.Lock() | 78 | mu.Lock() | ||
| 79 | - | statuses = append(statuses, s) | 79 | + | statuses = append(statuses, status) |
| 80 | mu.Unlock() | 80 | mu.Unlock() | ||
| 81 | }) | 81 | }) | ||
| 82 | 82 | ||||
| @@ -165,9 +165,9 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) { | |||||
| 165 | // Track status changes | 165 | // Track status changes | ||
| 166 | var statuses []filtering.FilterStatus | 166 | var statuses []filtering.FilterStatus | ||
| 167 | var mu sync.Mutex | 167 | var mu sync.Mutex | ||
| 168 | - | mgr.SetStatusCallback(func(s filtering.FilterStatus) { | 168 | + | mgr.SetStatusCallback(func(status filtering.FilterStatus) { |
| 169 | mu.Lock() | 169 | mu.Lock() | ||
| 170 | - | statuses = append(statuses, s) | 170 | + | statuses = append(statuses, status) |
| 171 | mu.Unlock() | 171 | mu.Unlock() | ||
| 172 | }) | 172 | }) | ||
| 173 | 173 | ||||
| @@ -470,7 +470,7 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) { | |||||
| 470 | // Track callback invocations | 470 | // Track callback invocations | ||
| 471 | callCount := 0 | 471 | callCount := 0 | ||
| 472 | var mu sync.Mutex | 472 | var mu sync.Mutex | ||
| 473 | - | mgr.SetStatusCallback(func(s filtering.FilterStatus) { | 473 | + | mgr.SetStatusCallback(func(_ filtering.FilterStatus) { |
| 474 | mu.Lock() | 474 | mu.Lock() | ||
| 475 | callCount++ | 475 | callCount++ | ||
| 476 | mu.Unlock() | 476 | mu.Unlock() | ||
M internal/infrastructure/filtering/store.go +5 -5
| @@ -44,7 +44,7 @@ type CompileResult struct { | |||||
| 44 | // Compile compiles a JSON filter file and stores it with the given identifier. | 44 | // Compile compiles a JSON filter file and stores it with the given identifier. | ||
| 45 | // This is an async operation that may take several seconds for large filter sets. | 45 | // This is an async operation that may take several seconds for large filter sets. | ||
| 46 | // The JSON file must be in Safari Content Blocker format. | 46 | // The JSON file must be in Safari Content Blocker format. | ||
| 47 | - | func (s *Store) Compile(ctx context.Context, identifier string, jsonPath string) (*webkit.UserContentFilter, error) { | 47 | + | func (s *Store) Compile(ctx context.Context, identifier, jsonPath string) (*webkit.UserContentFilter, error) { |
| 48 | log := logging.FromContext(ctx).With(). | 48 | log := logging.FromContext(ctx).With(). | ||
| 49 | Str("component", "filter-store"). | 49 | Str("component", "filter-store"). | ||
| 50 | Str("identifier", identifier). | 50 | Str("identifier", identifier). | ||
| @@ -90,7 +90,7 @@ func (s *Store) Compile(ctx context.Context, identifier string, jsonPath string) | |||||
| 90 | log.Info().Msg("filter compilation completed") | 90 | log.Info().Msg("filter compilation completed") | ||
| 91 | return result.Filter, nil | 91 | return result.Filter, nil | ||
| 92 | case <-ctx.Done(): | 92 | case <-ctx.Done(): | ||
| 93 | - | log.Warn().Msg("filter compilation cancelled") | 93 | + | log.Warn().Msg("filter compilation canceled") |
| 94 | return nil, ctx.Err() | 94 | return nil, ctx.Err() | ||
| 95 | } | 95 | } | ||
| 96 | } | 96 | } | ||
| @@ -142,7 +142,7 @@ func (s *Store) Load(ctx context.Context, identifier string) (*webkit.UserConten | |||||
| 142 | log.Debug().Msg("filter loaded successfully") | 142 | log.Debug().Msg("filter loaded successfully") | ||
| 143 | return result.Filter, nil | 143 | return result.Filter, nil | ||
| 144 | case <-ctx.Done(): | 144 | case <-ctx.Done(): | ||
| 145 | - | log.Warn().Msg("filter load cancelled") | 145 | + | log.Warn().Msg("filter load canceled") |
| 146 | return nil, ctx.Err() | 146 | return nil, ctx.Err() | ||
| 147 | } | 147 | } | ||
| 148 | } | 148 | } | ||
| @@ -192,7 +192,7 @@ func (s *Store) FetchIdentifiers(ctx context.Context) ([]string, error) { | |||||
| 192 | log.Debug().Int("count", len(result.Identifiers)).Msg("fetched filter identifiers") | 192 | log.Debug().Int("count", len(result.Identifiers)).Msg("fetched filter identifiers") | ||
| 193 | return result.Identifiers, nil | 193 | return result.Identifiers, nil | ||
| 194 | case <-ctx.Done(): | 194 | case <-ctx.Done(): | ||
| 195 | - | log.Warn().Msg("fetch identifiers cancelled") | 195 | + | log.Warn().Msg("fetch identifiers canceled") |
| 196 | return nil, ctx.Err() | 196 | return nil, ctx.Err() | ||
| 197 | } | 197 | } | ||
| 198 | } | 198 | } | ||
| @@ -237,7 +237,7 @@ func (s *Store) Remove(ctx context.Context, identifier string) error { | |||||
| 237 | log.Info().Msg("filter removed") | 237 | log.Info().Msg("filter removed") | ||
| 238 | return nil | 238 | return nil | ||
| 239 | case <-ctx.Done(): | 239 | case <-ctx.Done(): | ||
| 240 | - | log.Warn().Msg("remove filter cancelled") | 240 | + | log.Warn().Msg("remove filter canceled") |
| 241 | return ctx.Err() | 241 | return ctx.Err() | ||
| 242 | } | 242 | } | ||
| 243 | } | 243 | } | ||
M internal/infrastructure/media/diagnostics.go +22 -9
| @@ -90,6 +90,9 @@ func (a *Adapter) checkGStreamerPlugins(ctx context.Context, r *port.MediaDiagno | |||||
| 90 | 90 | ||||
| 91 | // parseVADecoders extracts decoder names from gst-inspect-1.0 va output. | 91 | // parseVADecoders extracts decoder names from gst-inspect-1.0 va output. | ||
| 92 | func (a *Adapter) parseVADecoders(output string, r *port.MediaDiagnosticsResult) { | 92 | func (a *Adapter) parseVADecoders(output string, r *port.MediaDiagnosticsResult) { | ||
| 93 | + | if a == nil { | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 93 | lines := strings.Split(output, "\n") | 96 | lines := strings.Split(output, "\n") | ||
| 94 | for _, line := range lines { | 97 | for _, line := range lines { | ||
| 95 | line = strings.TrimSpace(line) | 98 | line = strings.TrimSpace(line) | ||
| @@ -111,6 +114,9 @@ func (a *Adapter) parseVADecoders(output string, r *port.MediaDiagnosticsResult) | |||||
| 111 | 114 | ||||
| 112 | // parseVAAPIDecoders extracts decoder names from gst-inspect-1.0 vaapi output. | 115 | // parseVAAPIDecoders extracts decoder names from gst-inspect-1.0 vaapi output. | ||
| 113 | func (a *Adapter) parseVAAPIDecoders(output string, r *port.MediaDiagnosticsResult) { | 116 | func (a *Adapter) parseVAAPIDecoders(output string, r *port.MediaDiagnosticsResult) { | ||
| 117 | + | if a == nil { | |||
| 118 | + | return | |||
| 119 | + | } | |||
| 114 | lines := strings.Split(output, "\n") | 120 | lines := strings.Split(output, "\n") | ||
| 115 | for _, line := range lines { | 121 | for _, line := range lines { | ||
| 116 | line = strings.TrimSpace(line) | 122 | line = strings.TrimSpace(line) | ||
| @@ -132,6 +138,9 @@ func (a *Adapter) parseVAAPIDecoders(output string, r *port.MediaDiagnosticsResu | |||||
| 132 | 138 | ||||
| 133 | // parseNVCodecDecoders extracts decoder names from gst-inspect-1.0 nvcodec output. | 139 | // parseNVCodecDecoders extracts decoder names from gst-inspect-1.0 nvcodec output. | ||
| 134 | func (a *Adapter) parseNVCodecDecoders(output string, r *port.MediaDiagnosticsResult) { | 140 | func (a *Adapter) parseNVCodecDecoders(output string, r *port.MediaDiagnosticsResult) { | ||
| 141 | + | if a == nil { | |||
| 142 | + | return | |||
| 143 | + | } | |||
| 135 | lines := strings.Split(output, "\n") | 144 | lines := strings.Split(output, "\n") | ||
| 136 | for _, line := range lines { | 145 | for _, line := range lines { | ||
| 137 | line = strings.TrimSpace(line) | 146 | line = strings.TrimSpace(line) | ||
| @@ -154,6 +163,9 @@ func (a *Adapter) parseNVCodecDecoders(output string, r *port.MediaDiagnosticsRe | |||||
| 154 | // checkVAAPI detects VA-API driver and version. | 163 | // checkVAAPI detects VA-API driver and version. | ||
| 155 | func (a *Adapter) checkVAAPI(ctx context.Context, r *port.MediaDiagnosticsResult) { | 164 | func (a *Adapter) checkVAAPI(ctx context.Context, r *port.MediaDiagnosticsResult) { | ||
| 156 | log := logging.FromContext(ctx) | 165 | log := logging.FromContext(ctx) | ||
| 166 | + | if a == nil { | |||
| 167 | + | return | |||
| 168 | + | } | |||
| 157 | 169 | ||||
| 158 | // Check LIBVA_DRIVER_NAME environment first | 170 | // Check LIBVA_DRIVER_NAME environment first | ||
| 159 | if driver := os.Getenv("LIBVA_DRIVER_NAME"); driver != "" { | 171 | if driver := os.Getenv("LIBVA_DRIVER_NAME"); driver != "" { | ||
| @@ -211,23 +223,23 @@ func (a *Adapter) checkVAAPI(ctx context.Context, r *port.MediaDiagnosticsResult | |||||
| 211 | } | 223 | } | ||
| 212 | 224 | ||||
| 213 | // generateWarnings creates user-friendly warning messages. | 225 | // generateWarnings creates user-friendly warning messages. | ||
| 226 | + | //nolint:revive // receiver required for interface consistency | |||
| 214 | func (a *Adapter) generateWarnings(r *port.MediaDiagnosticsResult) { | 227 | func (a *Adapter) generateWarnings(r *port.MediaDiagnosticsResult) { | ||
| 215 | // Critical: GStreamer not installed | 228 | // Critical: GStreamer not installed | ||
| 216 | if !r.GStreamerAvailable { | 229 | if !r.GStreamerAvailable { | ||
| 217 | r.Warnings = append(r.Warnings, | 230 | r.Warnings = append(r.Warnings, | ||
| 218 | - | "CRITICAL: GStreamer not installed. Video playback will not work!") | 231 | + | "CRITICAL: GStreamer not installed. Video playback will not work!", |
| 219 | - | r.Warnings = append(r.Warnings, | 232 | + | "Install: gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav", |
| 220 | - | "Install: gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav") | 233 | + | ) |
| 221 | return // No point checking other things | 234 | return // No point checking other things | ||
| 222 | } | 235 | } | ||
| 223 | 236 | ||||
| 224 | if !r.HWAccelAvailable { | 237 | if !r.HWAccelAvailable { | ||
| 225 | r.Warnings = append(r.Warnings, | 238 | r.Warnings = append(r.Warnings, | ||
| 226 | - | "No hardware video acceleration detected. Video will use software decoding (higher CPU).") | 239 | + | "No hardware video acceleration detected. Video will use software decoding (higher CPU).", |
| 227 | - | r.Warnings = append(r.Warnings, | 240 | + | "Install VA plugin: gst-plugin-va (Arch) or gstreamer1.0-plugins-bad (Debian/Ubuntu)", |
| 228 | - | "Install VA plugin: gst-plugin-va (Arch) or gstreamer1.0-plugins-bad (Debian/Ubuntu)") | 241 | + | "Install VA-API driver: libva-mesa-driver (AMD) | intel-media-driver (Intel) | nvidia-vaapi-driver (NVIDIA)", |
| 229 | - | r.Warnings = append(r.Warnings, | 242 | + | ) |
| 230 | - | "Install VA-API driver: libva-mesa-driver (AMD) | intel-media-driver (Intel) | nvidia-vaapi-driver (NVIDIA)") | |||
| 231 | } | 243 | } | ||
| 232 | 244 | ||||
| 233 | // AV1 is the preferred codec - warn if not available | 245 | // AV1 is the preferred codec - warn if not available | ||
| @@ -245,6 +257,7 @@ func (a *Adapter) generateWarnings(r *port.MediaDiagnosticsResult) { | |||||
| 245 | // Prefer modern VA plugin over legacy VAAPI | 257 | // Prefer modern VA plugin over legacy VAAPI | ||
| 246 | if r.HasVAAPIPlugin && !r.HasVAPlugin { | 258 | if r.HasVAAPIPlugin && !r.HasVAPlugin { | ||
| 247 | r.Warnings = append(r.Warnings, | 259 | r.Warnings = append(r.Warnings, | ||
| 248 | - | "Using legacy gstreamer-vaapi. Install gst-plugin-va (Arch) or gstreamer1.0-plugins-bad (Debian/Ubuntu) for modern VA stateless decoders.") | 260 | + | "Using legacy gstreamer-vaapi. Install gst-plugin-va (Arch) or "+ |
| 261 | + | "gstreamer1.0-plugins-bad (Debian/Ubuntu) for modern VA stateless decoders.") | |||
| 249 | } | 262 | } | ||
| 250 | } | 263 | } | ||
M internal/infrastructure/persistence/sqlite/connection.go +2 -1
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | // NewConnection creates a new SQLite database connection with optimized settings. | 15 | // NewConnection creates a new SQLite database connection with optimized settings. | ||
| 16 | // It creates the database directory if it doesn't exist and applies performance pragmas. | 16 | // It creates the database directory if it doesn't exist and applies performance pragmas. | ||
| 17 | func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) { | 17 | func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) { | ||
| 18 | + | const dbDirPerm = 0o750 | |||
| 18 | log := logging.FromContext(ctx) | 19 | log := logging.FromContext(ctx) | ||
| 19 | 20 | ||||
| 20 | if dbPath == "" { | 21 | if dbPath == "" { | ||
| @@ -22,7 +23,7 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) { | |||||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 24 | // Ensure database directory exists | 25 | // Ensure database directory exists | ||
| 25 | - | if err := os.MkdirAll(filepath.Dir(dbPath), 0750); err != nil { | 26 | + | if err := os.MkdirAll(filepath.Dir(dbPath), dbDirPerm); err != nil { |
| 26 | return nil, fmt.Errorf("failed to create database directory: %w", err) | 27 | return nil, fmt.Errorf("failed to create database directory: %w", err) | ||
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
M internal/infrastructure/persistence/sqlite/favorite_repo.go +9 -8
| @@ -3,6 +3,7 @@ package sqlite | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "database/sql" | 5 | "database/sql" | ||
| 6 | + | "errors" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | "github.com/bnema/dumber/internal/domain/repository" | 9 | "github.com/bnema/dumber/internal/domain/repository" | ||
| @@ -46,7 +47,7 @@ func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error { | |||||
| 46 | func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) { | 47 | func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) { | ||
| 47 | row, err := r.queries.GetFavoriteByID(ctx, int64(id)) | 48 | row, err := r.queries.GetFavoriteByID(ctx, int64(id)) | ||
| 48 | if err != nil { | 49 | if err != nil { | ||
| 49 | - | if err == sql.ErrNoRows { | 50 | + | if errors.Is(err, sql.ErrNoRows) { |
| 50 | return nil, nil | 51 | return nil, nil | ||
| 51 | } | 52 | } | ||
| 52 | return nil, err | 53 | return nil, err | ||
| @@ -57,7 +58,7 @@ func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*ent | |||||
| 57 | func (r *favoriteRepo) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) { | 58 | func (r *favoriteRepo) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) { | ||
| 58 | row, err := r.queries.GetFavoriteByURL(ctx, url) | 59 | row, err := r.queries.GetFavoriteByURL(ctx, url) | ||
| 59 | if err != nil { | 60 | if err != nil { | ||
| 60 | - | if err == sql.ErrNoRows { | 61 | + | if errors.Is(err, sql.ErrNoRows) { |
| 61 | return nil, nil | 62 | return nil, nil | ||
| 62 | } | 63 | } | ||
| 63 | return nil, err | 64 | return nil, err | ||
| @@ -91,7 +92,7 @@ func (r *favoriteRepo) GetByFolder(ctx context.Context, folderID *entity.FolderI | |||||
| 91 | func (r *favoriteRepo) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) { | 92 | func (r *favoriteRepo) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) { | ||
| 92 | row, err := r.queries.GetFavoriteByShortcut(ctx, sql.NullInt64{Int64: int64(key), Valid: true}) | 93 | row, err := r.queries.GetFavoriteByShortcut(ctx, sql.NullInt64{Int64: int64(key), Valid: true}) | ||
| 93 | if err != nil { | 94 | if err != nil { | ||
| 94 | - | if err == sql.ErrNoRows { | 95 | + | if errors.Is(err, sql.ErrNoRows) { |
| 95 | return nil, nil | 96 | return nil, nil | ||
| 96 | } | 97 | } | ||
| 97 | return nil, err | 98 | return nil, err | ||
| @@ -155,8 +156,8 @@ func favoriteFromRow(row sqlc.Favorite) *entity.Favorite { | |||||
| 155 | 156 | ||||
| 156 | func favoritesFromRows(rows []sqlc.Favorite) []*entity.Favorite { | 157 | func favoritesFromRows(rows []sqlc.Favorite) []*entity.Favorite { | ||
| 157 | favorites := make([]*entity.Favorite, len(rows)) | 158 | favorites := make([]*entity.Favorite, len(rows)) | ||
| 158 | - | for i, row := range rows { | 159 | + | for i := range rows { |
| 159 | - | favorites[i] = favoriteFromRow(row) | 160 | + | favorites[i] = favoriteFromRow(rows[i]) |
| 160 | } | 161 | } | ||
| 161 | return favorites | 162 | return favorites | ||
| 162 | } | 163 | } | ||
| @@ -196,7 +197,7 @@ func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error { | |||||
| 196 | func (r *folderRepo) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) { | 197 | func (r *folderRepo) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) { | ||
| 197 | row, err := r.queries.GetFolderByID(ctx, int64(id)) | 198 | row, err := r.queries.GetFolderByID(ctx, int64(id)) | ||
| 198 | if err != nil { | 199 | if err != nil { | ||
| 199 | - | if err == sql.ErrNoRows { | 200 | + | if errors.Is(err, sql.ErrNoRows) { |
| 200 | return nil, nil | 201 | return nil, nil | ||
| 201 | } | 202 | } | ||
| 202 | return nil, err | 203 | return nil, err | ||
| @@ -290,7 +291,7 @@ func (r *tagRepo) Save(ctx context.Context, tag *entity.Tag) error { | |||||
| 290 | func (r *tagRepo) FindByID(ctx context.Context, id entity.TagID) (*entity.Tag, error) { | 291 | func (r *tagRepo) FindByID(ctx context.Context, id entity.TagID) (*entity.Tag, error) { | ||
| 291 | row, err := r.queries.GetTagByID(ctx, int64(id)) | 292 | row, err := r.queries.GetTagByID(ctx, int64(id)) | ||
| 292 | if err != nil { | 293 | if err != nil { | ||
| 293 | - | if err == sql.ErrNoRows { | 294 | + | if errors.Is(err, sql.ErrNoRows) { |
| 294 | return nil, nil | 295 | return nil, nil | ||
| 295 | } | 296 | } | ||
| 296 | return nil, err | 297 | return nil, err | ||
| @@ -301,7 +302,7 @@ func (r *tagRepo) FindByID(ctx context.Context, id entity.TagID) (*entity.Tag, e | |||||
| 301 | func (r *tagRepo) FindByName(ctx context.Context, name string) (*entity.Tag, error) { | 302 | func (r *tagRepo) FindByName(ctx context.Context, name string) (*entity.Tag, error) { | ||
| 302 | row, err := r.queries.GetTagByName(ctx, name) | 303 | row, err := r.queries.GetTagByName(ctx, name) | ||
| 303 | if err != nil { | 304 | if err != nil { | ||
| 304 | - | if err == sql.ErrNoRows { | 305 | + | if errors.Is(err, sql.ErrNoRows) { |
| 305 | return nil, nil | 306 | return nil, nil | ||
| 306 | } | 307 | } | ||
| 307 | return nil, err | 308 | return nil, err | ||
M internal/infrastructure/persistence/sqlite/history_repo.go +9 -6
| @@ -3,6 +3,7 @@ package sqlite | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "database/sql" | 5 | "database/sql" | ||
| 6 | + | "errors" | |||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 9 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -34,7 +35,7 @@ func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) erro | |||||
| 34 | func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) { | 35 | func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) { | ||
| 35 | row, err := r.queries.GetHistoryByURL(ctx, url) | 36 | row, err := r.queries.GetHistoryByURL(ctx, url) | ||
| 36 | if err != nil { | 37 | if err != nil { | ||
| 37 | - | if err == sql.ErrNoRows { | 38 | + | if errors.Is(err, sql.ErrNoRows) { |
| 38 | return nil, nil | 39 | return nil, nil | ||
| 39 | } | 40 | } | ||
| 40 | return nil, err | 41 | return nil, err | ||
| @@ -60,7 +61,8 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en | |||||
| 60 | } | 61 | } | ||
| 61 | 62 | ||||
| 62 | matches := make([]entity.HistoryMatch, len(rows)) | 63 | matches := make([]entity.HistoryMatch, len(rows)) | ||
| 63 | - | for i, row := range rows { | 64 | + | for i := range rows { |
| 65 | + | row := rows[i] | |||
| 64 | matches[i] = entity.HistoryMatch{ | 66 | matches[i] = entity.HistoryMatch{ | ||
| 65 | Entry: historyFromRow(row), | 67 | Entry: historyFromRow(row), | ||
| 66 | Score: 1.0, // FTS5 already ranked by bm25 | 68 | Score: 1.0, // FTS5 already ranked by bm25 | ||
| @@ -79,8 +81,8 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti | |||||
| 79 | } | 81 | } | ||
| 80 | 82 | ||||
| 81 | entries := make([]*entity.HistoryEntry, len(rows)) | 83 | entries := make([]*entity.HistoryEntry, len(rows)) | ||
| 82 | - | for i, row := range rows { | 84 | + | for i := range rows { |
| 83 | - | entries[i] = historyFromRow(row) | 85 | + | entries[i] = historyFromRow(rows[i]) |
| 84 | } | 86 | } | ||
| 85 | return entries, nil | 87 | return entries, nil | ||
| 86 | } | 88 | } | ||
| @@ -118,9 +120,10 @@ func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error | |||||
| 118 | 120 | ||||
| 119 | // Handle the interface{} type for total_visits | 121 | // Handle the interface{} type for total_visits | ||
| 120 | var totalVisits int64 | 122 | var totalVisits int64 | ||
| 121 | - | if v, ok := row.TotalVisits.(int64); ok { | 123 | + | switch v := row.TotalVisits.(type) { |
| 124 | + | case int64: | |||
| 122 | totalVisits = v | 125 | totalVisits = v | ||
| 123 | - | } else if v, ok := row.TotalVisits.(float64); ok { | 126 | + | case float64: |
| 124 | totalVisits = int64(v) | 127 | totalVisits = int64(v) | ||
| 125 | } | 128 | } | ||
| 126 | 129 | ||||
M internal/infrastructure/persistence/sqlite/migrations.go +2 -2
| @@ -36,8 +36,8 @@ func RunMigrations(ctx context.Context, db *sql.DB) error { | |||||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | // Run migrations | 38 | // Run migrations | ||
| 39 | - | if err := goose.Up(db, "migrations"); err != nil { | 39 | + | if migrateErr := goose.Up(db, "migrations"); migrateErr != nil { |
| 40 | - | return fmt.Errorf("failed to run migrations: %w", err) | 40 | + | return fmt.Errorf("failed to run migrations: %w", migrateErr) |
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | // Get new version after migration | 43 | // Get new version after migration | ||
M internal/infrastructure/persistence/sqlite/zoom_repo.go +2 -1
| @@ -3,6 +3,7 @@ package sqlite | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "database/sql" | 5 | "database/sql" | ||
| 6 | + | "errors" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | "github.com/bnema/dumber/internal/domain/repository" | 9 | "github.com/bnema/dumber/internal/domain/repository" | ||
| @@ -25,7 +26,7 @@ func (r *zoomRepo) Get(ctx context.Context, domain string) (*entity.ZoomLevel, e | |||||
| 25 | 26 | ||||
| 26 | row, err := r.queries.GetZoomLevel(ctx, domain) | 27 | row, err := r.queries.GetZoomLevel(ctx, domain) | ||
| 27 | if err != nil { | 28 | if err != nil { | ||
| 28 | - | if err == sql.ErrNoRows { | 29 | + | if errors.Is(err, sql.ErrNoRows) { |
| 29 | return nil, nil | 30 | return nil, nil | ||
| 30 | } | 31 | } | ||
| 31 | return nil, err | 32 | return nil, err | ||
M internal/infrastructure/webkit/factory.go +1 -1
| @@ -74,7 +74,7 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||||
| 74 | log := logging.FromContext(ctx) | 74 | log := logging.FromContext(ctx) | ||
| 75 | 75 | ||||
| 76 | // Look up parent WebView | 76 | // Look up parent WebView | ||
| 77 | - | parent := LookupWebView(WebViewID(parentID)) | 77 | + | parent := LookupWebView(parentID) |
| 78 | if parent == nil { | 78 | if parent == nil { | ||
| 79 | return nil, fmt.Errorf("parent webview %d not found", parentID) | 79 | return nil, fmt.Errorf("parent webview %d not found", parentID) | ||
| 80 | } | 80 | } | ||
M internal/infrastructure/webkit/handlers/config.go +3 -0
| @@ -22,6 +22,9 @@ func NewConfigHandler() *ConfigHandler { | |||||
| 22 | 22 | ||||
| 23 | // Handle processes the save_config message. | 23 | // Handle processes the save_config message. | ||
| 24 | func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | 24 | func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | ||
| 25 | + | if h == nil { | |||
| 26 | + | return nil, fmt.Errorf("config handler is nil") | |||
| 27 | + | } | |||
| 25 | log := logging.FromContext(ctx).With().Str("handler", "config").Logger() | 28 | log := logging.FromContext(ctx).With().Str("handler", "config").Logger() | ||
| 26 | 29 | ||||
| 27 | var payloadCfg port.WebUIConfig | 30 | var payloadCfg port.WebUIConfig | ||
M internal/infrastructure/webkit/handlers/homepage/favorites.go +4 -4
| @@ -22,7 +22,7 @@ func NewFavoritesHandlers(favoritesUC *usecase.ManageFavoritesUseCase) *Favorite | |||||
| 22 | 22 | ||||
| 23 | // HandleList handles favorite_list messages. | 23 | // HandleList handles favorite_list messages. | ||
| 24 | func (h *FavoritesHandlers) HandleList() webkit.MessageHandler { | 24 | func (h *FavoritesHandlers) HandleList() webkit.MessageHandler { | ||
| 25 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 25 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 26 | log := logging.FromContext(ctx) | 26 | log := logging.FromContext(ctx) | ||
| 27 | 27 | ||||
| 28 | requestID := ParseRequestID(payload) | 28 | requestID := ParseRequestID(payload) | ||
| @@ -49,7 +49,7 @@ type setShortcutRequest struct { | |||||
| 49 | 49 | ||||
| 50 | // HandleSetShortcut handles favorite_set_shortcut messages. | 50 | // HandleSetShortcut handles favorite_set_shortcut messages. | ||
| 51 | func (h *FavoritesHandlers) HandleSetShortcut() webkit.MessageHandler { | 51 | func (h *FavoritesHandlers) HandleSetShortcut() webkit.MessageHandler { | ||
| 52 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 52 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 53 | log := logging.FromContext(ctx) | 53 | log := logging.FromContext(ctx) | ||
| 54 | 54 | ||||
| 55 | var req setShortcutRequest | 55 | var req setShortcutRequest | ||
| @@ -78,7 +78,7 @@ type getByShortcutRequest struct { | |||||
| 78 | 78 | ||||
| 79 | // HandleGetByShortcut handles favorite_get_by_shortcut messages. | 79 | // HandleGetByShortcut handles favorite_get_by_shortcut messages. | ||
| 80 | func (h *FavoritesHandlers) HandleGetByShortcut() webkit.MessageHandler { | 80 | func (h *FavoritesHandlers) HandleGetByShortcut() webkit.MessageHandler { | ||
| 81 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 81 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 82 | log := logging.FromContext(ctx) | 82 | log := logging.FromContext(ctx) | ||
| 83 | 83 | ||||
| 84 | var req getByShortcutRequest | 84 | var req getByShortcutRequest | ||
| @@ -109,7 +109,7 @@ type setFolderRequest struct { | |||||
| 109 | 109 | ||||
| 110 | // HandleSetFolder handles favorite_set_folder messages. | 110 | // HandleSetFolder handles favorite_set_folder messages. | ||
| 111 | func (h *FavoritesHandlers) HandleSetFolder() webkit.MessageHandler { | 111 | func (h *FavoritesHandlers) HandleSetFolder() webkit.MessageHandler { | ||
| 112 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 112 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 113 | log := logging.FromContext(ctx) | 113 | log := logging.FromContext(ctx) | ||
| 114 | 114 | ||||
| 115 | var req setFolderRequest | 115 | var req setFolderRequest | ||
M internal/infrastructure/webkit/handlers/homepage/folders.go +4 -4
| @@ -22,7 +22,7 @@ func NewFolderHandlers(favoritesUC *usecase.ManageFavoritesUseCase) *FolderHandl | |||||
| 22 | 22 | ||||
| 23 | // HandleList handles folder_list messages. | 23 | // HandleList handles folder_list messages. | ||
| 24 | func (h *FolderHandlers) HandleList() webkit.MessageHandler { | 24 | func (h *FolderHandlers) HandleList() webkit.MessageHandler { | ||
| 25 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 25 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 26 | log := logging.FromContext(ctx) | 26 | log := logging.FromContext(ctx) | ||
| 27 | 27 | ||||
| 28 | requestID := ParseRequestID(payload) | 28 | requestID := ParseRequestID(payload) | ||
| @@ -49,7 +49,7 @@ type createFolderRequest struct { | |||||
| 49 | 49 | ||||
| 50 | // HandleCreate handles folder_create messages. | 50 | // HandleCreate handles folder_create messages. | ||
| 51 | func (h *FolderHandlers) HandleCreate() webkit.MessageHandler { | 51 | func (h *FolderHandlers) HandleCreate() webkit.MessageHandler { | ||
| 52 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 52 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 53 | log := logging.FromContext(ctx) | 53 | log := logging.FromContext(ctx) | ||
| 54 | 54 | ||||
| 55 | var req createFolderRequest | 55 | var req createFolderRequest | ||
| @@ -84,7 +84,7 @@ type deleteFolderRequest struct { | |||||
| 84 | 84 | ||||
| 85 | // HandleDelete handles folder_delete messages. | 85 | // HandleDelete handles folder_delete messages. | ||
| 86 | func (h *FolderHandlers) HandleDelete() webkit.MessageHandler { | 86 | func (h *FolderHandlers) HandleDelete() webkit.MessageHandler { | ||
| 87 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 87 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 88 | log := logging.FromContext(ctx) | 88 | log := logging.FromContext(ctx) | ||
| 89 | 89 | ||||
| 90 | var req deleteFolderRequest | 90 | var req deleteFolderRequest | ||
| @@ -116,7 +116,7 @@ type updateFolderRequest struct { | |||||
| 116 | // HandleUpdate handles folder_update messages. | 116 | // HandleUpdate handles folder_update messages. | ||
| 117 | // NOTE: This requires UpdateFolder() method to be added to ManageFavoritesUseCase. | 117 | // NOTE: This requires UpdateFolder() method to be added to ManageFavoritesUseCase. | ||
| 118 | func (h *FolderHandlers) HandleUpdate() webkit.MessageHandler { | 118 | func (h *FolderHandlers) HandleUpdate() webkit.MessageHandler { | ||
| 119 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 119 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 120 | log := logging.FromContext(ctx) | 120 | log := logging.FromContext(ctx) | ||
| 121 | 121 | ||||
| 122 | var req updateFolderRequest | 122 | var req updateFolderRequest | ||
M internal/infrastructure/webkit/handlers/homepage/history.go +8 -8
| @@ -30,7 +30,7 @@ type timelineRequest struct { | |||||
| 30 | 30 | ||||
| 31 | // HandleTimeline handles history_timeline messages. | 31 | // HandleTimeline handles history_timeline messages. | ||
| 32 | func (h *HistoryHandlers) HandleTimeline() webkit.MessageHandler { | 32 | func (h *HistoryHandlers) HandleTimeline() webkit.MessageHandler { | ||
| 33 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 33 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 34 | log := logging.FromContext(ctx) | 34 | log := logging.FromContext(ctx) | ||
| 35 | 35 | ||||
| 36 | var req timelineRequest | 36 | var req timelineRequest | ||
| @@ -62,7 +62,7 @@ type searchRequest struct { | |||||
| 62 | 62 | ||||
| 63 | // HandleSearchFTS handles history_search_fts messages. | 63 | // HandleSearchFTS handles history_search_fts messages. | ||
| 64 | func (h *HistoryHandlers) HandleSearchFTS() webkit.MessageHandler { | 64 | func (h *HistoryHandlers) HandleSearchFTS() webkit.MessageHandler { | ||
| 65 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 65 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 66 | log := logging.FromContext(ctx) | 66 | log := logging.FromContext(ctx) | ||
| 67 | 67 | ||||
| 68 | var req searchRequest | 68 | var req searchRequest | ||
| @@ -101,7 +101,7 @@ type deleteEntryRequest struct { | |||||
| 101 | 101 | ||||
| 102 | // HandleDeleteEntry handles history_delete_entry messages. | 102 | // HandleDeleteEntry handles history_delete_entry messages. | ||
| 103 | func (h *HistoryHandlers) HandleDeleteEntry() webkit.MessageHandler { | 103 | func (h *HistoryHandlers) HandleDeleteEntry() webkit.MessageHandler { | ||
| 104 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 104 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 105 | log := logging.FromContext(ctx) | 105 | log := logging.FromContext(ctx) | ||
| 106 | 106 | ||||
| 107 | var req deleteEntryRequest | 107 | var req deleteEntryRequest | ||
| @@ -130,7 +130,7 @@ type deleteRangeRequest struct { | |||||
| 130 | 130 | ||||
| 131 | // HandleDeleteRange handles history_delete_range messages. | 131 | // HandleDeleteRange handles history_delete_range messages. | ||
| 132 | func (h *HistoryHandlers) HandleDeleteRange() webkit.MessageHandler { | 132 | func (h *HistoryHandlers) HandleDeleteRange() webkit.MessageHandler { | ||
| 133 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 133 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 134 | log := logging.FromContext(ctx) | 134 | log := logging.FromContext(ctx) | ||
| 135 | 135 | ||||
| 136 | var req deleteRangeRequest | 136 | var req deleteRangeRequest | ||
| @@ -161,7 +161,7 @@ func (h *HistoryHandlers) HandleDeleteRange() webkit.MessageHandler { | |||||
| 161 | 161 | ||||
| 162 | // HandleClearAll handles history_clear_all messages. | 162 | // HandleClearAll handles history_clear_all messages. | ||
| 163 | func (h *HistoryHandlers) HandleClearAll() webkit.MessageHandler { | 163 | func (h *HistoryHandlers) HandleClearAll() webkit.MessageHandler { | ||
| 164 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 164 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 165 | log := logging.FromContext(ctx) | 165 | log := logging.FromContext(ctx) | ||
| 166 | 166 | ||||
| 167 | requestID := ParseRequestID(payload) | 167 | requestID := ParseRequestID(payload) | ||
| @@ -200,7 +200,7 @@ func rangeToTime(r string) time.Time { | |||||
| 200 | 200 | ||||
| 201 | // HandleAnalytics handles history_analytics messages. | 201 | // HandleAnalytics handles history_analytics messages. | ||
| 202 | func (h *HistoryHandlers) HandleAnalytics() webkit.MessageHandler { | 202 | func (h *HistoryHandlers) HandleAnalytics() webkit.MessageHandler { | ||
| 203 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 203 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 204 | log := logging.FromContext(ctx) | 204 | log := logging.FromContext(ctx) | ||
| 205 | 205 | ||||
| 206 | requestID := ParseRequestID(payload) | 206 | requestID := ParseRequestID(payload) | ||
| @@ -226,7 +226,7 @@ type domainStatsRequest struct { | |||||
| 226 | 226 | ||||
| 227 | // HandleDomainStats handles history_domain_stats messages. | 227 | // HandleDomainStats handles history_domain_stats messages. | ||
| 228 | func (h *HistoryHandlers) HandleDomainStats() webkit.MessageHandler { | 228 | func (h *HistoryHandlers) HandleDomainStats() webkit.MessageHandler { | ||
| 229 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 229 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 230 | log := logging.FromContext(ctx) | 230 | log := logging.FromContext(ctx) | ||
| 231 | 231 | ||||
| 232 | var req domainStatsRequest | 232 | var req domainStatsRequest | ||
| @@ -256,7 +256,7 @@ type deleteDomainRequest struct { | |||||
| 256 | 256 | ||||
| 257 | // HandleDeleteDomain handles history_delete_domain messages. | 257 | // HandleDeleteDomain handles history_delete_domain messages. | ||
| 258 | func (h *HistoryHandlers) HandleDeleteDomain() webkit.MessageHandler { | 258 | func (h *HistoryHandlers) HandleDeleteDomain() webkit.MessageHandler { | ||
| 259 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 259 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 260 | log := logging.FromContext(ctx) | 260 | log := logging.FromContext(ctx) | ||
| 261 | 261 | ||||
| 262 | var req deleteDomainRequest | 262 | var req deleteDomainRequest | ||
M internal/infrastructure/webkit/handlers/homepage/tags.go +13 -28
| @@ -22,7 +22,7 @@ func NewTagHandlers(favoritesUC *usecase.ManageFavoritesUseCase) *TagHandlers { | |||||
| 22 | 22 | ||||
| 23 | // HandleList handles tag_list messages. | 23 | // HandleList handles tag_list messages. | ||
| 24 | func (h *TagHandlers) HandleList() webkit.MessageHandler { | 24 | func (h *TagHandlers) HandleList() webkit.MessageHandler { | ||
| 25 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 25 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 26 | log := logging.FromContext(ctx) | 26 | log := logging.FromContext(ctx) | ||
| 27 | 27 | ||||
| 28 | requestID := ParseRequestID(payload) | 28 | requestID := ParseRequestID(payload) | ||
| @@ -49,7 +49,7 @@ type createTagRequest struct { | |||||
| 49 | 49 | ||||
| 50 | // HandleCreate handles tag_create messages. | 50 | // HandleCreate handles tag_create messages. | ||
| 51 | func (h *TagHandlers) HandleCreate() webkit.MessageHandler { | 51 | func (h *TagHandlers) HandleCreate() webkit.MessageHandler { | ||
| 52 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 52 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 53 | log := logging.FromContext(ctx) | 53 | log := logging.FromContext(ctx) | ||
| 54 | 54 | ||||
| 55 | var req createTagRequest | 55 | var req createTagRequest | ||
| @@ -84,7 +84,7 @@ type deleteTagRequest struct { | |||||
| 84 | 84 | ||||
| 85 | // HandleDelete handles tag_delete messages. | 85 | // HandleDelete handles tag_delete messages. | ||
| 86 | func (h *TagHandlers) HandleDelete() webkit.MessageHandler { | 86 | func (h *TagHandlers) HandleDelete() webkit.MessageHandler { | ||
| 87 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 87 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 88 | log := logging.FromContext(ctx) | 88 | log := logging.FromContext(ctx) | ||
| 89 | 89 | ||||
| 90 | var req deleteTagRequest | 90 | var req deleteTagRequest | ||
| @@ -116,7 +116,7 @@ type updateTagRequest struct { | |||||
| 116 | // HandleUpdate handles tag_update messages. | 116 | // HandleUpdate handles tag_update messages. | ||
| 117 | // NOTE: This requires UpdateTag() method to be added to ManageFavoritesUseCase. | 117 | // NOTE: This requires UpdateTag() method to be added to ManageFavoritesUseCase. | ||
| 118 | func (h *TagHandlers) HandleUpdate() webkit.MessageHandler { | 118 | func (h *TagHandlers) HandleUpdate() webkit.MessageHandler { | ||
| 119 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 119 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { |
| 120 | log := logging.FromContext(ctx) | 120 | log := logging.FromContext(ctx) | ||
| 121 | 121 | ||||
| 122 | var req updateTagRequest | 122 | var req updateTagRequest | ||
| @@ -155,34 +155,19 @@ type tagAssignRequest struct { | |||||
| 155 | 155 | ||||
| 156 | // HandleAssign handles tag_assign messages. | 156 | // HandleAssign handles tag_assign messages. | ||
| 157 | func (h *TagHandlers) HandleAssign() webkit.MessageHandler { | 157 | func (h *TagHandlers) HandleAssign() webkit.MessageHandler { | ||
| 158 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 158 | + | return handleTagAssign("tag_assign", h.favoritesUC.TagFavorite) |
| 159 | - | log := logging.FromContext(ctx) | |||
| 160 | - | ||||
| 161 | - | var req tagAssignRequest | |||
| 162 | - | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 163 | - | return NewErrorResponse("", err), nil | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | - | log.Debug(). | |||
| 167 | - | Str("request_id", req.RequestID). | |||
| 168 | - | Int64("favorite_id", req.FavoriteID). | |||
| 169 | - | Int64("tag_id", req.TagID). | |||
| 170 | - | Msg("handling tag_assign") | |||
| 171 | - | ||||
| 172 | - | if err := h.favoritesUC.TagFavorite(ctx, entity.FavoriteID(req.FavoriteID), entity.TagID(req.TagID)); err != nil { | |||
| 173 | - | return NewErrorResponse(req.RequestID, err), nil | |||
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | return NewSuccessResponse(req.RequestID, nil), nil | |||
| 177 | - | }) | |||
| 178 | } | 159 | } | ||
| 179 | 160 | ||||
| 180 | // HandleRemove handles tag_remove messages. | 161 | // HandleRemove handles tag_remove messages. | ||
| 181 | func (h *TagHandlers) HandleRemove() webkit.MessageHandler { | 162 | func (h *TagHandlers) HandleRemove() webkit.MessageHandler { | ||
| 182 | - | return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) { | 163 | + | return handleTagAssign("tag_remove", h.favoritesUC.UntagFavorite) |
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func handleTagAssign(action string, op func(context.Context, entity.FavoriteID, entity.TagID) error) webkit.MessageHandler { | |||
| 167 | + | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |||
| 183 | log := logging.FromContext(ctx) | 168 | log := logging.FromContext(ctx) | ||
| 184 | 169 | ||||
| 185 | - | var req tagAssignRequest // same structure as assign | 170 | + | var req tagAssignRequest |
| 186 | if err := json.Unmarshal(payload, &req); err != nil { | 171 | if err := json.Unmarshal(payload, &req); err != nil { | ||
| 187 | return NewErrorResponse("", err), nil | 172 | return NewErrorResponse("", err), nil | ||
| 188 | } | 173 | } | ||
| @@ -191,9 +176,9 @@ func (h *TagHandlers) HandleRemove() webkit.MessageHandler { | |||||
| 191 | Str("request_id", req.RequestID). | 176 | Str("request_id", req.RequestID). | ||
| 192 | Int64("favorite_id", req.FavoriteID). | 177 | Int64("favorite_id", req.FavoriteID). | ||
| 193 | Int64("tag_id", req.TagID). | 178 | Int64("tag_id", req.TagID). | ||
| 194 | - | Msg("handling tag_remove") | 179 | + | Msg("handling " + action) |
| 195 | 180 | ||||
| 196 | - | if err := h.favoritesUC.UntagFavorite(ctx, entity.FavoriteID(req.FavoriteID), entity.TagID(req.TagID)); err != nil { | 181 | + | if err := op(ctx, entity.FavoriteID(req.FavoriteID), entity.TagID(req.TagID)); err != nil { |
| 197 | return NewErrorResponse(req.RequestID, err), nil | 182 | return NewErrorResponse(req.RequestID, err), nil | ||
| 198 | } | 183 | } | ||
| 199 | 184 | ||||
M internal/infrastructure/webkit/messaging.go +8 -2
| @@ -106,7 +106,7 @@ func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCal | |||||
| 106 | // It registers the script message handler in the MAIN world (not isolated). | 106 | // It registers the script message handler in the MAIN world (not isolated). | ||
| 107 | // WebKit's messageHandlers is only available in main world. | 107 | // WebKit's messageHandlers is only available in main world. | ||
| 108 | // The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler. | 108 | // The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler. | ||
| 109 | - | func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, worldName string) (uint32, error) { | 109 | + | func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint32, error) { |
| 110 | log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger() | 110 | log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger() | ||
| 111 | 111 | ||||
| 112 | log.Debug().Msg("SetupMessageHandler called") | 112 | log.Debug().Msg("SetupMessageHandler called") | ||
| @@ -255,7 +255,13 @@ func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewI | |||||
| 255 | return fmt.Errorf("marshal callback payload: %w", err) | 255 | return fmt.Errorf("marshal callback payload: %w", err) | ||
| 256 | } | 256 | } | ||
| 257 | 257 | ||||
| 258 | - | script := fmt.Sprintf(`(function(){try{if(window.%[1]s){window.%[1]s(%[2]s);}else{console.warn("dumber callback missing: %[1]s");}}catch(e){console.error("dumber callback %[1]s failed", e);}})();`, callback, string(data)) | 258 | + | script := fmt.Sprintf( |
| 259 | + | `(function(){try{if(window.%[1]s){window.%[1]s(%[2]s);}`+ | |||
| 260 | + | `else{console.warn("dumber callback missing: %[1]s");}}`+ | |||
| 261 | + | `catch(e){console.error("dumber callback %[1]s failed", e);}})();`, | |||
| 262 | + | callback, | |||
| 263 | + | string(data), | |||
| 264 | + | ) | |||
| 259 | 265 | ||||
| 260 | wv.RunJavaScript(ctx, script, world) | 266 | wv.RunJavaScript(ctx, script, world) | ||
| 261 | return nil | 267 | return nil | ||
M internal/infrastructure/webkit/pool.go +8 -2
| @@ -54,7 +54,14 @@ type WebViewPool struct { | |||||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | // NewWebViewPool creates a new WebView pool. | 56 | // NewWebViewPool creates a new WebView pool. | ||
| 57 | - | func NewWebViewPool(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, cfg PoolConfig, injector *ContentInjector, router *MessageRouter) *WebViewPool { | 57 | + | func NewWebViewPool( |
| 58 | + | ctx context.Context, | |||
| 59 | + | wkCtx *WebKitContext, | |||
| 60 | + | settings *SettingsManager, | |||
| 61 | + | cfg PoolConfig, | |||
| 62 | + | injector *ContentInjector, | |||
| 63 | + | router *MessageRouter, | |||
| 64 | + | ) *WebViewPool { | |||
| 58 | if cfg.MaxSize <= 0 { | 65 | if cfg.MaxSize <= 0 { | ||
| 59 | cfg.MaxSize = 8 | 66 | cfg.MaxSize = 8 | ||
| 60 | } | 67 | } | ||
| @@ -203,7 +210,6 @@ func (p *WebViewPool) prewarmSync(ctx context.Context, count int) { | |||||
| 203 | default: | 210 | default: | ||
| 204 | // Pool full, destroy | 211 | // Pool full, destroy | ||
| 205 | wv.Destroy() | 212 | wv.Destroy() | ||
| 206 | - | break | |||
| 207 | } | 213 | } | ||
| 208 | 214 | ||||
| 209 | // Small delay between creations to avoid overwhelming the system | 215 | // Small delay between creations to avoid overwhelming the system | ||
M internal/infrastructure/webkit/scheme_handler.go +62 -88
| @@ -66,6 +66,23 @@ type DumbSchemeHandler struct { | |||||
| 66 | mu sync.RWMutex | 66 | mu sync.RWMutex | ||
| 67 | } | 67 | } | ||
| 68 | 68 | ||||
| 69 | + | type configAppearancePayload struct { | |||
| 70 | + | SansFont string `json:"sans_font"` | |||
| 71 | + | SerifFont string `json:"serif_font"` | |||
| 72 | + | MonospaceFont string `json:"monospace_font"` | |||
| 73 | + | DefaultFontSize int `json:"default_font_size"` | |||
| 74 | + | ColorScheme string `json:"color_scheme"` | |||
| 75 | + | LightPalette config.ColorPalette `json:"light_palette"` | |||
| 76 | + | DarkPalette config.ColorPalette `json:"dark_palette"` | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | type configPayload struct { | |||
| 80 | + | Appearance configAppearancePayload `json:"appearance"` | |||
| 81 | + | DefaultUIScale float64 `json:"default_ui_scale"` | |||
| 82 | + | DefaultSearchEngine string `json:"default_search_engine"` | |||
| 83 | + | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | |||
| 84 | + | } | |||
| 85 | + | ||||
| 69 | // NewDumbSchemeHandler creates a new handler for the dumb:// scheme. | 86 | // NewDumbSchemeHandler creates a new handler for the dumb:// scheme. | ||
| 70 | func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | 87 | func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler { | ||
| 71 | log := logging.FromContext(ctx) | 88 | log := logging.FromContext(ctx) | ||
| @@ -93,7 +110,7 @@ func (h *DumbSchemeHandler) SetAssets(assets embed.FS) { | |||||
| 93 | // registerDefaults sets up default page handlers. | 110 | // registerDefaults sets up default page handlers. | ||
| 94 | func (h *DumbSchemeHandler) registerDefaults() { | 111 | func (h *DumbSchemeHandler) registerDefaults() { | ||
| 95 | // Error page (static fallback) | 112 | // Error page (static fallback) | ||
| 96 | - | h.RegisterPage("/error", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse { | 113 | + | h.RegisterPage("/error", PageHandlerFunc(func(_ *SchemeRequest) *SchemeResponse { |
| 97 | return &SchemeResponse{ | 114 | return &SchemeResponse{ | ||
| 98 | Data: []byte(errorPageHTML), | 115 | Data: []byte(errorPageHTML), | ||
| 99 | ContentType: "text/html", | 116 | ContentType: "text/html", | ||
| @@ -107,46 +124,7 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 107 | return nil | 124 | return nil | ||
| 108 | } | 125 | } | ||
| 109 | 126 | ||||
| 110 | - | cfg := config.Get() | 127 | + | return buildConfigResponse(config.Get()) |
| 111 | - | resp := struct { | |||
| 112 | - | Appearance struct { | |||
| 113 | - | SansFont string `json:"sans_font"` | |||
| 114 | - | SerifFont string `json:"serif_font"` | |||
| 115 | - | MonospaceFont string `json:"monospace_font"` | |||
| 116 | - | DefaultFontSize int `json:"default_font_size"` | |||
| 117 | - | ColorScheme string `json:"color_scheme"` | |||
| 118 | - | LightPalette config.ColorPalette `json:"light_palette"` | |||
| 119 | - | DarkPalette config.ColorPalette `json:"dark_palette"` | |||
| 120 | - | } `json:"appearance"` | |||
| 121 | - | DefaultUIScale float64 `json:"default_ui_scale"` | |||
| 122 | - | DefaultSearchEngine string `json:"default_search_engine"` | |||
| 123 | - | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | |||
| 124 | - | }{ | |||
| 125 | - | DefaultUIScale: cfg.DefaultUIScale, | |||
| 126 | - | DefaultSearchEngine: cfg.DefaultSearchEngine, | |||
| 127 | - | SearchShortcuts: cfg.SearchShortcuts, | |||
| 128 | - | } | |||
| 129 | - | resp.Appearance.SansFont = cfg.Appearance.SansFont | |||
| 130 | - | resp.Appearance.SerifFont = cfg.Appearance.SerifFont | |||
| 131 | - | resp.Appearance.MonospaceFont = cfg.Appearance.MonospaceFont | |||
| 132 | - | resp.Appearance.DefaultFontSize = cfg.Appearance.DefaultFontSize | |||
| 133 | - | resp.Appearance.ColorScheme = cfg.Appearance.ColorScheme | |||
| 134 | - | resp.Appearance.LightPalette = cfg.Appearance.LightPalette | |||
| 135 | - | resp.Appearance.DarkPalette = cfg.Appearance.DarkPalette | |||
| 136 | - | ||||
| 137 | - | data, err := json.Marshal(resp) | |||
| 138 | - | if err != nil { | |||
| 139 | - | return &SchemeResponse{ | |||
| 140 | - | Data: []byte(fmt.Sprintf(`{"error": "%s"}`, err)), | |||
| 141 | - | ContentType: "application/json", | |||
| 142 | - | StatusCode: http.StatusInternalServerError, | |||
| 143 | - | } | |||
| 144 | - | } | |||
| 145 | - | return &SchemeResponse{ | |||
| 146 | - | Data: data, | |||
| 147 | - | ContentType: "application/json", | |||
| 148 | - | StatusCode: http.StatusOK, | |||
| 149 | - | } | |||
| 150 | })) | 128 | })) | ||
| 151 | 129 | ||||
| 152 | // API: Get default config (used by Reset Defaults in dumb://config) | 130 | // API: Get default config (used by Reset Defaults in dumb://config) | ||
| @@ -155,47 +133,40 @@ func (h *DumbSchemeHandler) registerDefaults() { | |||||
| 155 | return nil | 133 | return nil | ||
| 156 | } | 134 | } | ||
| 157 | 135 | ||||
| 158 | - | defaults := config.DefaultConfig() | 136 | + | return buildConfigResponse(config.DefaultConfig()) |
| 159 | - | resp := struct { | 137 | + | })) |
| 160 | - | Appearance struct { | 138 | + | } |
| 161 | - | SansFont string `json:"sans_font"` | 139 | + | |
| 162 | - | SerifFont string `json:"serif_font"` | 140 | + | func buildConfigResponse(cfg *config.Config) *SchemeResponse { |
| 163 | - | MonospaceFont string `json:"monospace_font"` | 141 | + | resp := configPayload{ |
| 164 | - | DefaultFontSize int `json:"default_font_size"` | 142 | + | DefaultUIScale: cfg.DefaultUIScale, |
| 165 | - | ColorScheme string `json:"color_scheme"` | 143 | + | DefaultSearchEngine: cfg.DefaultSearchEngine, |
| 166 | - | LightPalette config.ColorPalette `json:"light_palette"` | 144 | + | SearchShortcuts: cfg.SearchShortcuts, |
| 167 | - | DarkPalette config.ColorPalette `json:"dark_palette"` | 145 | + | Appearance: configAppearancePayload{ |
| 168 | - | } `json:"appearance"` | 146 | + | SansFont: cfg.Appearance.SansFont, |
| 169 | - | DefaultUIScale float64 `json:"default_ui_scale"` | 147 | + | SerifFont: cfg.Appearance.SerifFont, |
| 170 | - | DefaultSearchEngine string `json:"default_search_engine"` | 148 | + | MonospaceFont: cfg.Appearance.MonospaceFont, |
| 171 | - | SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"` | 149 | + | DefaultFontSize: cfg.Appearance.DefaultFontSize, |
| 172 | - | }{ | 150 | + | ColorScheme: cfg.Appearance.ColorScheme, |
| 173 | - | DefaultUIScale: defaults.DefaultUIScale, | 151 | + | LightPalette: cfg.Appearance.LightPalette, |
| 174 | - | DefaultSearchEngine: defaults.DefaultSearchEngine, | 152 | + | DarkPalette: cfg.Appearance.DarkPalette, |
| 175 | - | SearchShortcuts: defaults.SearchShortcuts, | 153 | + | }, |
| 176 | - | } | 154 | + | } |
| 177 | - | resp.Appearance.SansFont = defaults.Appearance.SansFont | 155 | + | |
| 178 | - | resp.Appearance.SerifFont = defaults.Appearance.SerifFont | 156 | + | data, err := json.Marshal(resp) |
| 179 | - | resp.Appearance.MonospaceFont = defaults.Appearance.MonospaceFont | 157 | + | if err != nil { |
| 180 | - | resp.Appearance.DefaultFontSize = defaults.Appearance.DefaultFontSize | |||
| 181 | - | resp.Appearance.ColorScheme = defaults.Appearance.ColorScheme | |||
| 182 | - | resp.Appearance.LightPalette = defaults.Appearance.LightPalette | |||
| 183 | - | resp.Appearance.DarkPalette = defaults.Appearance.DarkPalette | |||
| 184 | - | ||||
| 185 | - | data, err := json.Marshal(resp) | |||
| 186 | - | if err != nil { | |||
| 187 | - | return &SchemeResponse{ | |||
| 188 | - | Data: []byte(fmt.Sprintf(`{"error": "%s"}`, err)), | |||
| 189 | - | ContentType: "application/json", | |||
| 190 | - | StatusCode: http.StatusInternalServerError, | |||
| 191 | - | } | |||
| 192 | - | } | |||
| 193 | return &SchemeResponse{ | 158 | return &SchemeResponse{ | ||
| 194 | - | Data: data, | 159 | + | Data: []byte(fmt.Sprintf(`{"error": %q}`, err)), |
| 195 | ContentType: "application/json", | 160 | ContentType: "application/json", | ||
| 196 | - | StatusCode: http.StatusOK, | 161 | + | StatusCode: http.StatusInternalServerError, |
| 197 | } | 162 | } | ||
| 198 | - | })) | 163 | + | } |
| 164 | + | ||||
| 165 | + | return &SchemeResponse{ | |||
| 166 | + | Data: data, | |||
| 167 | + | ContentType: "application/json", | |||
| 168 | + | StatusCode: http.StatusOK, | |||
| 169 | + | } | |||
| 199 | } | 170 | } | ||
| 200 | 171 | ||||
| 201 | // RegisterPage registers a handler for a specific path. | 172 | // RegisterPage registers a handler for a specific path. | ||
| @@ -301,28 +272,28 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 301 | 272 | ||||
| 302 | var relPath string | 273 | var relPath string | ||
| 303 | switch { | 274 | switch { | ||
| 304 | - | // dumb://home or dumb://home/ → index.html | 275 | + | // Home root maps to index.html. |
| 305 | case host == HomePath && (path == "" || path == "/"): | 276 | case host == HomePath && (path == "" || path == "/"): | ||
| 306 | relPath = IndexHTML | 277 | relPath = IndexHTML | ||
| 307 | - | // dumb://home/<asset> → serve asset | 278 | + | // Home asset paths map directly to assets. |
| 308 | case host == HomePath && path != "": | 279 | case host == HomePath && path != "": | ||
| 309 | relPath = path | 280 | relPath = path | ||
| 310 | - | // dumb://config or dumb://config/ → config.html | 281 | + | // Config root maps to config.html. |
| 311 | case host == ConfigPath && (path == "" || path == "/"): | 282 | case host == ConfigPath && (path == "" || path == "/"): | ||
| 312 | relPath = "config.html" | 283 | relPath = "config.html" | ||
| 313 | - | // dumb://config/<asset> → serve asset | 284 | + | // Config asset paths map directly to assets. |
| 314 | case host == ConfigPath && path != "": | 285 | case host == ConfigPath && path != "": | ||
| 315 | relPath = path | 286 | relPath = path | ||
| 316 | - | // dumb://error or dumb://error/ → error.html | 287 | + | // Error root maps to error.html. |
| 317 | case host == ErrorPath && (path == "" || path == "/"): | 288 | case host == ErrorPath && (path == "" || path == "/"): | ||
| 318 | relPath = "error.html" | 289 | relPath = "error.html" | ||
| 319 | - | // dumb://error/<asset> → serve asset | 290 | + | // Error asset paths map directly to assets. |
| 320 | case host == ErrorPath && path != "": | 291 | case host == ErrorPath && path != "": | ||
| 321 | relPath = path | 292 | relPath = path | ||
| 322 | - | // dumb:home (opaque form) → index.html | 293 | + | // Opaque home form maps to index.html. |
| 323 | case u.Opaque == HomePath: | 294 | case u.Opaque == HomePath: | ||
| 324 | relPath = IndexHTML | 295 | relPath = IndexHTML | ||
| 325 | - | // dumb:error (opaque form) → error.html | 296 | + | // Opaque error form maps to error.html. |
| 326 | case u.Opaque == ErrorPath: | 297 | case u.Opaque == ErrorPath: | ||
| 327 | relPath = "error.html" | 298 | relPath = "error.html" | ||
| 328 | default: | 299 | default: | ||
| @@ -354,6 +325,9 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 354 | 325 | ||||
| 355 | // getMimeType determines the MIME type for a given file path. | 326 | // getMimeType determines the MIME type for a given file path. | ||
| 356 | func (h *DumbSchemeHandler) getMimeType(filename string) string { | 327 | func (h *DumbSchemeHandler) getMimeType(filename string) string { | ||
| 328 | + | if h == nil { | |||
| 329 | + | return "application/octet-stream" | |||
| 330 | + | } | |||
| 357 | ext := strings.ToLower(filepath.Ext(filename)) | 331 | ext := strings.ToLower(filepath.Ext(filename)) | ||
| 358 | 332 | ||||
| 359 | // Try standard mime type first | 333 | // Try standard mime type first | ||
| @@ -451,7 +425,7 @@ func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) { | |||||
| 451 | return | 425 | return | ||
| 452 | } | 426 | } | ||
| 453 | 427 | ||||
| 454 | - | callback := webkit.URISchemeRequestCallback(func(reqPtr, userData uintptr) { | 428 | + | callback := webkit.URISchemeRequestCallback(func(reqPtr, _ uintptr) { |
| 455 | h.HandleRequest(reqPtr) | 429 | h.HandleRequest(reqPtr) | ||
| 456 | }) | 430 | }) | ||
| 457 | 431 | ||||
M internal/infrastructure/webkit/settings.go +42 -26
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "github.com/bnema/dumber/internal/infrastructure/config" | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 8 | "github.com/bnema/dumber/internal/logging" | 8 | "github.com/bnema/dumber/internal/logging" | ||
| 9 | "github.com/bnema/puregotk-webkit/webkit" | 9 | "github.com/bnema/puregotk-webkit/webkit" | ||
| 10 | + | "github.com/rs/zerolog" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | // SettingsManager creates and manages WebKit Settings instances from config. | 13 | // SettingsManager creates and manages WebKit Settings instances from config. | ||
| @@ -44,12 +45,34 @@ func (sm *SettingsManager) CreateSettings(ctx context.Context) *webkit.Settings | |||||
| 44 | 45 | ||||
| 45 | // applySettings applies configuration to a webkit.Settings instance. | 46 | // applySettings applies configuration to a webkit.Settings instance. | ||
| 46 | func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.Settings, cfg *config.Config) { | 47 | func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.Settings, cfg *config.Config) { | ||
| 48 | + | if sm == nil { | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 47 | log := logging.FromContext(ctx) | 51 | log := logging.FromContext(ctx) | ||
| 48 | - | // JavaScript settings | 52 | + | applyJavaScriptSettings(settings) |
| 53 | + | applyFontSettings(settings, cfg) | |||
| 54 | + | applyRenderingSettings(settings, cfg) | |||
| 55 | + | applyDebugSettings(settings, cfg) | |||
| 56 | + | applyBrowsingSettings(settings) | |||
| 57 | + | applyMediaSettings(settings, cfg, log) | |||
| 58 | + | applyStorageSettings(settings) | |||
| 59 | + | applyUISettings(settings) | |||
| 60 | + | applyCanvasSettings(settings) | |||
| 61 | + | applyWebRTCSettings(settings) | |||
| 62 | + | ||||
| 63 | + | log.Debug(). | |||
| 64 | + | Str("sans_font", cfg.Appearance.SansFont). | |||
| 65 | + | Str("rendering_mode", string(cfg.RenderingMode)). | |||
| 66 | + | Bool("developer_extras", cfg.Debug.EnableDevTools). | |||
| 67 | + | Msg("settings applied") | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func applyJavaScriptSettings(settings *webkit.Settings) { | |||
| 49 | settings.SetEnableJavascript(true) | 71 | settings.SetEnableJavascript(true) | ||
| 50 | settings.SetEnableJavascriptMarkup(true) | 72 | settings.SetEnableJavascriptMarkup(true) | ||
| 73 | + | } | |||
| 51 | 74 | ||||
| 52 | - | // Font settings from config | 75 | + | func applyFontSettings(settings *webkit.Settings, cfg *config.Config) { |
| 53 | if cfg.Appearance.SansFont != "" { | 76 | if cfg.Appearance.SansFont != "" { | ||
| 54 | settings.SetDefaultFontFamily(cfg.Appearance.SansFont) | 77 | settings.SetDefaultFontFamily(cfg.Appearance.SansFont) | ||
| 55 | settings.SetSansSerifFontFamily(cfg.Appearance.SansFont) | 78 | settings.SetSansSerifFontFamily(cfg.Appearance.SansFont) | ||
| @@ -63,29 +86,31 @@ func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.S | |||||
| 63 | if cfg.Appearance.DefaultFontSize > 0 { | 86 | if cfg.Appearance.DefaultFontSize > 0 { | ||
| 64 | settings.SetDefaultFontSize(uint32(cfg.Appearance.DefaultFontSize)) | 87 | settings.SetDefaultFontSize(uint32(cfg.Appearance.DefaultFontSize)) | ||
| 65 | } | 88 | } | ||
| 89 | + | } | |||
| 66 | 90 | ||||
| 67 | - | // Hardware acceleration based on rendering mode | 91 | + | func applyRenderingSettings(settings *webkit.Settings, cfg *config.Config) { |
| 68 | switch cfg.RenderingMode { | 92 | switch cfg.RenderingMode { | ||
| 69 | case config.RenderingModeGPU: | 93 | case config.RenderingModeGPU: | ||
| 70 | settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlwaysValue) | 94 | settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlwaysValue) | ||
| 71 | case config.RenderingModeCPU: | 95 | case config.RenderingModeCPU: | ||
| 72 | settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyNeverValue) | 96 | settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyNeverValue) | ||
| 73 | case config.RenderingModeAuto: | 97 | case config.RenderingModeAuto: | ||
| 74 | - | // Default to always for auto mode (WebKit handles capability detection) | |||
| 75 | settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlwaysValue) | 98 | settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlwaysValue) | ||
| 76 | } | 99 | } | ||
| 100 | + | } | |||
| 77 | 101 | ||||
| 78 | - | // Debug settings | 102 | + | func applyDebugSettings(settings *webkit.Settings, cfg *config.Config) { |
| 79 | settings.SetEnableDeveloperExtras(cfg.Debug.EnableDevTools) | 103 | settings.SetEnableDeveloperExtras(cfg.Debug.EnableDevTools) | ||
| 80 | settings.SetEnableWriteConsoleMessagesToStdout(cfg.Logging.CaptureConsole) | 104 | settings.SetEnableWriteConsoleMessagesToStdout(cfg.Logging.CaptureConsole) | ||
| 105 | + | } | |||
| 81 | 106 | ||||
| 82 | - | // Browsing experience | 107 | + | func applyBrowsingSettings(settings *webkit.Settings) { |
| 83 | settings.SetEnableSmoothScrolling(true) | 108 | settings.SetEnableSmoothScrolling(true) | ||
| 84 | - | // Note: DNS prefetching setting deprecated in WebKitGTK 6 - use NetworkSession.PrefetchDns() instead | |||
| 85 | settings.SetEnablePageCache(true) | 109 | settings.SetEnablePageCache(true) | ||
| 86 | settings.SetEnableSiteSpecificQuirks(true) | 110 | settings.SetEnableSiteSpecificQuirks(true) | ||
| 111 | + | } | |||
| 87 | 112 | ||||
| 88 | - | // Media settings | 113 | + | func applyMediaSettings(settings *webkit.Settings, cfg *config.Config, log *zerolog.Logger) { |
| 89 | settings.SetEnableWebaudio(true) | 114 | settings.SetEnableWebaudio(true) | ||
| 90 | settings.SetEnableWebgl(true) | 115 | settings.SetEnableWebgl(true) | ||
| 91 | settings.SetEnableMedia(true) | 116 | settings.SetEnableMedia(true) | ||
| @@ -96,46 +121,37 @@ func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.S | |||||
| 96 | settings.SetMediaPlaybackRequiresUserGesture(true) | 121 | settings.SetMediaPlaybackRequiresUserGesture(true) | ||
| 97 | settings.SetMediaPlaybackAllowsInline(true) | 122 | settings.SetMediaPlaybackAllowsInline(true) | ||
| 98 | 123 | ||||
| 99 | - | // Hardware decoding mode - THE FIX for Twitch Error #4000 | |||
| 100 | - | // Default "auto" allows GStreamer to choose best decoder with software fallback | |||
| 101 | switch cfg.Media.HardwareDecodingMode { | 124 | switch cfg.Media.HardwareDecodingMode { | ||
| 102 | case config.HardwareDecodingForce: | 125 | case config.HardwareDecodingForce: | ||
| 103 | - | // Force hardware - will fail if unavailable (not recommended) | |||
| 104 | - | // Prioritize AV1 (most efficient codec) when user wants forced HW | |||
| 105 | hwTypes := "video/av01;video/mp4;video/webm;video/x-h264;video/x-h265" | 126 | hwTypes := "video/av01;video/mp4;video/webm;video/x-h264;video/x-h265" | ||
| 106 | settings.SetMediaContentTypesRequiringHardwareSupport(&hwTypes) | 127 | settings.SetMediaContentTypesRequiringHardwareSupport(&hwTypes) | ||
| 107 | log.Debug().Msg("hardware decoding: forced (may fail without hw support)") | 128 | log.Debug().Msg("hardware decoding: forced (may fail without hw support)") | ||
| 108 | case config.HardwareDecodingDisable: | 129 | case config.HardwareDecodingDisable: | ||
| 109 | - | // Force software only | |||
| 110 | settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyNeverValue) | 130 | settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyNeverValue) | ||
| 111 | log.Debug().Msg("hardware decoding: disabled (software only)") | 131 | log.Debug().Msg("hardware decoding: disabled (software only)") | ||
| 112 | - | default: // HardwareDecodingAuto | 132 | + | default: |
| 113 | - | // Empty string = let GStreamer choose best decoder with software fallback | |||
| 114 | - | // This fixes Twitch Error #4000 by allowing playback when HW unavailable | |||
| 115 | emptyTypes := "" | 133 | emptyTypes := "" | ||
| 116 | settings.SetMediaContentTypesRequiringHardwareSupport(&emptyTypes) | 134 | settings.SetMediaContentTypesRequiringHardwareSupport(&emptyTypes) | ||
| 117 | log.Debug().Msg("hardware decoding: auto (hw preferred, software fallback)") | 135 | log.Debug().Msg("hardware decoding: auto (hw preferred, software fallback)") | ||
| 118 | } | 136 | } | ||
| 137 | + | } | |||
| 119 | 138 | ||||
| 120 | - | // HTML5 storage | 139 | + | func applyStorageSettings(settings *webkit.Settings) { |
| 121 | settings.SetEnableHtml5LocalStorage(true) | 140 | settings.SetEnableHtml5LocalStorage(true) | ||
| 122 | settings.SetEnableHtml5Database(true) | 141 | settings.SetEnableHtml5Database(true) | ||
| 142 | + | } | |||
| 123 | 143 | ||||
| 124 | - | // UI behavior - touchpad swipe gestures for back/forward navigation | 144 | + | func applyUISettings(settings *webkit.Settings) { |
| 125 | settings.SetEnableBackForwardNavigationGestures(true) | 145 | settings.SetEnableBackForwardNavigationGestures(true) | ||
| 126 | settings.SetEnableFullscreen(true) | 146 | settings.SetEnableFullscreen(true) | ||
| 147 | + | } | |||
| 127 | 148 | ||||
| 128 | - | // Canvas acceleration | 149 | + | func applyCanvasSettings(settings *webkit.Settings) { |
| 129 | settings.SetEnable2dCanvasAcceleration(true) | 150 | settings.SetEnable2dCanvasAcceleration(true) | ||
| 151 | + | } | |||
| 130 | 152 | ||||
| 131 | - | // WebRTC | 153 | + | func applyWebRTCSettings(settings *webkit.Settings) { |
| 132 | settings.SetEnableWebrtc(true) | 154 | settings.SetEnableWebrtc(true) | ||
| 133 | - | ||||
| 134 | - | log.Debug(). | |||
| 135 | - | Str("sans_font", cfg.Appearance.SansFont). | |||
| 136 | - | Str("rendering_mode", string(cfg.RenderingMode)). | |||
| 137 | - | Bool("developer_extras", cfg.Debug.EnableDevTools). | |||
| 138 | - | Msg("settings applied") | |||
| 139 | } | 155 | } | ||
| 140 | 156 | ||||
| 141 | // UpdateFromConfig updates the manager with a new config (for hot-reload). | 157 | // UpdateFromConfig updates the manager with a new config (for hot-reload). | ||
M internal/infrastructure/webkit/webview.go +49 -36
| @@ -305,7 +305,18 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti | |||||
| 305 | 305 | ||||
| 306 | // connectSignals sets up signal handlers for the WebView. | 306 | // connectSignals sets up signal handlers for the WebView. | ||
| 307 | func (wv *WebView) connectSignals() { | 307 | func (wv *WebView) connectSignals() { | ||
| 308 | - | // load-changed signal | 308 | + | wv.connectLoadChangedSignal() |
| 309 | + | wv.connectCloseSignal() | |||
| 310 | + | wv.connectCreateSignal() | |||
| 311 | + | wv.connectReadyToShowSignal() | |||
| 312 | + | wv.connectTitleSignal() | |||
| 313 | + | wv.connectURISignal() | |||
| 314 | + | wv.connectFaviconSignal() | |||
| 315 | + | wv.connectProgressSignal() | |||
| 316 | + | wv.connectDecidePolicySignal() | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func (wv *WebView) connectLoadChangedSignal() { | |||
| 309 | loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) { | 320 | loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) { | ||
| 310 | uri := inner.GetUri() | 321 | uri := inner.GetUri() | ||
| 311 | title := inner.GetTitle() | 322 | title := inner.GetTitle() | ||
| @@ -337,18 +348,20 @@ func (wv *WebView) connectSignals() { | |||||
| 337 | } | 348 | } | ||
| 338 | sigID := wv.inner.ConnectLoadChanged(&loadChangedCb) | 349 | sigID := wv.inner.ConnectLoadChanged(&loadChangedCb) | ||
| 339 | wv.signalIDs = append(wv.signalIDs, sigID) | 350 | wv.signalIDs = append(wv.signalIDs, sigID) | ||
| 351 | + | } | |||
| 340 | 352 | ||||
| 341 | - | // close signal | 353 | + | func (wv *WebView) connectCloseSignal() { |
| 342 | - | closeCb := func(inner webkit.WebView) { | 354 | + | closeCb := func(_ webkit.WebView) { |
| 343 | if wv.OnClose != nil { | 355 | if wv.OnClose != nil { | ||
| 344 | wv.OnClose() | 356 | wv.OnClose() | ||
| 345 | } | 357 | } | ||
| 346 | } | 358 | } | ||
| 347 | - | sigID = wv.inner.ConnectClose(&closeCb) | 359 | + | sigID := wv.inner.ConnectClose(&closeCb) |
| 348 | wv.signalIDs = append(wv.signalIDs, sigID) | 360 | wv.signalIDs = append(wv.signalIDs, sigID) | ||
| 361 | + | } | |||
| 349 | 362 | ||||
| 350 | - | // create signal for popup window handling | 363 | + | func (wv *WebView) connectCreateSignal() { |
| 351 | - | createCb := func(inner webkit.WebView, navActionPtr uintptr) gtk.Widget { | 364 | + | createCb := func(_ webkit.WebView, navActionPtr uintptr) gtk.Widget { |
| 352 | if wv.OnCreate == nil { | 365 | if wv.OnCreate == nil { | ||
| 353 | return gtk.Widget{} // Block popup | 366 | return gtk.Widget{} // Block popup | ||
| 354 | } | 367 | } | ||
| @@ -378,20 +391,21 @@ func (wv *WebView) connectSignals() { | |||||
| 378 | 391 | ||||
| 379 | return newWV.inner.Widget | 392 | return newWV.inner.Widget | ||
| 380 | } | 393 | } | ||
| 381 | - | sigID = wv.inner.ConnectCreate(&createCb) | 394 | + | sigID := wv.inner.ConnectCreate(&createCb) |
| 382 | wv.signalIDs = append(wv.signalIDs, sigID) | 395 | wv.signalIDs = append(wv.signalIDs, sigID) | ||
| 396 | + | } | |||
| 383 | 397 | ||||
| 384 | - | // ready-to-show signal for popup display | 398 | + | func (wv *WebView) connectReadyToShowSignal() { |
| 385 | - | readyToShowCb := func(inner webkit.WebView) { | 399 | + | readyToShowCb := func(_ webkit.WebView) { |
| 386 | if wv.OnReadyToShow != nil { | 400 | if wv.OnReadyToShow != nil { | ||
| 387 | wv.OnReadyToShow() | 401 | wv.OnReadyToShow() | ||
| 388 | } | 402 | } | ||
| 389 | } | 403 | } | ||
| 390 | - | sigID = wv.inner.ConnectReadyToShow(&readyToShowCb) | 404 | + | sigID := wv.inner.ConnectReadyToShow(&readyToShowCb) |
| 391 | wv.signalIDs = append(wv.signalIDs, sigID) | 405 | wv.signalIDs = append(wv.signalIDs, sigID) | ||
| 406 | + | } | |||
| 392 | 407 | ||||
| 393 | - | // notify::title signal for title changes | 408 | + | func (wv *WebView) connectTitleSignal() { |
| 394 | - | // This fires when the page title changes (e.g., after page load, SPA navigation) | |||
| 395 | titleCb := func() { | 409 | titleCb := func() { | ||
| 396 | title := wv.inner.GetTitle() | 410 | title := wv.inner.GetTitle() | ||
| 397 | wv.mu.Lock() | 411 | wv.mu.Lock() | ||
| @@ -402,11 +416,11 @@ func (wv *WebView) connectSignals() { | |||||
| 402 | wv.OnTitleChanged(title) | 416 | wv.OnTitleChanged(title) | ||
| 403 | } | 417 | } | ||
| 404 | } | 418 | } | ||
| 405 | - | sigID = gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb)) | 419 | + | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb)) |
| 406 | wv.signalIDs = append(wv.signalIDs, sigID) | 420 | wv.signalIDs = append(wv.signalIDs, sigID) | ||
| 421 | + | } | |||
| 407 | 422 | ||||
| 408 | - | // notify::uri signal for URI changes | 423 | + | func (wv *WebView) connectURISignal() { |
| 409 | - | // This fires when the URI changes (e.g., redirects, SPA navigation via History API) | |||
| 410 | uriCb := func() { | 424 | uriCb := func() { | ||
| 411 | uri := wv.inner.GetUri() | 425 | uri := wv.inner.GetUri() | ||
| 412 | wv.mu.Lock() | 426 | wv.mu.Lock() | ||
| @@ -418,22 +432,22 @@ func (wv *WebView) connectSignals() { | |||||
| 418 | wv.OnURIChanged(uri) | 432 | wv.OnURIChanged(uri) | ||
| 419 | } | 433 | } | ||
| 420 | } | 434 | } | ||
| 421 | - | sigID = gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb)) | 435 | + | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb)) |
| 422 | wv.signalIDs = append(wv.signalIDs, sigID) | 436 | wv.signalIDs = append(wv.signalIDs, sigID) | ||
| 437 | + | } | |||
| 423 | 438 | ||||
| 424 | - | // notify::favicon signal for favicon changes | 439 | + | func (wv *WebView) connectFaviconSignal() { |
| 425 | - | // This fires when the page favicon changes (after page load or dynamic updates) | |||
| 426 | faviconCb := func() { | 440 | faviconCb := func() { | ||
| 427 | favicon := wv.inner.GetFavicon() | 441 | favicon := wv.inner.GetFavicon() | ||
| 428 | if wv.OnFaviconChanged != nil { | 442 | if wv.OnFaviconChanged != nil { | ||
| 429 | wv.OnFaviconChanged(favicon) | 443 | wv.OnFaviconChanged(favicon) | ||
| 430 | } | 444 | } | ||
| 431 | } | 445 | } | ||
| 432 | - | sigID = gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb)) | 446 | + | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb)) |
| 433 | wv.signalIDs = append(wv.signalIDs, sigID) | 447 | wv.signalIDs = append(wv.signalIDs, sigID) | ||
| 448 | + | } | |||
| 434 | 449 | ||||
| 435 | - | // notify::estimated-load-progress signal for load progress updates | 450 | + | func (wv *WebView) connectProgressSignal() { |
| 436 | - | // This fires frequently during page load with values from 0.0 to 1.0 | |||
| 437 | progressCb := func() { | 451 | progressCb := func() { | ||
| 438 | progress := wv.inner.GetEstimatedLoadProgress() | 452 | progress := wv.inner.GetEstimatedLoadProgress() | ||
| 439 | wv.mu.Lock() | 453 | wv.mu.Lock() | ||
| @@ -444,17 +458,16 @@ func (wv *WebView) connectSignals() { | |||||
| 444 | wv.OnProgressChanged(progress) | 458 | wv.OnProgressChanged(progress) | ||
| 445 | } | 459 | } | ||
| 446 | } | 460 | } | ||
| 447 | - | sigID = gobject.SignalConnect(wv.inner.GoPointer(), "notify::estimated-load-progress", glib.NewCallback(&progressCb)) | 461 | + | sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::estimated-load-progress", glib.NewCallback(&progressCb)) |
| 448 | wv.signalIDs = append(wv.signalIDs, sigID) | 462 | wv.signalIDs = append(wv.signalIDs, sigID) | ||
| 463 | + | } | |||
| 449 | 464 | ||||
| 450 | - | // decide-policy signal for intercepting middle-click and Ctrl+click on links | 465 | + | func (wv *WebView) connectDecidePolicySignal() { |
| 451 | - | decidePolicyCb := func(inner webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool { | 466 | + | decidePolicyCb := func(_ webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool { |
| 452 | - | // Only handle navigation actions (not new window or response policies) | |||
| 453 | if decisionType != webkit.PolicyDecisionTypeNavigationActionValue { | 467 | if decisionType != webkit.PolicyDecisionTypeNavigationActionValue { | ||
| 454 | return false // Let WebKit handle | 468 | return false // Let WebKit handle | ||
| 455 | } | 469 | } | ||
| 456 | 470 | ||||
| 457 | - | // Cast to NavigationPolicyDecision | |||
| 458 | navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr) | 471 | navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr) | ||
| 459 | if navDecision == nil { | 472 | if navDecision == nil { | ||
| 460 | return false | 473 | return false | ||
| @@ -465,24 +478,19 @@ func (wv *WebView) connectSignals() { | |||||
| 465 | return false | 478 | return false | ||
| 466 | } | 479 | } | ||
| 467 | 480 | ||||
| 468 | - | // Only handle link clicks | |||
| 469 | if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue { | 481 | if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue { | ||
| 470 | return false | 482 | return false | ||
| 471 | } | 483 | } | ||
| 472 | 484 | ||||
| 473 | - | // Get mouse button and modifiers | |||
| 474 | mouseButton := navAction.GetMouseButton() | 485 | mouseButton := navAction.GetMouseButton() | ||
| 475 | modifiers := navAction.GetModifiers() | 486 | modifiers := navAction.GetModifiers() | ||
| 476 | - | ||||
| 477 | - | // Check for middle-click (button 2) or Ctrl+left-click (button 1 + Ctrl) | |||
| 478 | isMiddleClick := mouseButton == 2 | 487 | isMiddleClick := mouseButton == 2 | ||
| 479 | isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0 | 488 | isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0 | ||
| 480 | 489 | ||||
| 481 | if !isMiddleClick && !isCtrlClick { | 490 | if !isMiddleClick && !isCtrlClick { | ||
| 482 | - | return false // Normal click, let WebKit handle | 491 | + | return false |
| 483 | } | 492 | } | ||
| 484 | 493 | ||||
| 485 | - | // Get the link URL | |||
| 486 | request := navAction.GetRequest() | 494 | request := navAction.GetRequest() | ||
| 487 | if request == nil { | 495 | if request == nil { | ||
| 488 | return false | 496 | return false | ||
| @@ -499,18 +507,16 @@ func (wv *WebView) connectSignals() { | |||||
| 499 | Bool("ctrl", isCtrlClick). | 507 | Bool("ctrl", isCtrlClick). | ||
| 500 | Msg("middle-click/ctrl+click on link detected") | 508 | Msg("middle-click/ctrl+click on link detected") | ||
| 501 | 509 | ||||
| 502 | - | // Call the handler | |||
| 503 | if wv.OnLinkMiddleClick != nil { | 510 | if wv.OnLinkMiddleClick != nil { | ||
| 504 | if wv.OnLinkMiddleClick(linkURI) { | 511 | if wv.OnLinkMiddleClick(linkURI) { | ||
| 505 | - | // Handler processed it, block the navigation | |||
| 506 | navDecision.Ignore() | 512 | navDecision.Ignore() | ||
| 507 | return true | 513 | return true | ||
| 508 | } | 514 | } | ||
| 509 | } | 515 | } | ||
| 510 | 516 | ||||
| 511 | - | return false // Let WebKit handle if not handled | 517 | + | return false |
| 512 | } | 518 | } | ||
| 513 | - | sigID = wv.inner.ConnectDecidePolicy(&decidePolicyCb) | 519 | + | sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb) |
| 514 | wv.signalIDs = append(wv.signalIDs, sigID) | 520 | wv.signalIDs = append(wv.signalIDs, sigID) | ||
| 515 | } | 521 | } | ||
| 516 | 522 | ||||
| @@ -566,6 +572,7 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error | |||||
| 566 | baseURIPtr = &baseURI | 572 | baseURIPtr = &baseURI | ||
| 567 | } | 573 | } | ||
| 568 | wv.inner.LoadHtml(content, baseURIPtr) | 574 | wv.inner.LoadHtml(content, baseURIPtr) | ||
| 575 | + | logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("loading HTML content") | |||
| 569 | return nil | 576 | return nil | ||
| 570 | } | 577 | } | ||
| 571 | 578 | ||||
| @@ -575,6 +582,7 @@ func (wv *WebView) Reload(ctx context.Context) error { | |||||
| 575 | return fmt.Errorf("webview %d is destroyed", wv.id) | 582 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 576 | } | 583 | } | ||
| 577 | wv.inner.Reload() | 584 | wv.inner.Reload() | ||
| 585 | + | logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("reloading webview") | |||
| 578 | return nil | 586 | return nil | ||
| 579 | } | 587 | } | ||
| 580 | 588 | ||||
| @@ -584,6 +592,7 @@ func (wv *WebView) ReloadBypassCache(ctx context.Context) error { | |||||
| 584 | return fmt.Errorf("webview %d is destroyed", wv.id) | 592 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 585 | } | 593 | } | ||
| 586 | wv.inner.ReloadBypassCache() | 594 | wv.inner.ReloadBypassCache() | ||
| 595 | + | logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("reloading webview bypassing cache") | |||
| 587 | return nil | 596 | return nil | ||
| 588 | } | 597 | } | ||
| 589 | 598 | ||||
| @@ -593,6 +602,7 @@ func (wv *WebView) Stop(ctx context.Context) error { | |||||
| 593 | return fmt.Errorf("webview %d is destroyed", wv.id) | 602 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 594 | } | 603 | } | ||
| 595 | wv.inner.StopLoading() | 604 | wv.inner.StopLoading() | ||
| 605 | + | logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("stopping webview load") | |||
| 596 | return nil | 606 | return nil | ||
| 597 | } | 607 | } | ||
| 598 | 608 | ||||
| @@ -605,6 +615,7 @@ func (wv *WebView) GoBack(ctx context.Context) error { | |||||
| 605 | return fmt.Errorf("cannot go back") | 615 | return fmt.Errorf("cannot go back") | ||
| 606 | } | 616 | } | ||
| 607 | wv.inner.GoBack() | 617 | wv.inner.GoBack() | ||
| 618 | + | logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go back") | |||
| 608 | return nil | 619 | return nil | ||
| 609 | } | 620 | } | ||
| 610 | 621 | ||||
| @@ -617,6 +628,7 @@ func (wv *WebView) GoForward(ctx context.Context) error { | |||||
| 617 | return fmt.Errorf("cannot go forward") | 628 | return fmt.Errorf("cannot go forward") | ||
| 618 | } | 629 | } | ||
| 619 | wv.inner.GoForward() | 630 | wv.inner.GoForward() | ||
| 631 | + | logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go forward") | |||
| 620 | return nil | 632 | return nil | ||
| 621 | } | 633 | } | ||
| 622 | 634 | ||||
| @@ -677,6 +689,7 @@ func (wv *WebView) SetZoomLevel(ctx context.Context, level float64) error { | |||||
| 677 | return fmt.Errorf("webview %d is destroyed", wv.id) | 689 | return fmt.Errorf("webview %d is destroyed", wv.id) | ||
| 678 | } | 690 | } | ||
| 679 | wv.inner.SetZoomLevel(level) | 691 | wv.inner.SetZoomLevel(level) | ||
| 692 | + | logging.FromContext(ctx).Debug().Float64("level", level).Int("webview_id", int(wv.id)).Msg("set webview zoom level") | |||
| 680 | return nil | 693 | return nil | ||
| 681 | } | 694 | } | ||
| 682 | 695 | ||||
M internal/logging/logger.go +25 -15
| @@ -9,6 +9,12 @@ import ( | |||||
| 9 | "github.com/rs/zerolog" | 9 | "github.com/rs/zerolog" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | + | const ( | |||
| 13 | + | logFormatConsole = "console" | |||
| 14 | + | logFormatJSON = "json" | |||
| 15 | + | logFormatText = "text" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 12 | // Config holds logging configuration | 18 | // Config holds logging configuration | ||
| 13 | type Config struct { | 19 | type Config struct { | ||
| 14 | Level zerolog.Level | 20 | Level zerolog.Level | ||
| @@ -28,7 +34,7 @@ type FileConfig struct { | |||||
| 28 | func DefaultConfig() Config { | 34 | func DefaultConfig() Config { | ||
| 29 | return Config{ | 35 | return Config{ | ||
| 30 | Level: zerolog.InfoLevel, | 36 | Level: zerolog.InfoLevel, | ||
| 31 | - | Format: "console", | 37 | + | Format: logFormatConsole, |
| 32 | TimeFormat: time.RFC3339, | 38 | TimeFormat: time.RFC3339, | ||
| 33 | } | 39 | } | ||
| 34 | } | 40 | } | ||
| @@ -38,12 +44,12 @@ func New(cfg Config) zerolog.Logger { | |||||
| 38 | var output io.Writer = os.Stderr | 44 | var output io.Writer = os.Stderr | ||
| 39 | 45 | ||||
| 40 | switch cfg.Format { | 46 | switch cfg.Format { | ||
| 41 | - | case "console": | 47 | + | case logFormatConsole: |
| 42 | output = zerolog.ConsoleWriter{ | 48 | output = zerolog.ConsoleWriter{ | ||
| 43 | Out: os.Stderr, | 49 | Out: os.Stderr, | ||
| 44 | TimeFormat: cfg.TimeFormat, | 50 | TimeFormat: cfg.TimeFormat, | ||
| 45 | } | 51 | } | ||
| 46 | - | case "json": | 52 | + | case logFormatJSON: |
| 47 | // JSON is the default zerolog format | 53 | // JSON is the default zerolog format | ||
| 48 | output = os.Stderr | 54 | output = os.Stderr | ||
| 49 | } | 55 | } | ||
| @@ -60,18 +66,22 @@ func New(cfg Config) zerolog.Logger { | |||||
| 60 | // LogDir must exist before calling this function (handled by config.EnsureDirectories). | 66 | // LogDir must exist before calling this function (handled by config.EnsureDirectories). | ||
| 61 | // Returns the logger and a cleanup function to close the file. | 67 | // Returns the logger and a cleanup function to close the file. | ||
| 62 | func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) { | 68 | func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) { | ||
| 69 | + | const ( | |||
| 70 | + | logDirPerm = 0o755 | |||
| 71 | + | logFilePerm = 0o644 | |||
| 72 | + | ) | |||
| 63 | var writers []io.Writer | 73 | var writers []io.Writer | ||
| 64 | - | var cleanup func() = func() {} | 74 | + | cleanup := func() {} |
| 65 | 75 | ||||
| 66 | // Stderr output (only if enabled) | 76 | // Stderr output (only if enabled) | ||
| 67 | if fileCfg.WriteToStderr { | 77 | if fileCfg.WriteToStderr { | ||
| 68 | switch cfg.Format { | 78 | switch cfg.Format { | ||
| 69 | - | case "console", "text", "": | 79 | + | case logFormatConsole, logFormatText, "": |
| 70 | writers = append(writers, zerolog.ConsoleWriter{ | 80 | writers = append(writers, zerolog.ConsoleWriter{ | ||
| 71 | Out: os.Stderr, | 81 | Out: os.Stderr, | ||
| 72 | TimeFormat: cfg.TimeFormat, | 82 | TimeFormat: cfg.TimeFormat, | ||
| 73 | }) | 83 | }) | ||
| 74 | - | case "json": | 84 | + | case logFormatJSON: |
| 75 | writers = append(writers, os.Stderr) | 85 | writers = append(writers, os.Stderr) | ||
| 76 | default: | 86 | default: | ||
| 77 | writers = append(writers, zerolog.ConsoleWriter{ | 87 | writers = append(writers, zerolog.ConsoleWriter{ | ||
| @@ -84,18 +94,18 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) | |||||
| 84 | // Session file output (JSON format for parsing) | 94 | // Session file output (JSON format for parsing) | ||
| 85 | if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" { | 95 | if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" { | ||
| 86 | // Ensure log directory exists (LogDir is from config, may not have logs subdir) | 96 | // Ensure log directory exists (LogDir is from config, may not have logs subdir) | ||
| 87 | - | if err := os.MkdirAll(fileCfg.LogDir, 0755); err != nil { | 97 | + | if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil { |
| 88 | return zerolog.Logger{}, nil, err | 98 | return zerolog.Logger{}, nil, err | ||
| 89 | } | 99 | } | ||
| 90 | 100 | ||||
| 91 | filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID)) | 101 | filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID)) | ||
| 92 | - | file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) | 102 | + | file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm) |
| 93 | if err != nil { | 103 | if err != nil { | ||
| 94 | return zerolog.Logger{}, nil, err | 104 | return zerolog.Logger{}, nil, err | ||
| 95 | } | 105 | } | ||
| 96 | 106 | ||||
| 97 | writers = append(writers, file) | 107 | writers = append(writers, file) | ||
| 98 | - | cleanup = func() { file.Close() } | 108 | + | cleanup = func() { _ = file.Close() } |
| 99 | } | 109 | } | ||
| 100 | 110 | ||||
| 101 | // Fallback to discard if no writers configured | 111 | // Fallback to discard if no writers configured | ||
| @@ -129,7 +139,7 @@ func NewFromEnv() zerolog.Logger { | |||||
| 129 | 139 | ||||
| 130 | if format := os.Getenv("DUMBER_LOG_FORMAT"); format != "" { | 140 | if format := os.Getenv("DUMBER_LOG_FORMAT"); format != "" { | ||
| 131 | switch format { | 141 | switch format { | ||
| 132 | - | case "json", "console", "text": | 142 | + | case logFormatJSON, logFormatConsole, logFormatText: |
| 133 | cfg.Format = format | 143 | cfg.Format = format | ||
| 134 | } | 144 | } | ||
| 135 | } | 145 | } | ||
| @@ -145,12 +155,12 @@ func NewFromConfigValues(level, format string) zerolog.Logger { | |||||
| 145 | cfg.Level = ParseLevel(level) | 155 | cfg.Level = ParseLevel(level) | ||
| 146 | 156 | ||||
| 147 | switch format { | 157 | switch format { | ||
| 148 | - | case "json": | 158 | + | case logFormatJSON: |
| 149 | - | cfg.Format = "json" | 159 | + | cfg.Format = logFormatJSON |
| 150 | - | case "console", "text", "": | 160 | + | case logFormatConsole, logFormatText, "": |
| 151 | - | cfg.Format = "console" | 161 | + | cfg.Format = logFormatConsole |
| 152 | default: | 162 | default: | ||
| 153 | - | cfg.Format = "console" | 163 | + | cfg.Format = logFormatConsole |
| 154 | } | 164 | } | ||
| 155 | 165 | ||||
| 156 | return New(cfg) | 166 | return New(cfg) | ||
M internal/ui/app.go +17 -8
| @@ -229,6 +229,13 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 229 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | 229 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | ||
| 230 | a.handleModeChange(ctx, from, to) | 230 | a.handleModeChange(ctx, from, to) | ||
| 231 | }) | 231 | }) | ||
| 232 | + | a.keyboardHandler.SetShouldBypassInput(func() bool { | |||
| 233 | + | wsView := a.activeWorkspaceView() | |||
| 234 | + | if wsView == nil { | |||
| 235 | + | return false | |||
| 236 | + | } | |||
| 237 | + | return wsView.IsOmniboxVisible() | |||
| 238 | + | }) | |||
| 232 | a.keyboardHandler.AttachTo(a.mainWindow.Window()) | 239 | a.keyboardHandler.AttachTo(a.mainWindow.Window()) | ||
| 233 | 240 | ||||
| 234 | // Store omnibox config (omnibox is created per-pane via WorkspaceView) | 241 | // Store omnibox config (omnibox is created per-pane via WorkspaceView) | ||
| @@ -242,7 +249,9 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 242 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | 249 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | ||
| 243 | UIScale: a.deps.Config.DefaultUIScale, | 250 | UIScale: a.deps.Config.DefaultUIScale, | ||
| 244 | OnNavigate: func(url string) { | 251 | OnNavigate: func(url string) { | ||
| 245 | - | a.navCoord.Navigate(ctx, url) | 252 | + | if err := a.navCoord.Navigate(ctx, url); err != nil { |
| 253 | + | log.Error().Err(err).Str("url", url).Msg("navigation failed") | |||
| 254 | + | } | |||
| 246 | }, | 255 | }, | ||
| 247 | } | 256 | } | ||
| 248 | a.navCoord.SetOmniboxProvider(a) | 257 | a.navCoord.SetOmniboxProvider(a) | ||
| @@ -355,7 +364,9 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 355 | // Wire tab bar click handling to coordinator | 364 | // Wire tab bar click handling to coordinator | ||
| 356 | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | 365 | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | ||
| 357 | a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) { | 366 | a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) { | ||
| 358 | - | a.tabCoord.Switch(ctx, tabID) | 367 | + | if err := a.tabCoord.Switch(ctx, tabID); err != nil { |
| 368 | + | log.Error().Err(err).Str("tab_id", string(tabID)).Msg("tab switch failed") | |||
| 369 | + | } | |||
| 359 | }) | 370 | }) | ||
| 360 | } | 371 | } | ||
| 361 | 372 | ||||
| @@ -751,11 +762,10 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||||
| 751 | // Only appearance is hot-reloaded for now. | 762 | // Only appearance is hot-reloaded for now. | ||
| 752 | a.configManager.OnConfigChange(func(newCfg *config.Config) { | 763 | a.configManager.OnConfigChange(func(newCfg *config.Config) { | ||
| 753 | cfgCopy := newCfg | 764 | cfgCopy := newCfg | ||
| 754 | - | var cb glib.SourceFunc | 765 | + | cb := glib.SourceFunc(func(_ uintptr) bool { |
| 755 | - | cb = func(_ uintptr) bool { | |||
| 756 | a.applyAppearanceConfig(ctx, cfgCopy) | 766 | a.applyAppearanceConfig(ctx, cfgCopy) | ||
| 757 | return false | 767 | return false | ||
| 758 | - | } | 768 | + | }) |
| 759 | glib.IdleAdd(&cb, 0) | 769 | glib.IdleAdd(&cb, 0) | ||
| 760 | }) | 770 | }) | ||
| 761 | 771 | ||||
| @@ -827,11 +837,10 @@ func (a *App) initFilteringAsync(ctx context.Context) { | |||||
| 827 | 837 | ||||
| 828 | a.deps.FilterManager.SetStatusCallback(func(status filtering.FilterStatus) { | 838 | a.deps.FilterManager.SetStatusCallback(func(status filtering.FilterStatus) { | ||
| 829 | statusCopy := status // Capture for closure | 839 | statusCopy := status // Capture for closure | ||
| 830 | - | var cb glib.SourceFunc | 840 | + | cb := glib.SourceFunc(func(_ uintptr) bool { |
| 831 | - | cb = func(_ uintptr) bool { | |||
| 832 | a.showFilterStatus(ctx, statusCopy) | 841 | a.showFilterStatus(ctx, statusCopy) | ||
| 833 | return false // Don't repeat | 842 | return false // Don't repeat | ||
| 834 | - | } | 843 | + | }) |
| 835 | glib.IdleAdd(&cb, 0) | 844 | glib.IdleAdd(&cb, 0) | ||
| 836 | }) | 845 | }) | ||
| 837 | a.deps.FilterManager.LoadAsync(ctx) | 846 | a.deps.FilterManager.LoadAsync(ctx) | ||
M internal/ui/component/find_bar.go +65 -29
| @@ -149,6 +149,27 @@ func (fb *FindBar) FindPrevious() { | |||||
| 149 | } | 149 | } | ||
| 150 | 150 | ||||
| 151 | func (fb *FindBar) createWidgets() error { | 151 | func (fb *FindBar) createWidgets() error { | ||
| 152 | + | if err := fb.initContainerWidgets(); err != nil { | |||
| 153 | + | return err | |||
| 154 | + | } | |||
| 155 | + | if err := fb.initInputRowWidgets(); err != nil { | |||
| 156 | + | return err | |||
| 157 | + | } | |||
| 158 | + | if err := fb.initOptionsRowWidgets(); err != nil { | |||
| 159 | + | return err | |||
| 160 | + | } | |||
| 161 | + | fb.assembleWidgets() | |||
| 162 | + | return nil | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (fb *FindBar) setupHandlers() { | |||
| 166 | + | fb.connectEntryHandlers() | |||
| 167 | + | fb.connectNavHandlers() | |||
| 168 | + | fb.connectToggleHandlers() | |||
| 169 | + | fb.attachKeyController() | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func (fb *FindBar) initContainerWidgets() error { | |||
| 152 | fb.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | 173 | fb.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | ||
| 153 | if fb.outerBox == nil { | 174 | if fb.outerBox == nil { | ||
| 154 | return errNilWidget("findOuterBox") | 175 | return errNilWidget("findOuterBox") | ||
| @@ -165,7 +186,10 @@ func (fb *FindBar) createWidgets() error { | |||||
| 165 | return errNilWidget("findContainerBox") | 186 | return errNilWidget("findContainerBox") | ||
| 166 | } | 187 | } | ||
| 167 | fb.containerBox.AddCssClass("find-bar-container") | 188 | fb.containerBox.AddCssClass("find-bar-container") | ||
| 189 | + | return nil | |||
| 190 | + | } | |||
| 168 | 191 | ||||
| 192 | + | func (fb *FindBar) initInputRowWidgets() error { | |||
| 169 | fb.inputRow = gtk.NewBox(gtk.OrientationHorizontalValue, findBarRowSpacing) | 193 | fb.inputRow = gtk.NewBox(gtk.OrientationHorizontalValue, findBarRowSpacing) | ||
| 170 | if fb.inputRow == nil { | 194 | if fb.inputRow == nil { | ||
| 171 | return errNilWidget("findInputRow") | 195 | return errNilWidget("findInputRow") | ||
| @@ -206,7 +230,10 @@ func (fb *FindBar) createWidgets() error { | |||||
| 206 | return errNilWidget("findCloseBtn") | 230 | return errNilWidget("findCloseBtn") | ||
| 207 | } | 231 | } | ||
| 208 | fb.closeBtn.AddCssClass("find-bar-close") | 232 | fb.closeBtn.AddCssClass("find-bar-close") | ||
| 233 | + | return nil | |||
| 234 | + | } | |||
| 209 | 235 | ||||
| 236 | + | func (fb *FindBar) initOptionsRowWidgets() error { | |||
| 210 | fb.optionsRow = gtk.NewBox(gtk.OrientationHorizontalValue, findBarRowSpacing) | 237 | fb.optionsRow = gtk.NewBox(gtk.OrientationHorizontalValue, findBarRowSpacing) | ||
| 211 | if fb.optionsRow == nil { | 238 | if fb.optionsRow == nil { | ||
| 212 | return errNilWidget("findOptionsRow") | 239 | return errNilWidget("findOptionsRow") | ||
| @@ -231,7 +258,10 @@ func (fb *FindBar) createWidgets() error { | |||||
| 231 | } | 258 | } | ||
| 232 | fb.hlToggle.AddCssClass("find-bar-toggle") | 259 | fb.hlToggle.AddCssClass("find-bar-toggle") | ||
| 233 | fb.hlToggle.SetActive(true) | 260 | fb.hlToggle.SetActive(true) | ||
| 261 | + | return nil | |||
| 262 | + | } | |||
| 234 | 263 | ||||
| 264 | + | func (fb *FindBar) assembleWidgets() { | |||
| 235 | fb.inputRow.Append(&fb.entry.Widget) | 265 | fb.inputRow.Append(&fb.entry.Widget) | ||
| 236 | fb.inputRow.Append(&fb.prevBtn.Widget) | 266 | fb.inputRow.Append(&fb.prevBtn.Widget) | ||
| 237 | fb.inputRow.Append(&fb.nextBtn.Widget) | 267 | fb.inputRow.Append(&fb.nextBtn.Widget) | ||
| @@ -245,20 +275,21 @@ func (fb *FindBar) createWidgets() error { | |||||
| 245 | fb.containerBox.Append(&fb.inputRow.Widget) | 275 | fb.containerBox.Append(&fb.inputRow.Widget) | ||
| 246 | fb.containerBox.Append(&fb.optionsRow.Widget) | 276 | fb.containerBox.Append(&fb.optionsRow.Widget) | ||
| 247 | fb.outerBox.Append(&fb.containerBox.Widget) | 277 | fb.outerBox.Append(&fb.containerBox.Widget) | ||
| 248 | - | ||||
| 249 | - | return nil | |||
| 250 | } | 278 | } | ||
| 251 | 279 | ||||
| 252 | - | func (fb *FindBar) setupHandlers() { | 280 | + | func (fb *FindBar) connectEntryHandlers() { |
| 253 | - | if fb.entry != nil { | 281 | + | if fb.entry == nil { |
| 254 | - | changedCb := func(_ gtk.SearchEntry) { | 282 | + | return |
| 255 | - | if fb.uc != nil { | 283 | + | } |
| 256 | - | fb.uc.SetQuery(fb.entry.GetText()) | 284 | + | changedCb := func(_ gtk.SearchEntry) { |
| 257 | - | } | 285 | + | if fb.uc != nil { |
| 286 | + | fb.uc.SetQuery(fb.entry.GetText()) | |||
| 258 | } | 287 | } | ||
| 259 | - | fb.entry.ConnectSearchChanged(&changedCb) | |||
| 260 | } | 288 | } | ||
| 289 | + | fb.entry.ConnectSearchChanged(&changedCb) | |||
| 290 | + | } | |||
| 261 | 291 | ||||
| 292 | + | func (fb *FindBar) connectNavHandlers() { | |||
| 262 | if fb.prevBtn != nil { | 293 | if fb.prevBtn != nil { | ||
| 263 | prevCb := func(_ gtk.Button) { | 294 | prevCb := func(_ gtk.Button) { | ||
| 264 | fb.FindPrevious() | 295 | fb.FindPrevious() | ||
| @@ -282,7 +313,9 @@ func (fb *FindBar) setupHandlers() { | |||||
| 282 | } | 313 | } | ||
| 283 | fb.closeBtn.ConnectClicked(&closeCb) | 314 | fb.closeBtn.ConnectClicked(&closeCb) | ||
| 284 | } | 315 | } | ||
| 316 | + | } | |||
| 285 | 317 | ||||
| 318 | + | func (fb *FindBar) connectToggleHandlers() { | |||
| 286 | if fb.caseToggle != nil { | 319 | if fb.caseToggle != nil { | ||
| 287 | caseCb := func(_ gtk.ToggleButton) { | 320 | caseCb := func(_ gtk.ToggleButton) { | ||
| 288 | if fb.uc != nil { | 321 | if fb.uc != nil { | ||
| @@ -309,31 +342,34 @@ func (fb *FindBar) setupHandlers() { | |||||
| 309 | } | 342 | } | ||
| 310 | fb.hlToggle.ConnectToggled(&hlCb) | 343 | fb.hlToggle.ConnectToggled(&hlCb) | ||
| 311 | } | 344 | } | ||
| 345 | + | } | |||
| 312 | 346 | ||||
| 347 | + | func (fb *FindBar) attachKeyController() { | |||
| 313 | controller := gtk.NewEventControllerKey() | 348 | controller := gtk.NewEventControllerKey() | ||
| 314 | - | if controller != nil { | 349 | + | if controller == nil { |
| 315 | - | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | 350 | + | return |
| 316 | - | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool { | 351 | + | } |
| 317 | - | switch keyval { | 352 | + | controller.SetPropagationPhase(gtk.PhaseCaptureValue) |
| 318 | - | case uint(gdk.KEY_Escape): | 353 | + | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool { |
| 319 | - | fb.Hide() | 354 | + | switch keyval { |
| 320 | - | if fb.onClose != nil { | 355 | + | case uint(gdk.KEY_Escape): |
| 321 | - | fb.onClose() | 356 | + | fb.Hide() |
| 322 | - | } | 357 | + | if fb.onClose != nil { |
| 323 | - | return true | 358 | + | fb.onClose() |
| 324 | - | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |||
| 325 | - | if state&gdk.ShiftMaskValue != 0 { | |||
| 326 | - | fb.FindPrevious() | |||
| 327 | - | } else { | |||
| 328 | - | fb.FindNext() | |||
| 329 | - | } | |||
| 330 | - | return true | |||
| 331 | } | 359 | } | ||
| 332 | - | return false | 360 | + | return true |
| 361 | + | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |||
| 362 | + | if state&gdk.ShiftMaskValue != 0 { | |||
| 363 | + | fb.FindPrevious() | |||
| 364 | + | } else { | |||
| 365 | + | fb.FindNext() | |||
| 366 | + | } | |||
| 367 | + | return true | |||
| 333 | } | 368 | } | ||
| 334 | - | controller.ConnectKeyPressed(&keyPressedCb) | 369 | + | return false |
| 335 | - | fb.outerBox.AddController(&controller.EventController) | |||
| 336 | } | 370 | } | ||
| 371 | + | controller.ConnectKeyPressed(&keyPressedCb) | |||
| 372 | + | fb.outerBox.AddController(&controller.EventController) | |||
| 337 | } | 373 | } | ||
| 338 | 374 | ||||
| 339 | func (fb *FindBar) bindUseCase() { | 375 | func (fb *FindBar) bindUseCase() { | ||
M internal/ui/component/omnibox.go +104 -80
| @@ -24,6 +24,7 @@ const ( | |||||
| 24 | omniboxMaxWidth = 800 // Maximum width in pixels | 24 | omniboxMaxWidth = 800 // Maximum width in pixels | ||
| 25 | omniboxHeightPct = 0.6 // 60% of parent window height | 25 | omniboxHeightPct = 0.6 // 60% of parent window height | ||
| 26 | omniboxMaxResults = 10 | 26 | omniboxMaxResults = 10 | ||
| 27 | + | omniboxTopMarginPct = 0.2 // 20% from top | |||
| 27 | debounceDelayMs = 150 | 28 | debounceDelayMs = 150 | ||
| 28 | ) | 29 | ) | ||
| 29 | 30 | ||||
| @@ -236,7 +237,8 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||||
| 236 | rowHeight = o.measuredHeights.singleRow | 237 | rowHeight = o.measuredHeights.singleRow | ||
| 237 | } else { | 238 | } else { | ||
| 238 | // Fallback to hardcoded estimate (before first measurement) | 239 | // Fallback to hardcoded estimate (before first measurement) | ||
| 239 | - | rowHeight = int(60 * o.uiScale) | 240 | + | const fallbackRowHeight = 60 |
| 241 | + | rowHeight = int(fallbackRowHeight * o.uiScale) | |||
| 240 | } | 242 | } | ||
| 241 | 243 | ||||
| 242 | // Calculate content height for the scrolled window | 244 | // Calculate content height for the scrolled window | ||
| @@ -275,7 +277,46 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||||
| 275 | 277 | ||||
| 276 | // createWidgets builds the GTK widget hierarchy. | 278 | // createWidgets builds the GTK widget hierarchy. | ||
| 277 | func (o *Omnibox) createWidgets() error { | 279 | func (o *Omnibox) createWidgets() error { | ||
| 278 | - | // Create outer container for positioning (will be added to an overlay) | 280 | + | if err := o.initOuterBox(); err != nil { |
| 281 | + | return err | |||
| 282 | + | } | |||
| 283 | + | if err := o.initMainBox(); err != nil { | |||
| 284 | + | return err | |||
| 285 | + | } | |||
| 286 | + | if err := o.initHeader(); err != nil { | |||
| 287 | + | return err | |||
| 288 | + | } | |||
| 289 | + | if err := o.initEntry(); err != nil { | |||
| 290 | + | return err | |||
| 291 | + | } | |||
| 292 | + | if err := o.initList(); err != nil { | |||
| 293 | + | return err | |||
| 294 | + | } | |||
| 295 | + | o.assembleWidgets() | |||
| 296 | + | return nil | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | // setupKeyboardHandling adds keyboard event handling. | |||
| 300 | + | func (o *Omnibox) setupKeyboardHandling() { | |||
| 301 | + | log := logging.FromContext(o.ctx) | |||
| 302 | + | controller := gtk.NewEventControllerKey() | |||
| 303 | + | if controller == nil { | |||
| 304 | + | log.Error().Msg("failed to create event controller key") | |||
| 305 | + | return | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // Set capture phase to intercept before entry | |||
| 309 | + | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | |||
| 310 | + | ||||
| 311 | + | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool { | |||
| 312 | + | return o.handleKeyPress(keyval, state) | |||
| 313 | + | } | |||
| 314 | + | controller.ConnectKeyPressed(&keyPressedCb) | |||
| 315 | + | ||||
| 316 | + | o.outerBox.AddController(&controller.EventController) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func (o *Omnibox) initOuterBox() error { | |||
| 279 | o.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | 320 | o.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | ||
| 280 | if o.outerBox == nil { | 321 | if o.outerBox == nil { | ||
| 281 | return errNilWidget("outerBox") | 322 | return errNilWidget("outerBox") | ||
| @@ -284,15 +325,19 @@ func (o *Omnibox) createWidgets() error { | |||||
| 284 | o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally | 325 | o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally | ||
| 285 | o.outerBox.SetValign(gtk.AlignStartValue) // Align to top | 326 | o.outerBox.SetValign(gtk.AlignStartValue) // Align to top | ||
| 286 | o.outerBox.SetVisible(false) // Hidden by default | 327 | o.outerBox.SetVisible(false) // Hidden by default | ||
| 328 | + | return nil | |||
| 329 | + | } | |||
| 287 | 330 | ||||
| 288 | - | // Main content box | 331 | + | func (o *Omnibox) initMainBox() error { |
| 289 | o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | 332 | o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | ||
| 290 | if o.mainBox == nil { | 333 | if o.mainBox == nil { | ||
| 291 | return errNilWidget("mainBox") | 334 | return errNilWidget("mainBox") | ||
| 292 | } | 335 | } | ||
| 293 | o.mainBox.AddCssClass("omnibox-container") | 336 | o.mainBox.AddCssClass("omnibox-container") | ||
| 337 | + | return nil | |||
| 338 | + | } | |||
| 294 | 339 | ||||
| 295 | - | // Header with History/Favorites toggle | 340 | + | func (o *Omnibox) initHeader() error { |
| 296 | o.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0) | 341 | o.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0) | ||
| 297 | if o.headerBox == nil { | 342 | if o.headerBox == nil { | ||
| 298 | return errNilWidget("headerBox") | 343 | return errNilWidget("headerBox") | ||
| @@ -315,18 +360,16 @@ func (o *Omnibox) createWidgets() error { | |||||
| 315 | o.favoritesBtn.AddCssClass("omnibox-header-btn") | 360 | o.favoritesBtn.AddCssClass("omnibox-header-btn") | ||
| 316 | o.favoritesBtn.SetCanFocus(false) | 361 | o.favoritesBtn.SetCanFocus(false) | ||
| 317 | 362 | ||||
| 318 | - | // Connect header button clicks | 363 | + | historyClickCb := func(_ gtk.Button) { |
| 319 | - | historyClickCb := func(btn gtk.Button) { | |||
| 320 | o.setViewMode(ViewModeHistory) | 364 | o.setViewMode(ViewModeHistory) | ||
| 321 | } | 365 | } | ||
| 322 | o.historyBtn.ConnectClicked(&historyClickCb) | 366 | o.historyBtn.ConnectClicked(&historyClickCb) | ||
| 323 | 367 | ||||
| 324 | - | favoritesClickCb := func(btn gtk.Button) { | 368 | + | favoritesClickCb := func(_ gtk.Button) { |
| 325 | o.setViewMode(ViewModeFavorites) | 369 | o.setViewMode(ViewModeFavorites) | ||
| 326 | } | 370 | } | ||
| 327 | o.favoritesBtn.ConnectClicked(&favoritesClickCb) | 371 | o.favoritesBtn.ConnectClicked(&favoritesClickCb) | ||
| 328 | 372 | ||||
| 329 | - | // Zoom indicator label (shown when zoom != 100%) | |||
| 330 | o.zoomLabel = gtk.NewLabel(nil) | 373 | o.zoomLabel = gtk.NewLabel(nil) | ||
| 331 | if o.zoomLabel != nil { | 374 | if o.zoomLabel != nil { | ||
| 332 | o.zoomLabel.AddCssClass("omnibox-zoom-indicator") | 375 | o.zoomLabel.AddCssClass("omnibox-zoom-indicator") | ||
| @@ -334,14 +377,10 @@ func (o *Omnibox) createWidgets() error { | |||||
| 334 | o.zoomLabel.SetHalign(gtk.AlignEndValue) | 377 | o.zoomLabel.SetHalign(gtk.AlignEndValue) | ||
| 335 | o.zoomLabel.SetHexpand(true) | 378 | o.zoomLabel.SetHexpand(true) | ||
| 336 | } | 379 | } | ||
| 380 | + | return nil | |||
| 381 | + | } | |||
| 337 | 382 | ||||
| 338 | - | o.headerBox.Append(&o.historyBtn.Widget) | 383 | + | func (o *Omnibox) initEntry() error { |
| 339 | - | o.headerBox.Append(&o.favoritesBtn.Widget) | |||
| 340 | - | if o.zoomLabel != nil { | |||
| 341 | - | o.headerBox.Append(&o.zoomLabel.Widget) | |||
| 342 | - | } | |||
| 343 | - | ||||
| 344 | - | // Search entry field | |||
| 345 | o.entry = gtk.NewSearchEntry() | 384 | o.entry = gtk.NewSearchEntry() | ||
| 346 | if o.entry == nil { | 385 | if o.entry == nil { | ||
| 347 | return errNilWidget("entry") | 386 | return errNilWidget("entry") | ||
| @@ -351,8 +390,10 @@ func (o *Omnibox) createWidgets() error { | |||||
| 351 | 390 | ||||
| 352 | placeholder := "Search history or enter URL..." | 391 | placeholder := "Search history or enter URL..." | ||
| 353 | o.entry.SetPlaceholderText(&placeholder) | 392 | o.entry.SetPlaceholderText(&placeholder) | ||
| 393 | + | return nil | |||
| 394 | + | } | |||
| 354 | 395 | ||||
| 355 | - | // Scrolled window for list | 396 | + | func (o *Omnibox) initList() error { |
| 356 | o.scrolledWin = gtk.NewScrolledWindow() | 397 | o.scrolledWin = gtk.NewScrolledWindow() | ||
| 357 | if o.scrolledWin == nil { | 398 | if o.scrolledWin == nil { | ||
| 358 | return errNilWidget("scrolledWin") | 399 | return errNilWidget("scrolledWin") | ||
| @@ -360,10 +401,8 @@ func (o *Omnibox) createWidgets() error { | |||||
| 360 | o.scrolledWin.AddCssClass("omnibox-scrolled") | 401 | o.scrolledWin.AddCssClass("omnibox-scrolled") | ||
| 361 | o.scrolledWin.SetVexpand(true) | 402 | o.scrolledWin.SetVexpand(true) | ||
| 362 | o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | 403 | o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | ||
| 363 | - | // Let GTK handle natural sizing - propagate child height up to max | |||
| 364 | o.scrolledWin.SetPropagateNaturalHeight(true) | 404 | o.scrolledWin.SetPropagateNaturalHeight(true) | ||
| 365 | 405 | ||||
| 366 | - | // List box for suggestions | |||
| 367 | o.listBox = gtk.NewListBox() | 406 | o.listBox = gtk.NewListBox() | ||
| 368 | if o.listBox == nil { | 407 | if o.listBox == nil { | ||
| 369 | return errNilWidget("listBox") | 408 | return errNilWidget("listBox") | ||
| @@ -371,8 +410,7 @@ func (o *Omnibox) createWidgets() error { | |||||
| 371 | o.listBox.AddCssClass("omnibox-listbox") | 410 | o.listBox.AddCssClass("omnibox-listbox") | ||
| 372 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | 411 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | ||
| 373 | 412 | ||||
| 374 | - | // Connect row selection | 413 | + | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { |
| 375 | - | rowSelectedCb := func(lb gtk.ListBox, rowPtr uintptr) { | |||
| 376 | if rowPtr == 0 { | 414 | if rowPtr == 0 { | ||
| 377 | o.mu.Lock() | 415 | o.mu.Lock() | ||
| 378 | o.selectedIndex = -1 | 416 | o.selectedIndex = -1 | ||
| @@ -387,35 +425,21 @@ func (o *Omnibox) createWidgets() error { | |||||
| 387 | } | 425 | } | ||
| 388 | } | 426 | } | ||
| 389 | o.listBox.ConnectRowSelected(&rowSelectedCb) | 427 | o.listBox.ConnectRowSelected(&rowSelectedCb) | ||
| 428 | + | return nil | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | func (o *Omnibox) assembleWidgets() { | |||
| 432 | + | o.headerBox.Append(&o.historyBtn.Widget) | |||
| 433 | + | o.headerBox.Append(&o.favoritesBtn.Widget) | |||
| 434 | + | if o.zoomLabel != nil { | |||
| 435 | + | o.headerBox.Append(&o.zoomLabel.Widget) | |||
| 436 | + | } | |||
| 390 | 437 | ||||
| 391 | - | // Assemble hierarchy | |||
| 392 | o.scrolledWin.SetChild(&o.listBox.Widget) | 438 | o.scrolledWin.SetChild(&o.listBox.Widget) | ||
| 393 | o.mainBox.Append(&o.headerBox.Widget) | 439 | o.mainBox.Append(&o.headerBox.Widget) | ||
| 394 | o.mainBox.Append(&o.entry.Widget) | 440 | o.mainBox.Append(&o.entry.Widget) | ||
| 395 | o.mainBox.Append(&o.scrolledWin.Widget) | 441 | o.mainBox.Append(&o.scrolledWin.Widget) | ||
| 396 | o.outerBox.Append(&o.mainBox.Widget) | 442 | o.outerBox.Append(&o.mainBox.Widget) | ||
| 397 | - | ||||
| 398 | - | return nil | |||
| 399 | - | } | |||
| 400 | - | ||||
| 401 | - | // setupKeyboardHandling adds keyboard event handling. | |||
| 402 | - | func (o *Omnibox) setupKeyboardHandling() { | |||
| 403 | - | log := logging.FromContext(o.ctx) | |||
| 404 | - | controller := gtk.NewEventControllerKey() | |||
| 405 | - | if controller == nil { | |||
| 406 | - | log.Error().Msg("failed to create event controller key") | |||
| 407 | - | return | |||
| 408 | - | } | |||
| 409 | - | ||||
| 410 | - | // Set capture phase to intercept before entry | |||
| 411 | - | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | |||
| 412 | - | ||||
| 413 | - | keyPressedCb := func(ctrl gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool { | |||
| 414 | - | return o.handleKeyPress(keyval, state) | |||
| 415 | - | } | |||
| 416 | - | controller.ConnectKeyPressed(&keyPressedCb) | |||
| 417 | - | ||||
| 418 | - | o.outerBox.AddController(&controller.EventController) | |||
| 419 | } | 443 | } | ||
| 420 | 444 | ||||
| 421 | // setupEntryChanged wires entry text changes to debounced search. | 445 | // setupEntryChanged wires entry text changes to debounced search. | ||
| @@ -484,14 +508,16 @@ func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool { | |||||
| 484 | case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5), | 508 | case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5), | ||
| 485 | uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9): | 509 | uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9): | ||
| 486 | if ctrl { | 510 | if ctrl { | ||
| 487 | - | index := int(keyval - uint(gdk.KEY_1)) // 0-8 | 511 | + | const firstShortcutKey = uint(gdk.KEY_1) |
| 512 | + | index := int(keyval - firstShortcutKey) // 0-8 | |||
| 488 | o.selectAndNavigate(index) | 513 | o.selectAndNavigate(index) | ||
| 489 | return true | 514 | return true | ||
| 490 | } | 515 | } | ||
| 491 | 516 | ||||
| 492 | case uint(gdk.KEY_0): | 517 | case uint(gdk.KEY_0): | ||
| 493 | if ctrl { | 518 | if ctrl { | ||
| 494 | - | o.selectAndNavigate(9) // 10th item | 519 | + | const tenthItemIndex = 9 |
| 520 | + | o.selectAndNavigate(tenthItemIndex) | |||
| 495 | return true | 521 | return true | ||
| 496 | } | 522 | } | ||
| 497 | } | 523 | } | ||
| @@ -597,11 +623,8 @@ func (o *Omnibox) loadInitialHistory() { | |||||
| 597 | o.idleAddUpdateSuggestions(nil) | 623 | o.idleAddUpdateSuggestions(nil) | ||
| 598 | return | 624 | return | ||
| 599 | 625 | ||||
| 600 | - | case "most_visited": | 626 | + | case "most_visited", "recent", "": |
| 601 | // TODO: Implement GetMostVisited in use case if needed | 627 | // TODO: Implement GetMostVisited in use case if needed | ||
| 602 | - | fallthrough | |||
| 603 | - | ||||
| 604 | - | case "recent", "": | |||
| 605 | results, err := o.historyUC.GetRecent(ctx, omniboxMaxResults, 0) | 628 | results, err := o.historyUC.GetRecent(ctx, omniboxMaxResults, 0) | ||
| 606 | if err != nil { | 629 | if err != nil { | ||
| 607 | log.Error().Err(err).Msg("failed to load recent history") | 630 | log.Error().Err(err).Msg("failed to load recent history") | ||
| @@ -759,7 +782,7 @@ func (o *Omnibox) rebuildList() { | |||||
| 759 | } | 782 | } | ||
| 760 | 783 | ||||
| 761 | // createFaviconImage creates a favicon image with async loading from cache. | 784 | // createFaviconImage creates a favicon image with async loading from cache. | ||
| 762 | - | func (o *Omnibox) createFaviconImage(url, fallbackIcon string) *gtk.Image { | 785 | + | func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image { |
| 763 | favicon := gtk.NewImage() | 786 | favicon := gtk.NewImage() | ||
| 764 | if favicon == nil { | 787 | if favicon == nil { | ||
| 765 | return nil | 788 | return nil | ||
| @@ -769,8 +792,8 @@ func (o *Omnibox) createFaviconImage(url, fallbackIcon string) *gtk.Image { | |||||
| 769 | favicon.AddCssClass("omnibox-favicon") | 792 | favicon.AddCssClass("omnibox-favicon") | ||
| 770 | 793 | ||||
| 771 | // Async load favicon from cache | 794 | // Async load favicon from cache | ||
| 772 | - | if o.faviconAdapter != nil && url != "" { | 795 | + | if o.faviconAdapter != nil && rawURL != "" { |
| 773 | - | o.faviconAdapter.GetOrFetch(o.ctx, url, func(texture *gdk.Texture) { | 796 | + | o.faviconAdapter.GetOrFetch(o.ctx, rawURL, func(texture *gdk.Texture) { |
| 774 | if texture != nil { | 797 | if texture != nil { | ||
| 775 | var cb glib.SourceFunc = func(data uintptr) bool { | 798 | var cb glib.SourceFunc = func(data uintptr) bool { | ||
| 776 | favicon.SetFromPaintable(texture) | 799 | favicon.SetFromPaintable(texture) | ||
| @@ -785,21 +808,22 @@ func (o *Omnibox) createFaviconImage(url, fallbackIcon string) *gtk.Image { | |||||
| 785 | } | 808 | } | ||
| 786 | 809 | ||||
| 787 | // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge. | 810 | // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge. | ||
| 788 | - | func (o *Omnibox) createRowWithFavicon(url, title, fallbackIcon string, index int) *gtk.ListBoxRow { | 811 | + | func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow { |
| 789 | row := gtk.NewListBoxRow() | 812 | row := gtk.NewListBoxRow() | ||
| 790 | if row == nil { | 813 | if row == nil { | ||
| 791 | return nil | 814 | return nil | ||
| 792 | } | 815 | } | ||
| 793 | row.AddCssClass("omnibox-row") | 816 | row.AddCssClass("omnibox-row") | ||
| 794 | 817 | ||||
| 795 | - | hbox := gtk.NewBox(gtk.OrientationHorizontalValue, 8) | 818 | + | const rowSpacing = 8 |
| 819 | + | hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing) | |||
| 796 | if hbox == nil { | 820 | if hbox == nil { | ||
| 797 | return nil | 821 | return nil | ||
| 798 | } | 822 | } | ||
| 799 | hbox.SetHexpand(true) | 823 | hbox.SetHexpand(true) | ||
| 800 | 824 | ||||
| 801 | // Favicon image (vertically centered) | 825 | // Favicon image (vertically centered) | ||
| 802 | - | if favicon := o.createFaviconImage(url, fallbackIcon); favicon != nil { | 826 | + | if favicon := o.createFaviconImage(rawURL, fallbackIcon); favicon != nil { |
| 803 | favicon.SetValign(gtk.AlignCenterValue) | 827 | favicon.SetValign(gtk.AlignCenterValue) | ||
| 804 | hbox.Append(&favicon.Widget) | 828 | hbox.Append(&favicon.Widget) | ||
| 805 | } | 829 | } | ||
| @@ -815,7 +839,7 @@ func (o *Omnibox) createRowWithFavicon(url, title, fallbackIcon string, index in | |||||
| 815 | // Title label (or URL if no title) | 839 | // Title label (or URL if no title) | ||
| 816 | displayTitle := title | 840 | displayTitle := title | ||
| 817 | if displayTitle == "" { | 841 | if displayTitle == "" { | ||
| 818 | - | displayTitle = url | 842 | + | displayTitle = rawURL |
| 819 | } | 843 | } | ||
| 820 | titleLabel := gtk.NewLabel(nil) | 844 | titleLabel := gtk.NewLabel(nil) | ||
| 821 | if titleLabel != nil { | 845 | if titleLabel != nil { | ||
| @@ -827,10 +851,10 @@ func (o *Omnibox) createRowWithFavicon(url, title, fallbackIcon string, index in | |||||
| 827 | } | 851 | } | ||
| 828 | 852 | ||||
| 829 | // URL label (only if title exists and differs from URL) | 853 | // URL label (only if title exists and differs from URL) | ||
| 830 | - | if title != "" && title != url { | 854 | + | if title != "" && title != rawURL { |
| 831 | urlLabel := gtk.NewLabel(nil) | 855 | urlLabel := gtk.NewLabel(nil) | ||
| 832 | if urlLabel != nil { | 856 | if urlLabel != nil { | ||
| 833 | - | urlLabel.SetText(url) | 857 | + | urlLabel.SetText(rawURL) |
| 834 | urlLabel.AddCssClass("omnibox-suggestion-url") | 858 | urlLabel.AddCssClass("omnibox-suggestion-url") | ||
| 835 | urlLabel.SetHalign(gtk.AlignStartValue) | 859 | urlLabel.SetHalign(gtk.AlignStartValue) | ||
| 836 | urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | 860 | urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | ||
| @@ -841,10 +865,11 @@ func (o *Omnibox) createRowWithFavicon(url, title, fallbackIcon string, index in | |||||
| 841 | hbox.Append(&textBox.Widget) | 865 | hbox.Append(&textBox.Widget) | ||
| 842 | 866 | ||||
| 843 | // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th) | 867 | // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th) | ||
| 844 | - | if index <= 9 { | 868 | + | const maxShortcutIndex = 9 |
| 869 | + | if index <= maxShortcutIndex { | |||
| 845 | shortcutLabel := gtk.NewLabel(nil) | 870 | shortcutLabel := gtk.NewLabel(nil) | ||
| 846 | if shortcutLabel != nil { | 871 | if shortcutLabel != nil { | ||
| 847 | - | if index < 9 { | 872 | + | if index < maxShortcutIndex { |
| 848 | shortcutLabel.SetText(formatShortcut(index + 1)) | 873 | shortcutLabel.SetText(formatShortcut(index + 1)) | ||
| 849 | } else { | 874 | } else { | ||
| 850 | shortcutLabel.SetText("Ctrl+0") | 875 | shortcutLabel.SetText("Ctrl+0") | ||
| @@ -947,33 +972,33 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 947 | o.mu.RUnlock() | 972 | o.mu.RUnlock() | ||
| 948 | 973 | ||||
| 949 | entryText := o.entry.GetText() | 974 | entryText := o.entry.GetText() | ||
| 950 | - | var url string | 975 | + | var targetURL string |
| 951 | 976 | ||||
| 952 | // Check if user typed a URL-like string (contains . and no spaces) | 977 | // Check if user typed a URL-like string (contains . and no spaces) | ||
| 953 | // If so, navigate to that URL directly instead of the selection | 978 | // If so, navigate to that URL directly instead of the selection | ||
| 954 | if o.looksLikeURL(entryText) { | 979 | if o.looksLikeURL(entryText) { | ||
| 955 | - | url = o.buildURL(entryText) | 980 | + | targetURL = o.buildURL(entryText) |
| 956 | } else if idx < 0 { | 981 | } else if idx < 0 { | ||
| 957 | // No selection - use entry text as URL/search | 982 | // No selection - use entry text as URL/search | ||
| 958 | - | url = o.buildURL(entryText) | 983 | + | targetURL = o.buildURL(entryText) |
| 959 | } else if mode == ViewModeHistory { | 984 | } else if mode == ViewModeHistory { | ||
| 960 | if idx < len(suggestions) { | 985 | if idx < len(suggestions) { | ||
| 961 | - | url = suggestions[idx].URL | 986 | + | targetURL = suggestions[idx].URL |
| 962 | } | 987 | } | ||
| 963 | } else { | 988 | } else { | ||
| 964 | if idx < len(favorites) { | 989 | if idx < len(favorites) { | ||
| 965 | - | url = favorites[idx].URL | 990 | + | targetURL = favorites[idx].URL |
| 966 | } | 991 | } | ||
| 967 | } | 992 | } | ||
| 968 | 993 | ||||
| 969 | - | if url == "" { | 994 | + | if targetURL == "" { |
| 970 | return | 995 | return | ||
| 971 | } | 996 | } | ||
| 972 | 997 | ||||
| 973 | o.Hide(o.ctx) | 998 | o.Hide(o.ctx) | ||
| 974 | 999 | ||||
| 975 | if o.onNavigate != nil { | 1000 | if o.onNavigate != nil { | ||
| 976 | - | o.onNavigate(url) | 1001 | + | o.onNavigate(targetURL) |
| 977 | } | 1002 | } | ||
| 978 | } | 1003 | } | ||
| 979 | 1004 | ||||
| @@ -1016,9 +1041,9 @@ func (o *Omnibox) toggleFavorite() { | |||||
| 1016 | 1041 | ||||
| 1017 | go func() { | 1042 | go func() { | ||
| 1018 | ctx := o.ctx | 1043 | ctx := o.ctx | ||
| 1019 | - | log := logging.FromContext(ctx) | 1044 | + | goLog := logging.FromContext(ctx) |
| 1020 | 1045 | ||||
| 1021 | - | log.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites") | 1046 | + | goLog.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites") |
| 1022 | 1047 | ||||
| 1023 | input := usecase.AddFavoriteInput{ | 1048 | input := usecase.AddFavoriteInput{ | ||
| 1024 | URL: s.URL, | 1049 | URL: s.URL, | ||
| @@ -1027,11 +1052,11 @@ func (o *Omnibox) toggleFavorite() { | |||||
| 1027 | 1052 | ||||
| 1028 | fav, err := o.favoritesUC.Add(ctx, input) | 1053 | fav, err := o.favoritesUC.Add(ctx, input) | ||
| 1029 | if err != nil { | 1054 | if err != nil { | ||
| 1030 | - | log.Error().Err(err).Str("url", s.URL).Msg("failed to add favorite") | 1055 | + | goLog.Error().Err(err).Str("url", s.URL).Msg("failed to add favorite") |
| 1031 | return | 1056 | return | ||
| 1032 | } | 1057 | } | ||
| 1033 | 1058 | ||||
| 1034 | - | log.Info().Str("url", s.URL).Int64("id", int64(fav.ID)).Msg("favorite added from omnibox") | 1059 | + | goLog.Info().Str("url", s.URL).Int64("id", int64(fav.ID)).Msg("favorite added from omnibox") |
| 1035 | }() | 1060 | }() | ||
| 1036 | } else { | 1061 | } else { | ||
| 1037 | // Remove from favorites | 1062 | // Remove from favorites | ||
| @@ -1082,39 +1107,38 @@ func (o *Omnibox) yankSelectedURL() { | |||||
| 1082 | favorites := o.favorites | 1107 | favorites := o.favorites | ||
| 1083 | o.mu.RUnlock() | 1108 | o.mu.RUnlock() | ||
| 1084 | 1109 | ||||
| 1085 | - | var url string | 1110 | + | var selectedURL string |
| 1086 | if mode == ViewModeHistory { | 1111 | if mode == ViewModeHistory { | ||
| 1087 | if idx < 0 || idx >= len(suggestions) { | 1112 | if idx < 0 || idx >= len(suggestions) { | ||
| 1088 | log.Debug().Int("index", idx).Msg("yank URL: invalid selection") | 1113 | log.Debug().Int("index", idx).Msg("yank URL: invalid selection") | ||
| 1089 | return | 1114 | return | ||
| 1090 | } | 1115 | } | ||
| 1091 | - | url = suggestions[idx].URL | 1116 | + | selectedURL = suggestions[idx].URL |
| 1092 | } else { | 1117 | } else { | ||
| 1093 | if idx < 0 || idx >= len(favorites) { | 1118 | if idx < 0 || idx >= len(favorites) { | ||
| 1094 | log.Debug().Int("index", idx).Msg("yank URL: invalid selection") | 1119 | log.Debug().Int("index", idx).Msg("yank URL: invalid selection") | ||
| 1095 | return | 1120 | return | ||
| 1096 | } | 1121 | } | ||
| 1097 | - | url = favorites[idx].URL | 1122 | + | selectedURL = favorites[idx].URL |
| 1098 | } | 1123 | } | ||
| 1099 | 1124 | ||||
| 1100 | - | if url == "" { | 1125 | + | if selectedURL == "" { |
| 1101 | log.Debug().Msg("yank URL: empty URL") | 1126 | log.Debug().Msg("yank URL: empty URL") | ||
| 1102 | return | 1127 | return | ||
| 1103 | } | 1128 | } | ||
| 1104 | 1129 | ||||
| 1105 | go func() { | 1130 | go func() { | ||
| 1106 | ctx := o.ctx | 1131 | ctx := o.ctx | ||
| 1107 | - | if err := o.copyURLUC.Copy(ctx, url); err != nil { | 1132 | + | if err := o.copyURLUC.Copy(ctx, selectedURL); err != nil { |
| 1108 | return // Use case already logs the error | 1133 | return // Use case already logs the error | ||
| 1109 | } | 1134 | } | ||
| 1110 | 1135 | ||||
| 1111 | // Show toast notification on success (must run on GTK main thread) | 1136 | // Show toast notification on success (must run on GTK main thread) | ||
| 1112 | if o.onToast != nil { | 1137 | if o.onToast != nil { | ||
| 1113 | - | var cb glib.SourceFunc | 1138 | + | cb := glib.SourceFunc(func(_ uintptr) bool { |
| 1114 | - | cb = func(_ uintptr) bool { | |||
| 1115 | o.onToast("URL copied") | 1139 | o.onToast("URL copied") | ||
| 1116 | return false // Don't repeat | 1140 | return false // Don't repeat | ||
| 1117 | - | } | 1141 | + | }) |
| 1118 | glib.IdleAdd(&cb, 0) | 1142 | glib.IdleAdd(&cb, 0) | ||
| 1119 | } | 1143 | } | ||
| 1120 | }() | 1144 | }() | ||
| @@ -1201,7 +1225,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||||
| 1201 | if width > omniboxMaxWidth { | 1225 | if width > omniboxMaxWidth { | ||
| 1202 | width = omniboxMaxWidth | 1226 | width = omniboxMaxWidth | ||
| 1203 | } | 1227 | } | ||
| 1204 | - | marginTop := int(float64(parentHeight) * 0.20) // 20% from top | 1228 | + | marginTop := int(float64(parentHeight) * omniboxTopMarginPct) |
| 1205 | 1229 | ||||
| 1206 | o.mainBox.SetSizeRequest(width, -1) | 1230 | o.mainBox.SetSizeRequest(width, -1) | ||
| 1207 | o.outerBox.SetMarginTop(marginTop) | 1231 | o.outerBox.SetMarginTop(marginTop) | ||
M internal/ui/component/progress_bar.go +2 -3
| @@ -94,8 +94,7 @@ func (pb *ProgressBar) SetProgress(progress float64) { | |||||
| 94 | // startAnimation begins the smooth progress animation. | 94 | // startAnimation begins the smooth progress animation. | ||
| 95 | // Must be called with lock held. | 95 | // Must be called with lock held. | ||
| 96 | func (pb *ProgressBar) startAnimation() { | 96 | func (pb *ProgressBar) startAnimation() { | ||
| 97 | - | var cb glib.SourceFunc | 97 | + | cb := glib.SourceFunc(func(_ uintptr) bool { |
| 98 | - | cb = func(_ uintptr) bool { | |||
| 99 | pb.mu.Lock() | 98 | pb.mu.Lock() | ||
| 100 | defer pb.mu.Unlock() | 99 | defer pb.mu.Unlock() | ||
| 101 | 100 | ||||
| @@ -119,7 +118,7 @@ func (pb *ProgressBar) startAnimation() { | |||||
| 119 | return false | 118 | return false | ||
| 120 | } | 119 | } | ||
| 121 | return true | 120 | return true | ||
| 122 | - | } | 121 | + | }) |
| 123 | 122 | ||||
| 124 | pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0) | 123 | pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0) | ||
| 125 | } | 124 | } | ||
M internal/ui/component/tab_button.go +2 -1
| @@ -46,8 +46,9 @@ func NewTabButton(tab *entity.Tab) *TabButton { | |||||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | // Configure label for text overflow | 48 | // Configure label for text overflow | ||
| 49 | + | const maxTabTitleChars = 20 | |||
| 49 | tb.label.SetEllipsize(pango.EllipsizeMiddleValue) | 50 | tb.label.SetEllipsize(pango.EllipsizeMiddleValue) | ||
| 50 | - | tb.label.SetMaxWidthChars(20) | 51 | + | tb.label.SetMaxWidthChars(maxTabTitleChars) |
| 51 | tb.label.AddCssClass("tab-title") | 52 | tb.label.AddCssClass("tab-title") | ||
| 52 | 53 | ||||
| 53 | // Set label as button child | 54 | // Set label as button child | ||
M internal/ui/component/toaster.go +2 -3
| @@ -176,8 +176,7 @@ func (t *Toaster) hide() { | |||||
| 176 | func (t *Toaster) startDismissTimer(ctx context.Context) { | 176 | func (t *Toaster) startDismissTimer(ctx context.Context) { | ||
| 177 | log := logging.FromContext(ctx) | 177 | log := logging.FromContext(ctx) | ||
| 178 | 178 | ||||
| 179 | - | var cb glib.SourceFunc | 179 | + | cb := glib.SourceFunc(func(_ uintptr) bool { |
| 180 | - | cb = func(_ uintptr) bool { | |||
| 181 | t.mu.Lock() | 180 | t.mu.Lock() | ||
| 182 | defer t.mu.Unlock() | 181 | defer t.mu.Unlock() | ||
| 183 | 182 | ||||
| @@ -187,7 +186,7 @@ func (t *Toaster) startDismissTimer(ctx context.Context) { | |||||
| 187 | 186 | ||||
| 188 | log.Debug().Msg("toast auto-dismissed") | 187 | log.Debug().Msg("toast auto-dismissed") | ||
| 189 | return false // Don't repeat | 188 | return false // Don't repeat | ||
| 190 | - | } | 189 | + | }) |
| 191 | 190 | ||||
| 192 | t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0) | 191 | t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0) | ||
| 193 | } | 192 | } | ||
M internal/ui/component/workspace_view.go +4 -1
| @@ -4,6 +4,7 @@ package component | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "fmt" | |||
| 7 | "sync" | 8 | "sync" | ||
| 8 | 9 | ||||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -167,7 +168,9 @@ func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace) | |||||
| 167 | 168 | ||||
| 168 | // Set initial active pane | 169 | // Set initial active pane | ||
| 169 | if ws.ActivePaneID != "" { | 170 | if ws.ActivePaneID != "" { | ||
| 170 | - | wv.setActivePaneIDInternal(ws.ActivePaneID) | 171 | + | if err := wv.setActivePaneIDInternal(ws.ActivePaneID); err != nil { |
| 172 | + | return fmt.Errorf("set initial active pane: %w", err) | |||
| 173 | + | } | |||
| 171 | } | 174 | } | ||
| 172 | 175 | ||||
| 173 | return nil | 176 | return nil | ||
M internal/ui/component/workspace_view_test.go +12 -12
| @@ -14,7 +14,7 @@ import ( | |||||
| 14 | "github.com/bnema/dumber/internal/ui/layout/mocks" | 14 | "github.com/bnema/dumber/internal/ui/layout/mocks" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | - | func setupWorkspaceViewBase(t *testing.T, mockFactory *mocks.MockWidgetFactory) (context.Context, *mocks.MockBoxWidget, *mocks.MockOverlayWidget) { | 17 | + | func setupWorkspaceViewBase(t *testing.T, mockFactory *mocks.MockWidgetFactory) (context.Context, *mocks.MockBoxWidget) { |
| 18 | ctx := context.Background() | 18 | ctx := context.Background() | ||
| 19 | mockContainer := mocks.NewMockBoxWidget(t) | 19 | mockContainer := mocks.NewMockBoxWidget(t) | ||
| 20 | mockOverlay := mocks.NewMockOverlayWidget(t) | 20 | mockOverlay := mocks.NewMockOverlayWidget(t) | ||
| @@ -30,7 +30,7 @@ func setupWorkspaceViewBase(t *testing.T, mockFactory *mocks.MockWidgetFactory) | |||||
| 30 | mockOverlay.EXPECT().SetChild(mockContainer).Once() | 30 | mockOverlay.EXPECT().SetChild(mockContainer).Once() | ||
| 31 | mockOverlay.EXPECT().SetVisible(true).Once() | 31 | mockOverlay.EXPECT().SetVisible(true).Once() | ||
| 32 | 32 | ||||
| 33 | - | return ctx, mockContainer, mockOverlay | 33 | + | return ctx, mockContainer |
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory) (*mocks.MockOverlayWidget, *mocks.MockBoxWidget) { | 36 | func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory) (*mocks.MockOverlayWidget, *mocks.MockBoxWidget) { | ||
| @@ -76,7 +76,7 @@ func setupStackedLeafMocks( | |||||
| 76 | t *testing.T, | 76 | t *testing.T, | ||
| 77 | mockFactory *mocks.MockWidgetFactory, | 77 | mockFactory *mocks.MockWidgetFactory, | ||
| 78 | container *mocks.MockOverlayWidget, | 78 | container *mocks.MockOverlayWidget, | ||
| 79 | - | ) (*mocks.MockBoxWidget, *mocks.MockBoxWidget) { | 79 | + | ) *mocks.MockBoxWidget { |
| 80 | mockStackBox := mocks.NewMockBoxWidget(t) | 80 | mockStackBox := mocks.NewMockBoxWidget(t) | ||
| 81 | mockTitleBar := mocks.NewMockBoxWidget(t) | 81 | mockTitleBar := mocks.NewMockBoxWidget(t) | ||
| 82 | mockFavicon := mocks.NewMockImageWidget(t) | 82 | mockFavicon := mocks.NewMockImageWidget(t) | ||
| @@ -118,13 +118,13 @@ func setupStackedLeafMocks( | |||||
| 118 | container.EXPECT().SetVisible(true).Once() | 118 | container.EXPECT().SetVisible(true).Once() | ||
| 119 | mockTitleBar.EXPECT().AddCssClass("active").Once() | 119 | mockTitleBar.EXPECT().AddCssClass("active").Once() | ||
| 120 | 120 | ||||
| 121 | - | return mockStackBox, mockTitleBar | 121 | + | return mockStackBox |
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| 124 | func TestNewWorkspaceView_CreatesEmptyContainer(t *testing.T) { | 124 | func TestNewWorkspaceView_CreatesEmptyContainer(t *testing.T) { | ||
| 125 | // Arrange | 125 | // Arrange | ||
| 126 | mockFactory := mocks.NewMockWidgetFactory(t) | 126 | mockFactory := mocks.NewMockWidgetFactory(t) | ||
| 127 | - | ctx, mockBox, _ := setupWorkspaceViewBase(t, mockFactory) | 127 | + | ctx, mockBox := setupWorkspaceViewBase(t, mockFactory) |
| 128 | 128 | ||||
| 129 | // Act | 129 | // Act | ||
| 130 | wv := component.NewWorkspaceView(ctx, mockFactory) | 130 | wv := component.NewWorkspaceView(ctx, mockFactory) | ||
| @@ -138,7 +138,7 @@ func TestNewWorkspaceView_CreatesEmptyContainer(t *testing.T) { | |||||
| 138 | func TestSetWorkspace_NilWorkspace_ReturnsError(t *testing.T) { | 138 | func TestSetWorkspace_NilWorkspace_ReturnsError(t *testing.T) { | ||
| 139 | // Arrange | 139 | // Arrange | ||
| 140 | mockFactory := mocks.NewMockWidgetFactory(t) | 140 | mockFactory := mocks.NewMockWidgetFactory(t) | ||
| 141 | - | ctx, _, _ := setupWorkspaceViewBase(t, mockFactory) | 141 | + | ctx, _ := setupWorkspaceViewBase(t, mockFactory) |
| 142 | 142 | ||||
| 143 | wv := component.NewWorkspaceView(ctx, mockFactory) | 143 | wv := component.NewWorkspaceView(ctx, mockFactory) | ||
| 144 | 144 | ||||
| @@ -152,12 +152,12 @@ func TestSetWorkspace_NilWorkspace_ReturnsError(t *testing.T) { | |||||
| 152 | func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) { | 152 | func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) { | ||
| 153 | // Arrange | 153 | // Arrange | ||
| 154 | mockFactory := mocks.NewMockWidgetFactory(t) | 154 | mockFactory := mocks.NewMockWidgetFactory(t) | ||
| 155 | - | ctx, mockBox, _ := setupWorkspaceViewBase(t, mockFactory) | 155 | + | ctx, mockBox := setupWorkspaceViewBase(t, mockFactory) |
| 156 | 156 | ||||
| 157 | wv := component.NewWorkspaceView(ctx, mockFactory) | 157 | wv := component.NewWorkspaceView(ctx, mockFactory) | ||
| 158 | 158 | ||||
| 159 | mockOverlay, mockBorderBox := setupWorkspacePaneViewMocks(t, mockFactory) | 159 | mockOverlay, mockBorderBox := setupWorkspacePaneViewMocks(t, mockFactory) | ||
| 160 | - | mockStackBox, _ := setupStackedLeafMocks(t, mockFactory, mockOverlay) | 160 | + | mockStackBox := setupStackedLeafMocks(t, mockFactory, mockOverlay) |
| 161 | 161 | ||||
| 162 | mockStackBox.EXPECT().SetVisible(true).Once() | 162 | mockStackBox.EXPECT().SetVisible(true).Once() | ||
| 163 | mockBox.EXPECT().Append(mockStackBox).Once() | 163 | mockBox.EXPECT().Append(mockStackBox).Once() | ||
| @@ -185,7 +185,7 @@ func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) { | |||||
| 185 | func TestSetActivePaneID_UpdatesStyling(t *testing.T) { | 185 | func TestSetActivePaneID_UpdatesStyling(t *testing.T) { | ||
| 186 | // Arrange | 186 | // Arrange | ||
| 187 | mockFactory := mocks.NewMockWidgetFactory(t) | 187 | mockFactory := mocks.NewMockWidgetFactory(t) | ||
| 188 | - | ctx, mockBox, _ := setupWorkspaceViewBase(t, mockFactory) | 188 | + | ctx, mockBox := setupWorkspaceViewBase(t, mockFactory) |
| 189 | 189 | ||||
| 190 | wv := component.NewWorkspaceView(ctx, mockFactory) | 190 | wv := component.NewWorkspaceView(ctx, mockFactory) | ||
| 191 | 191 | ||||
| @@ -198,9 +198,9 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) { | |||||
| 198 | // Create mock widgets for split | 198 | // Create mock widgets for split | ||
| 199 | mockPaned := mocks.NewMockPanedWidget(t) | 199 | mockPaned := mocks.NewMockPanedWidget(t) | ||
| 200 | mockOverlay1, mockBorderBox1 := setupWorkspacePaneViewMocks(t, mockFactory) | 200 | mockOverlay1, mockBorderBox1 := setupWorkspacePaneViewMocks(t, mockFactory) | ||
| 201 | - | mockStackBox1, _ := setupStackedLeafMocks(t, mockFactory, mockOverlay1) | 201 | + | mockStackBox1 := setupStackedLeafMocks(t, mockFactory, mockOverlay1) |
| 202 | mockOverlay2, mockBorderBox2 := setupWorkspacePaneViewMocks(t, mockFactory) | 202 | mockOverlay2, mockBorderBox2 := setupWorkspacePaneViewMocks(t, mockFactory) | ||
| 203 | - | mockStackBox2, _ := setupStackedLeafMocks(t, mockFactory, mockOverlay2) | 203 | + | mockStackBox2 := setupStackedLeafMocks(t, mockFactory, mockOverlay2) |
| 204 | 204 | ||||
| 205 | // Split view creation | 205 | // Split view creation | ||
| 206 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once() | 206 | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once() | ||
| @@ -251,7 +251,7 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) { | |||||
| 251 | func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) { | 251 | func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) { | ||
| 252 | // Arrange | 252 | // Arrange | ||
| 253 | mockFactory := mocks.NewMockWidgetFactory(t) | 253 | mockFactory := mocks.NewMockWidgetFactory(t) | ||
| 254 | - | ctx, _, _ := setupWorkspaceViewBase(t, mockFactory) | 254 | + | ctx, _ := setupWorkspaceViewBase(t, mockFactory) |
| 255 | 255 | ||||
| 256 | wv := component.NewWorkspaceView(ctx, mockFactory) | 256 | wv := component.NewWorkspaceView(ctx, mockFactory) | ||
| 257 | 257 | ||||
M internal/ui/coordinator/content.go +30 -18
| @@ -138,17 +138,17 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa | |||||
| 138 | wv.OnLoadChanged = func(event webkit.LoadEvent) { | 138 | wv.OnLoadChanged = func(event webkit.LoadEvent) { | ||
| 139 | switch event { | 139 | switch event { | ||
| 140 | case webkit.LoadStarted: | 140 | case webkit.LoadStarted: | ||
| 141 | - | c.onLoadStarted(ctx, paneID) | 141 | + | c.onLoadStarted(paneID) |
| 142 | case webkit.LoadCommitted: | 142 | case webkit.LoadCommitted: | ||
| 143 | c.onLoadCommitted(ctx, paneID, wv) | 143 | c.onLoadCommitted(ctx, paneID, wv) | ||
| 144 | case webkit.LoadFinished: | 144 | case webkit.LoadFinished: | ||
| 145 | - | c.onLoadFinished(ctx, paneID) | 145 | + | c.onLoadFinished(paneID) |
| 146 | } | 146 | } | ||
| 147 | } | 147 | } | ||
| 148 | 148 | ||||
| 149 | // Set up progress callback for loading indicator | 149 | // Set up progress callback for loading indicator | ||
| 150 | wv.OnProgressChanged = func(progress float64) { | 150 | wv.OnProgressChanged = func(progress float64) { | ||
| 151 | - | c.onProgressChanged(ctx, paneID, progress) | 151 | + | c.onProgressChanged(paneID, progress) |
| 152 | } | 152 | } | ||
| 153 | 153 | ||||
| 154 | // Set up URI change callback for SPA navigation (History API) | 154 | // Set up URI change callback for SPA navigation (History API) | ||
| @@ -483,7 +483,13 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P | |||||
| 483 | } | 483 | } | ||
| 484 | 484 | ||||
| 485 | // updateStackedPaneTitle updates the title of a pane in a StackedView. | 485 | // updateStackedPaneTitle updates the title of a pane in a StackedView. | ||
| 486 | - | func (c *ContentCoordinator) updateStackedPaneTitle(ctx context.Context, ws *entity.Workspace, sv *layout.StackedView, paneID entity.PaneID, title string) { | 486 | + | func (c *ContentCoordinator) updateStackedPaneTitle( |
| 487 | + | ctx context.Context, | |||
| 488 | + | ws *entity.Workspace, | |||
| 489 | + | sv *layout.StackedView, | |||
| 490 | + | paneID entity.PaneID, | |||
| 491 | + | title string, | |||
| 492 | + | ) { | |||
| 487 | log := logging.FromContext(ctx) | 493 | log := logging.FromContext(ctx) | ||
| 488 | 494 | ||||
| 489 | if ws == nil { | 495 | if ws == nil { | ||
| @@ -547,7 +553,13 @@ func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity | |||||
| 547 | } | 553 | } | ||
| 548 | 554 | ||||
| 549 | // updateStackedPaneFavicon updates the favicon of a pane in a StackedView. | 555 | // updateStackedPaneFavicon updates the favicon of a pane in a StackedView. | ||
| 550 | - | func (c *ContentCoordinator) updateStackedPaneFavicon(ctx context.Context, ws *entity.Workspace, sv *layout.StackedView, paneID entity.PaneID, favicon *gdk.Texture) { | 556 | + | func (c *ContentCoordinator) updateStackedPaneFavicon( |
| 557 | + | ctx context.Context, | |||
| 558 | + | ws *entity.Workspace, | |||
| 559 | + | sv *layout.StackedView, | |||
| 560 | + | paneID entity.PaneID, | |||
| 561 | + | favicon *gdk.Texture, | |||
| 562 | + | ) { | |||
| 551 | log := logging.FromContext(ctx) | 563 | log := logging.FromContext(ctx) | ||
| 552 | 564 | ||||
| 553 | if ws == nil { | 565 | if ws == nil { | ||
| @@ -653,7 +665,7 @@ func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity. | |||||
| 653 | } | 665 | } | ||
| 654 | 666 | ||||
| 655 | // onLoadStarted shows the progress bar when page loading begins. | 667 | // onLoadStarted shows the progress bar when page loading begins. | ||
| 656 | - | func (c *ContentCoordinator) onLoadStarted(ctx context.Context, paneID entity.PaneID) { | 668 | + | func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) { |
| 657 | _, wsView := c.getActiveWS() | 669 | _, wsView := c.getActiveWS() | ||
| 658 | if wsView == nil { | 670 | if wsView == nil { | ||
| 659 | return | 671 | return | ||
| @@ -666,7 +678,7 @@ func (c *ContentCoordinator) onLoadStarted(ctx context.Context, paneID entity.Pa | |||||
| 666 | } | 678 | } | ||
| 667 | 679 | ||||
| 668 | // onLoadFinished hides the progress bar when page loading completes. | 680 | // onLoadFinished hides the progress bar when page loading completes. | ||
| 669 | - | func (c *ContentCoordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID) { | 681 | + | func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) { |
| 670 | _, wsView := c.getActiveWS() | 682 | _, wsView := c.getActiveWS() | ||
| 671 | if wsView == nil { | 683 | if wsView == nil { | ||
| 672 | return | 684 | return | ||
| @@ -679,7 +691,7 @@ func (c *ContentCoordinator) onLoadFinished(ctx context.Context, paneID entity.P | |||||
| 679 | } | 691 | } | ||
| 680 | 692 | ||||
| 681 | // onProgressChanged updates the progress bar with current load progress. | 693 | // onProgressChanged updates the progress bar with current load progress. | ||
| 682 | - | func (c *ContentCoordinator) onProgressChanged(ctx context.Context, paneID entity.PaneID, progress float64) { | 694 | + | func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) { |
| 683 | _, wsView := c.getActiveWS() | 695 | _, wsView := c.getActiveWS() | ||
| 684 | if wsView == nil { | 696 | if wsView == nil { | ||
| 685 | return | 697 | return | ||
| @@ -761,7 +773,7 @@ func (c *ContentCoordinator) handlePopupCreate( | |||||
| 761 | } | 773 | } | ||
| 762 | 774 | ||||
| 763 | // Create related WebView for session sharing | 775 | // Create related WebView for session sharing | ||
| 764 | - | parentID := port.WebViewID(parentWV.ID()) | 776 | + | parentID := parentWV.ID() |
| 765 | popupWV, err := c.factory.CreateRelated(ctx, parentID) | 777 | popupWV, err := c.factory.CreateRelated(ctx, parentID) | ||
| 766 | if err != nil { | 778 | if err != nil { | ||
| 767 | log.Error().Err(err).Msg("failed to create related webview for popup") | 779 | log.Error().Err(err).Msg("failed to create related webview for popup") | ||
| @@ -772,7 +784,7 @@ func (c *ContentCoordinator) handlePopupCreate( | |||||
| 772 | popupType := DetectPopupType(req.FrameName) | 784 | popupType := DetectPopupType(req.FrameName) | ||
| 773 | 785 | ||||
| 774 | // Store pending popup for ready-to-show handling | 786 | // Store pending popup for ready-to-show handling | ||
| 775 | - | popupID := port.WebViewID(popupWV.ID()) | 787 | + | popupID := popupWV.ID() |
| 776 | pending := &PendingPopup{ | 788 | pending := &PendingPopup{ | ||
| 777 | WebView: popupWV, | 789 | WebView: popupWV, | ||
| 778 | ParentPaneID: parentPaneID, | 790 | ParentPaneID: parentPaneID, | ||
| @@ -863,7 +875,7 @@ func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID | |||||
| 863 | 875 | ||||
| 864 | // Request insertion into workspace | 876 | // Request insertion into workspace | ||
| 865 | if c.onInsertPopup != nil { | 877 | if c.onInsertPopup != nil { | ||
| 866 | - | input := InsertPopupInput{ | 878 | + | popupInput := InsertPopupInput{ |
| 867 | ParentPaneID: pending.ParentPaneID, | 879 | ParentPaneID: pending.ParentPaneID, | ||
| 868 | PopupPane: popupPane, | 880 | PopupPane: popupPane, | ||
| 869 | WebView: pending.WebView, | 881 | WebView: pending.WebView, | ||
| @@ -873,7 +885,7 @@ func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID | |||||
| 873 | TargetURI: pending.TargetURI, | 885 | TargetURI: pending.TargetURI, | ||
| 874 | } | 886 | } | ||
| 875 | 887 | ||||
| 876 | - | if err := c.onInsertPopup(ctx, input); err != nil { | 888 | + | if err := c.onInsertPopup(ctx, popupInput); err != nil { |
| 877 | log.Error().Err(err).Msg("failed to insert popup into workspace") | 889 | log.Error().Err(err).Msg("failed to insert popup into workspace") | ||
| 878 | // Clean up on failure | 890 | // Clean up on failure | ||
| 879 | delete(c.webViews, paneID) | 891 | delete(c.webViews, paneID) | ||
| @@ -912,7 +924,7 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port. | |||||
| 912 | // Find pane by WebView ID | 924 | // Find pane by WebView ID | ||
| 913 | var paneID entity.PaneID | 925 | var paneID entity.PaneID | ||
| 914 | for pid, wv := range c.webViews { | 926 | for pid, wv := range c.webViews { | ||
| 915 | - | if wv != nil && port.WebViewID(wv.ID()) == popupID { | 927 | + | if wv != nil && wv.ID() == popupID { |
| 916 | paneID = pid | 928 | paneID = pid | ||
| 917 | break | 929 | break | ||
| 918 | } | 930 | } | ||
| @@ -952,17 +964,17 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||||
| 952 | wv.OnLoadChanged = func(event webkit.LoadEvent) { | 964 | wv.OnLoadChanged = func(event webkit.LoadEvent) { | ||
| 953 | switch event { | 965 | switch event { | ||
| 954 | case webkit.LoadStarted: | 966 | case webkit.LoadStarted: | ||
| 955 | - | c.onLoadStarted(ctx, paneID) | 967 | + | c.onLoadStarted(paneID) |
| 956 | case webkit.LoadCommitted: | 968 | case webkit.LoadCommitted: | ||
| 957 | c.onLoadCommitted(ctx, paneID, wv) | 969 | c.onLoadCommitted(ctx, paneID, wv) | ||
| 958 | case webkit.LoadFinished: | 970 | case webkit.LoadFinished: | ||
| 959 | - | c.onLoadFinished(ctx, paneID) | 971 | + | c.onLoadFinished(paneID) |
| 960 | } | 972 | } | ||
| 961 | } | 973 | } | ||
| 962 | 974 | ||||
| 963 | // Progress | 975 | // Progress | ||
| 964 | wv.OnProgressChanged = func(progress float64) { | 976 | wv.OnProgressChanged = func(progress float64) { | ||
| 965 | - | c.onProgressChanged(ctx, paneID, progress) | 977 | + | c.onProgressChanged(paneID, progress) |
| 966 | } | 978 | } | ||
| 967 | 979 | ||||
| 968 | // SPA navigation | 980 | // SPA navigation | ||
| @@ -1094,7 +1106,7 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa | |||||
| 1094 | 1106 | ||||
| 1095 | // Request insertion into workspace | 1107 | // Request insertion into workspace | ||
| 1096 | if c.onInsertPopup != nil { | 1108 | if c.onInsertPopup != nil { | ||
| 1097 | - | input := InsertPopupInput{ | 1109 | + | popupInput := InsertPopupInput{ |
| 1098 | ParentPaneID: parentPaneID, | 1110 | ParentPaneID: parentPaneID, | ||
| 1099 | PopupPane: newPane, | 1111 | PopupPane: newPane, | ||
| 1100 | WebView: newWV, | 1112 | WebView: newWV, | ||
| @@ -1104,7 +1116,7 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa | |||||
| 1104 | TargetURI: uri, | 1116 | TargetURI: uri, | ||
| 1105 | } | 1117 | } | ||
| 1106 | 1118 | ||||
| 1107 | - | if err := c.onInsertPopup(ctx, input); err != nil { | 1119 | + | if err := c.onInsertPopup(ctx, popupInput); err != nil { |
| 1108 | log.Error().Err(err).Msg("failed to insert middle-click pane into workspace") | 1120 | log.Error().Err(err).Msg("failed to insert middle-click pane into workspace") | ||
| 1109 | // Clean up on failure | 1121 | // Clean up on failure | ||
| 1110 | delete(c.webViews, paneID) | 1122 | delete(c.webViews, paneID) | ||
M internal/ui/coordinator/tab.go +6 -1
| @@ -320,7 +320,12 @@ func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabI | |||||
| 320 | 320 | ||||
| 321 | // CreateWithPane creates a new tab with a pre-created pane and WebView. | 321 | // CreateWithPane creates a new tab with a pre-created pane and WebView. | ||
| 322 | // This is used for tabbed popup behavior where the popup pane already exists. | 322 | // This is used for tabbed popup behavior where the popup pane already exists. | ||
| 323 | - | func (c *TabCoordinator) CreateWithPane(ctx context.Context, pane *entity.Pane, wv *webkit.WebView, initialURL string) (*entity.Tab, error) { | 323 | + | func (c *TabCoordinator) CreateWithPane( |
| 324 | + | ctx context.Context, | |||
| 325 | + | pane *entity.Pane, | |||
| 326 | + | wv *webkit.WebView, | |||
| 327 | + | initialURL string, | |||
| 328 | + | ) (*entity.Tab, error) { | |||
| 324 | log := logging.FromContext(ctx) | 329 | log := logging.FromContext(ctx) | ||
| 325 | 330 | ||||
| 326 | output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{ | 331 | output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{ | ||
M internal/ui/coordinator/workspace.go +666 -491
| @@ -11,6 +11,12 @@ import ( | |||||
| 11 | "github.com/bnema/dumber/internal/ui/component" | 11 | "github.com/bnema/dumber/internal/ui/component" | ||
| 12 | "github.com/bnema/dumber/internal/ui/focus" | 12 | "github.com/bnema/dumber/internal/ui/focus" | ||
| 13 | "github.com/bnema/dumber/internal/ui/layout" | 13 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 14 | + | "github.com/rs/zerolog" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | defaultPaneTitle = "Untitled" | |||
| 19 | + | nilString = "nil" | |||
| 14 | ) | 20 | ) | ||
| 15 | 21 | ||||
| 16 | // WorkspaceCoordinator manages pane operations within a workspace. | 22 | // WorkspaceCoordinator manages pane operations within a workspace. | ||
| @@ -39,6 +45,22 @@ type WorkspaceCoordinatorConfig struct { | |||||
| 39 | GenerateID func() string | 45 | GenerateID func() string | ||
| 40 | } | 46 | } | ||
| 41 | 47 | ||||
| 48 | + | type splitContext struct { | |||
| 49 | + | ws *entity.Workspace | |||
| 50 | + | wsView *component.WorkspaceView | |||
| 51 | + | activePane *entity.PaneNode | |||
| 52 | + | existingWidget layout.Widget | |||
| 53 | + | isStackSplit bool | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | type stackPaneContext struct { | |||
| 57 | + | ws *entity.Workspace | |||
| 58 | + | wsView *component.WorkspaceView | |||
| 59 | + | activeNode *entity.PaneNode | |||
| 60 | + | activePaneID entity.PaneID | |||
| 61 | + | originalTitle string | |||
| 62 | + | } | |||
| 63 | + | ||||
| 42 | // NewWorkspaceCoordinator creates a new WorkspaceCoordinator. | 64 | // NewWorkspaceCoordinator creates a new WorkspaceCoordinator. | ||
| 43 | func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator { | 65 | func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator { | ||
| 44 | log := logging.FromContext(ctx) | 66 | log := logging.FromContext(ctx) | ||
| @@ -81,108 +103,150 @@ func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *com | |||||
| 81 | func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error { | 103 | func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error { | ||
| 82 | log := logging.FromContext(ctx) | 104 | log := logging.FromContext(ctx) | ||
| 83 | 105 | ||||
| 106 | + | splitCtx, ok := c.prepareSplit(ctx, direction) | |||
| 107 | + | if !ok { | |||
| 108 | + | return nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{ | |||
| 112 | + | Workspace: splitCtx.ws, | |||
| 113 | + | TargetPane: splitCtx.activePane, | |||
| 114 | + | Direction: direction, | |||
| 115 | + | }) | |||
| 116 | + | if err != nil { | |||
| 117 | + | log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane") | |||
| 118 | + | return err | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // Remember old active pane before changing | |||
| 122 | + | oldActivePaneID := splitCtx.activePane.Pane.ID | |||
| 123 | + | ||||
| 124 | + | // Set the new pane as active | |||
| 125 | + | splitCtx.ws.ActivePaneID = output.NewPaneNode.Pane.ID | |||
| 126 | + | ||||
| 127 | + | // Update the workspace view | |||
| 128 | + | if splitCtx.wsView != nil { | |||
| 129 | + | c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed") | |||
| 133 | + | ||||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func (c *WorkspaceCoordinator) prepareSplit( | |||
| 138 | + | ctx context.Context, | |||
| 139 | + | direction usecase.SplitDirection, | |||
| 140 | + | ) (*splitContext, bool) { | |||
| 141 | + | log := logging.FromContext(ctx) | |||
| 84 | if c.panesUC == nil { | 142 | if c.panesUC == nil { | ||
| 85 | log.Warn().Msg("panes use case not available") | 143 | log.Warn().Msg("panes use case not available") | ||
| 86 | - | return nil | 144 | + | return nil, false |
| 87 | } | 145 | } | ||
| 88 | 146 | ||||
| 89 | ws, wsView := c.getActiveWS() | 147 | ws, wsView := c.getActiveWS() | ||
| 90 | if ws == nil { | 148 | if ws == nil { | ||
| 91 | log.Warn().Msg("no active workspace") | 149 | log.Warn().Msg("no active workspace") | ||
| 92 | - | return nil | 150 | + | return nil, false |
| 93 | } | 151 | } | ||
| 94 | 152 | ||||
| 95 | activePane := ws.ActivePane() | 153 | activePane := ws.ActivePane() | ||
| 96 | if activePane == nil { | 154 | if activePane == nil { | ||
| 97 | log.Warn().Msg("no active pane to split") | 155 | log.Warn().Msg("no active pane to split") | ||
| 98 | - | return nil | 156 | + | return nil, false |
| 99 | } | 157 | } | ||
| 100 | 158 | ||||
| 101 | - | // Check if we're splitting from inside a stack (horizontal split around stack) | |||
| 102 | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && | 159 | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && | ||
| 103 | activePane.Parent != nil && activePane.Parent.IsStacked | 160 | activePane.Parent != nil && activePane.Parent.IsStacked | ||
| 104 | 161 | ||||
| 105 | - | // Get the existing widget BEFORE domain changes (for incremental operation) | |||
| 106 | var existingWidget layout.Widget | 162 | var existingWidget layout.Widget | ||
| 107 | if wsView != nil { | 163 | if wsView != nil { | ||
| 108 | - | if isStackSplit { | 164 | + | existingWidget = c.resolveSplitWidget(wsView, activePane, isStackSplit) |
| 109 | - | // Stack split: get the StackedView widget | |||
| 110 | - | tr := wsView.TreeRenderer() | |||
| 111 | - | if tr != nil { | |||
| 112 | - | stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID)) | |||
| 113 | - | if stackedView != nil { | |||
| 114 | - | existingWidget = stackedView.Widget() | |||
| 115 | - | } | |||
| 116 | - | } | |||
| 117 | - | } else { | |||
| 118 | - | // Regular split: get the current root widget | |||
| 119 | - | existingWidget = wsView.GetRootWidget() | |||
| 120 | - | } | |||
| 121 | } | 165 | } | ||
| 122 | 166 | ||||
| 123 | - | output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{ | 167 | + | return &splitContext{ |
| 124 | - | Workspace: ws, | 168 | + | ws: ws, |
| 125 | - | TargetPane: activePane, | 169 | + | wsView: wsView, |
| 126 | - | Direction: direction, | 170 | + | activePane: activePane, |
| 127 | - | }) | 171 | + | existingWidget: existingWidget, |
| 128 | - | if err != nil { | 172 | + | isStackSplit: isStackSplit, |
| 129 | - | log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane") | 173 | + | }, true |
| 130 | - | return err | 174 | + | } |
| 175 | + | ||||
| 176 | + | func (c *WorkspaceCoordinator) resolveSplitWidget( | |||
| 177 | + | wsView *component.WorkspaceView, | |||
| 178 | + | activePane *entity.PaneNode, | |||
| 179 | + | isStackSplit bool, | |||
| 180 | + | ) layout.Widget { | |||
| 181 | + | if wsView == nil { | |||
| 182 | + | return nil | |||
| 131 | } | 183 | } | ||
| 132 | 184 | ||||
| 133 | - | // Remember old active pane before changing | 185 | + | if !isStackSplit { |
| 134 | - | oldActivePaneID := activePane.Pane.ID | 186 | + | return wsView.GetRootWidget() |
| 187 | + | } | |||
| 135 | 188 | ||||
| 136 | - | // Set the new pane as active | 189 | + | tr := wsView.TreeRenderer() |
| 137 | - | ws.ActivePaneID = output.NewPaneNode.Pane.ID | 190 | + | if tr == nil { |
| 191 | + | return nil | |||
| 192 | + | } | |||
| 138 | 193 | ||||
| 139 | - | // Update the workspace view | 194 | + | stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID)) |
| 140 | - | if wsView != nil && existingWidget != nil { | 195 | + | if stackedView == nil { |
| 196 | + | return nil | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | return stackedView.Widget() | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func (c *WorkspaceCoordinator) applySplitToView( | |||
| 203 | + | ctx context.Context, | |||
| 204 | + | wsView *component.WorkspaceView, | |||
| 205 | + | ws *entity.Workspace, | |||
| 206 | + | output *usecase.SplitPaneOutput, | |||
| 207 | + | direction usecase.SplitDirection, | |||
| 208 | + | existingWidget layout.Widget, | |||
| 209 | + | isStackSplit bool, | |||
| 210 | + | oldActivePaneID entity.PaneID, | |||
| 211 | + | ) { | |||
| 212 | + | log := logging.FromContext(ctx) | |||
| 213 | + | needsAttach := false | |||
| 214 | + | ||||
| 215 | + | if existingWidget != nil { | |||
| 141 | var splitErr error | 216 | var splitErr error | ||
| 142 | if isStackSplit { | 217 | if isStackSplit { | ||
| 143 | - | // Incremental stack split: reuse existing stack widget | 218 | + | splitErr = c.doIncrementalStackSplit(ctx, wsView, output, direction, existingWidget, oldActivePaneID) |
| 144 | - | splitErr = c.doIncrementalStackSplit(ctx, wsView, ws, output, direction, existingWidget, oldActivePaneID) | |||
| 145 | } else { | 219 | } else { | ||
| 146 | - | // Incremental regular split: reuse existing root widget | |||
| 147 | splitErr = c.doIncrementalSplit(ctx, wsView, ws, output, direction, existingWidget, oldActivePaneID) | 220 | splitErr = c.doIncrementalSplit(ctx, wsView, ws, output, direction, existingWidget, oldActivePaneID) | ||
| 148 | } | 221 | } | ||
| 149 | 222 | ||||
| 150 | if splitErr != nil { | 223 | if splitErr != nil { | ||
| 151 | log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild") | 224 | log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild") | ||
| 152 | - | // Fallback to full rebuild | |||
| 153 | if err := wsView.Rebuild(ctx); err != nil { | 225 | if err := wsView.Rebuild(ctx); err != nil { | ||
| 154 | log.Error().Err(err).Msg("failed to rebuild workspace view") | 226 | log.Error().Err(err).Msg("failed to rebuild workspace view") | ||
| 155 | } | 227 | } | ||
| 156 | - | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | 228 | + | needsAttach = true |
| 157 | } | 229 | } | ||
| 158 | - | // Update workspace view's active pane tracking | 230 | + | } else { |
| 159 | - | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 160 | - | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 161 | - | } | |||
| 162 | - | wsView.FocusPane(ws.ActivePaneID) | |||
| 163 | - | } else if wsView != nil { | |||
| 164 | - | // No existing widget (shouldn't happen), fallback to rebuild | |||
| 165 | if err := wsView.Rebuild(ctx); err != nil { | 231 | if err := wsView.Rebuild(ctx); err != nil { | ||
| 166 | log.Error().Err(err).Msg("failed to rebuild workspace view") | 232 | log.Error().Err(err).Msg("failed to rebuild workspace view") | ||
| 167 | } | 233 | } | ||
| 168 | - | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | 234 | + | needsAttach = true |
| 169 | - | // Update workspace view's active pane tracking | |||
| 170 | - | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 171 | - | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 172 | - | } | |||
| 173 | - | wsView.FocusPane(ws.ActivePaneID) | |||
| 174 | } | 235 | } | ||
| 175 | 236 | ||||
| 176 | - | log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed") | 237 | + | if needsAttach { |
| 177 | - | 238 | + | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |
| 178 | - | return nil | 239 | + | } |
| 240 | + | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 241 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 242 | + | } | |||
| 243 | + | wsView.FocusPane(ws.ActivePaneID) | |||
| 179 | } | 244 | } | ||
| 180 | 245 | ||||
| 181 | // doIncrementalStackSplit performs an incremental split around an existing stacked pane. | 246 | // doIncrementalStackSplit performs an incremental split around an existing stacked pane. | ||
| 182 | func (c *WorkspaceCoordinator) doIncrementalStackSplit( | 247 | func (c *WorkspaceCoordinator) doIncrementalStackSplit( | ||
| 183 | ctx context.Context, | 248 | ctx context.Context, | ||
| 184 | wsView *component.WorkspaceView, | 249 | wsView *component.WorkspaceView, | ||
| 185 | - | ws *entity.Workspace, | |||
| 186 | output *usecase.SplitPaneOutput, | 250 | output *usecase.SplitPaneOutput, | ||
| 187 | direction usecase.SplitDirection, | 251 | direction usecase.SplitDirection, | ||
| 188 | existingStackWidget layout.Widget, | 252 | existingStackWidget layout.Widget, | ||
| @@ -317,7 +381,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||||
| 317 | if grandparentNode != nil { | 381 | if grandparentNode != nil { | ||
| 318 | return grandparentNode.ID | 382 | return grandparentNode.ID | ||
| 319 | } | 383 | } | ||
| 320 | - | return "nil" | 384 | + | return nilString |
| 321 | }()). | 385 | }()). | ||
| 322 | Msg("determined split type") | 386 | Msg("determined split type") | ||
| 323 | 387 | ||||
| @@ -329,106 +393,40 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||||
| 329 | newStackedView := layout.NewStackedView(factory) | 393 | newStackedView := layout.NewStackedView(factory) | ||
| 330 | newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget()) | 394 | newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget()) | ||
| 331 | 395 | ||||
| 332 | - | // 6. Determine orientation based on direction | 396 | + | orientation, existingFirst, ok := splitOrientation(direction) |
| 333 | - | var orientation layout.Orientation | 397 | + | if !ok { |
| 334 | - | var existingFirst bool | |||
| 335 | - | switch direction { | |||
| 336 | - | case usecase.SplitRight: | |||
| 337 | - | orientation = layout.OrientationHorizontal | |||
| 338 | - | existingFirst = true | |||
| 339 | - | case usecase.SplitLeft: | |||
| 340 | - | orientation = layout.OrientationHorizontal | |||
| 341 | - | existingFirst = false | |||
| 342 | - | case usecase.SplitDown: | |||
| 343 | - | orientation = layout.OrientationVertical | |||
| 344 | - | existingFirst = true | |||
| 345 | - | case usecase.SplitUp: | |||
| 346 | - | orientation = layout.OrientationVertical | |||
| 347 | - | existingFirst = false | |||
| 348 | - | default: | |||
| 349 | return nil | 398 | return nil | ||
| 350 | } | 399 | } | ||
| 351 | 400 | ||||
| 352 | // 7. Handle root vs non-root split differently | 401 | // 7. Handle root vs non-root split differently | ||
| 353 | if isRootSplit { | 402 | if isRootSplit { | ||
| 354 | - | // ROOT SPLIT: Remove from container, wrap in split, set as new root | 403 | + | if err := c.replaceRootSplit( |
| 355 | - | // Clear rootWidget ref first so SetRootWidgetDirect won't double-remove | 404 | + | ctx, |
| 356 | - | wsView.ClearRootWidgetRef() | 405 | + | wsView, |
| 357 | - | wsView.Container().Remove(existingRootWidget) | 406 | + | tr, |
| 358 | - | 407 | + | factory, | |
| 359 | - | var splitView *layout.SplitView | 408 | + | existingRootWidget, |
| 360 | - | if existingFirst { | 409 | + | newStackedView, |
| 361 | - | splitView = layout.NewSplitView(ctx, factory, orientation, | 410 | + | output, |
| 362 | - | existingRootWidget, newStackedView.Widget(), output.SplitRatio) | 411 | + | orientation, |
| 363 | - | } else { | 412 | + | existingFirst, |
| 364 | - | splitView = layout.NewSplitView(ctx, factory, orientation, | 413 | + | ); err != nil { |
| 365 | - | newStackedView.Widget(), existingRootWidget, output.SplitRatio) | 414 | + | return err |
| 366 | } | 415 | } | ||
| 367 | - | ||||
| 368 | - | wsView.SetRootWidgetDirect(splitView.Widget()) | |||
| 369 | - | tr.RegisterWidget(output.ParentNode.ID, splitView.Widget()) | |||
| 370 | - | ||||
| 371 | - | log.Debug().Msg("root split: replaced root with new split view") | |||
| 372 | } else { | 416 | } else { | ||
| 373 | - | // NON-ROOT SPLIT: Find grandparent's widget from TreeRenderer | 417 | + | if err := c.replaceNonRootSplit( |
| 374 | - | grandparentWidget := tr.Lookup(grandparentNode.ID) | 418 | + | ctx, |
| 375 | - | if grandparentWidget == nil { | 419 | + | tr, |
| 376 | - | log.Warn().Str("grandparent_id", grandparentNode.ID).Msg("grandparent widget not found in TreeRenderer") | 420 | + | factory, |
| 377 | - | return fmt.Errorf("grandparent widget not found") | 421 | + | grandparentNode, |
| 378 | - | } | 422 | + | output, |
| 379 | - | 423 | + | activePaneWidget, | |
| 380 | - | // Cast to PanedWidget | 424 | + | newStackedView, |
| 381 | - | panedWidget, ok := grandparentWidget.(layout.PanedWidget) | 425 | + | orientation, |
| 382 | - | if !ok { | 426 | + | existingFirst, |
| 383 | - | log.Warn().Msg("grandparent widget is not a PanedWidget, falling back") | 427 | + | ); err != nil { |
| 384 | - | return fmt.Errorf("grandparent is not a PanedWidget") | 428 | + | return err |
| 385 | - | } | |||
| 386 | - | ||||
| 387 | - | // Determine if active pane was in start or end position | |||
| 388 | - | // The new split node (output.ParentNode) replaced the active pane's position | |||
| 389 | - | // So we check if the new split node is Left or Right child of grandparent | |||
| 390 | - | isStartChild := grandparentNode.Left() == output.ParentNode | |||
| 391 | - | ||||
| 392 | - | log.Debug(). | |||
| 393 | - | Bool("is_start_child", isStartChild). | |||
| 394 | - | Str("grandparent_left_id", func() string { | |||
| 395 | - | if grandparentNode.Left() != nil { | |||
| 396 | - | return grandparentNode.Left().ID | |||
| 397 | - | } | |||
| 398 | - | return "nil" | |||
| 399 | - | }()). | |||
| 400 | - | Str("new_parent_id", output.ParentNode.ID). | |||
| 401 | - | Msg("determined position in grandparent") | |||
| 402 | - | ||||
| 403 | - | // Remove the active pane widget from grandparent paned | |||
| 404 | - | if isStartChild { | |||
| 405 | - | panedWidget.SetStartChild(nil) | |||
| 406 | - | } else { | |||
| 407 | - | panedWidget.SetEndChild(nil) | |||
| 408 | - | } | |||
| 409 | - | ||||
| 410 | - | // Create new split view with active pane + new pane | |||
| 411 | - | var splitView *layout.SplitView | |||
| 412 | - | if existingFirst { | |||
| 413 | - | splitView = layout.NewSplitView(ctx, factory, orientation, | |||
| 414 | - | activePaneWidget, newStackedView.Widget(), output.SplitRatio) | |||
| 415 | - | } else { | |||
| 416 | - | splitView = layout.NewSplitView(ctx, factory, orientation, | |||
| 417 | - | newStackedView.Widget(), activePaneWidget, output.SplitRatio) | |||
| 418 | - | } | |||
| 419 | - | ||||
| 420 | - | // Insert new split view in grandparent at same position | |||
| 421 | - | if isStartChild { | |||
| 422 | - | panedWidget.SetStartChild(splitView.Widget()) | |||
| 423 | - | } else { | |||
| 424 | - | panedWidget.SetEndChild(splitView.Widget()) | |||
| 425 | } | 429 | } | ||
| 426 | - | ||||
| 427 | - | tr.RegisterWidget(output.ParentNode.ID, splitView.Widget()) | |||
| 428 | - | ||||
| 429 | - | log.Debug(). | |||
| 430 | - | Bool("was_start_child", isStartChild). | |||
| 431 | - | Msg("non-root split: replaced pane in grandparent with new split view") | |||
| 432 | } | 430 | } | ||
| 433 | 431 | ||||
| 434 | // 8. Register the new pane in tracking maps and activate it | 432 | // 8. Register the new pane in tracking maps and activate it | ||
| @@ -462,6 +460,117 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||||
| 462 | return nil | 460 | return nil | ||
| 463 | } | 461 | } | ||
| 464 | 462 | ||||
| 463 | + | func splitOrientation(direction usecase.SplitDirection) (layout.Orientation, bool, bool) { | |||
| 464 | + | switch direction { | |||
| 465 | + | case usecase.SplitRight: | |||
| 466 | + | return layout.OrientationHorizontal, true, true | |||
| 467 | + | case usecase.SplitLeft: | |||
| 468 | + | return layout.OrientationHorizontal, false, true | |||
| 469 | + | case usecase.SplitDown: | |||
| 470 | + | return layout.OrientationVertical, true, true | |||
| 471 | + | case usecase.SplitUp: | |||
| 472 | + | return layout.OrientationVertical, false, true | |||
| 473 | + | default: | |||
| 474 | + | return layout.OrientationHorizontal, false, false | |||
| 475 | + | } | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | func (c *WorkspaceCoordinator) replaceRootSplit( | |||
| 479 | + | ctx context.Context, | |||
| 480 | + | wsView *component.WorkspaceView, | |||
| 481 | + | tr *layout.TreeRenderer, | |||
| 482 | + | factory layout.WidgetFactory, | |||
| 483 | + | existingRootWidget layout.Widget, | |||
| 484 | + | newStackedView *layout.StackedView, | |||
| 485 | + | output *usecase.SplitPaneOutput, | |||
| 486 | + | orientation layout.Orientation, | |||
| 487 | + | existingFirst bool, | |||
| 488 | + | ) error { | |||
| 489 | + | wsView.ClearRootWidgetRef() | |||
| 490 | + | wsView.Container().Remove(existingRootWidget) | |||
| 491 | + | ||||
| 492 | + | var splitView *layout.SplitView | |||
| 493 | + | if existingFirst { | |||
| 494 | + | splitView = layout.NewSplitView(ctx, factory, orientation, | |||
| 495 | + | existingRootWidget, newStackedView.Widget(), output.SplitRatio) | |||
| 496 | + | } else { | |||
| 497 | + | splitView = layout.NewSplitView(ctx, factory, orientation, | |||
| 498 | + | newStackedView.Widget(), existingRootWidget, output.SplitRatio) | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | wsView.SetRootWidgetDirect(splitView.Widget()) | |||
| 502 | + | tr.RegisterWidget(output.ParentNode.ID, splitView.Widget()) | |||
| 503 | + | ||||
| 504 | + | logging.FromContext(ctx).Debug().Msg("root split: replaced root with new split view") | |||
| 505 | + | return nil | |||
| 506 | + | } | |||
| 507 | + | ||||
| 508 | + | func (c *WorkspaceCoordinator) replaceNonRootSplit( | |||
| 509 | + | ctx context.Context, | |||
| 510 | + | tr *layout.TreeRenderer, | |||
| 511 | + | factory layout.WidgetFactory, | |||
| 512 | + | grandparentNode *entity.PaneNode, | |||
| 513 | + | output *usecase.SplitPaneOutput, | |||
| 514 | + | activePaneWidget layout.Widget, | |||
| 515 | + | newStackedView *layout.StackedView, | |||
| 516 | + | orientation layout.Orientation, | |||
| 517 | + | existingFirst bool, | |||
| 518 | + | ) error { | |||
| 519 | + | log := logging.FromContext(ctx) | |||
| 520 | + | grandparentWidget := tr.Lookup(grandparentNode.ID) | |||
| 521 | + | if grandparentWidget == nil { | |||
| 522 | + | log.Warn().Str("grandparent_id", grandparentNode.ID).Msg("grandparent widget not found in TreeRenderer") | |||
| 523 | + | return fmt.Errorf("grandparent widget not found") | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | panedWidget, ok := grandparentWidget.(layout.PanedWidget) | |||
| 527 | + | if !ok { | |||
| 528 | + | log.Warn().Msg("grandparent widget is not a PanedWidget, falling back") | |||
| 529 | + | return fmt.Errorf("grandparent is not a PanedWidget") | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | isStartChild := grandparentNode.Left() == output.ParentNode | |||
| 533 | + | ||||
| 534 | + | log.Debug(). | |||
| 535 | + | Bool("is_start_child", isStartChild). | |||
| 536 | + | Str("grandparent_left_id", func() string { | |||
| 537 | + | if grandparentNode.Left() != nil { | |||
| 538 | + | return grandparentNode.Left().ID | |||
| 539 | + | } | |||
| 540 | + | return nilString | |||
| 541 | + | }()). | |||
| 542 | + | Str("new_parent_id", output.ParentNode.ID). | |||
| 543 | + | Msg("determined position in grandparent") | |||
| 544 | + | ||||
| 545 | + | if isStartChild { | |||
| 546 | + | panedWidget.SetStartChild(nil) | |||
| 547 | + | } else { | |||
| 548 | + | panedWidget.SetEndChild(nil) | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | var splitView *layout.SplitView | |||
| 552 | + | if existingFirst { | |||
| 553 | + | splitView = layout.NewSplitView(ctx, factory, orientation, | |||
| 554 | + | activePaneWidget, newStackedView.Widget(), output.SplitRatio) | |||
| 555 | + | } else { | |||
| 556 | + | splitView = layout.NewSplitView(ctx, factory, orientation, | |||
| 557 | + | newStackedView.Widget(), activePaneWidget, output.SplitRatio) | |||
| 558 | + | } | |||
| 559 | + | ||||
| 560 | + | if isStartChild { | |||
| 561 | + | panedWidget.SetStartChild(splitView.Widget()) | |||
| 562 | + | } else { | |||
| 563 | + | panedWidget.SetEndChild(splitView.Widget()) | |||
| 564 | + | } | |||
| 565 | + | ||||
| 566 | + | tr.RegisterWidget(output.ParentNode.ID, splitView.Widget()) | |||
| 567 | + | ||||
| 568 | + | log.Debug(). | |||
| 569 | + | Bool("was_start_child", isStartChild). | |||
| 570 | + | Msg("non-root split: replaced pane in grandparent with new split view") | |||
| 571 | + | return nil | |||
| 572 | + | } | |||
| 573 | + | ||||
| 465 | // ClosePane closes the active pane. | 574 | // ClosePane closes the active pane. | ||
| 466 | func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error { | 575 | func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error { | ||
| 467 | log := logging.FromContext(ctx) | 576 | log := logging.FromContext(ctx) | ||
| @@ -531,7 +640,7 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error { | |||||
| 531 | if siblingNode != nil { | 640 | if siblingNode != nil { | ||
| 532 | return siblingNode.ID | 641 | return siblingNode.ID | ||
| 533 | } | 642 | } | ||
| 534 | - | return "nil" | 643 | + | return nilString |
| 535 | }()). | 644 | }()). | ||
| 536 | Bool("sibling_is_start", siblingIsStartChild). | 645 | Bool("sibling_is_start", siblingIsStartChild). | ||
| 537 | Bool("parent_is_start_in_grand", parentIsStartInGrand). | 646 | Bool("parent_is_start_in_grand", parentIsStartInGrand). | ||
| @@ -548,47 +657,18 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error { | |||||
| 548 | return err | 657 | return err | ||
| 549 | } | 658 | } | ||
| 550 | 659 | ||||
| 551 | - | // Try incremental close if we have the context | 660 | + | c.finalizePaneClose( |
| 552 | - | if wsView != nil && parentNode != nil { | 661 | + | ctx, |
| 553 | - | var closeErr error | 662 | + | wsView, |
| 554 | - | 663 | + | ws, | |
| 555 | - | if parentNode.IsStacked { | 664 | + | closingPaneID, |
| 556 | - | // Stacked close: remove pane from stack without rebuild | 665 | + | parentNode, |
| 557 | - | closeErr = c.doIncrementalStackClose(ctx, wsView, closingPaneID, parentNode) | 666 | + | siblingNode, |
| 558 | - | } else if siblingNode != nil && parentWidget != nil { | 667 | + | grandparentNode, |
| 559 | - | // Split close: promote sibling without rebuild | 668 | + | parentWidget, |
| 560 | - | closeErr = c.doIncrementalClose(ctx, wsView, ws, closingPaneID, siblingNode, parentNode, grandparentNode, parentWidget, siblingIsStartChild, parentIsStartInGrand) | 669 | + | siblingIsStartChild, |
| 561 | - | } else { | 670 | + | parentIsStartInGrand, |
| 562 | - | closeErr = fmt.Errorf("missing context for incremental close") | 671 | + | ) |
| 563 | - | } | |||
| 564 | - | ||||
| 565 | - | if closeErr != nil { | |||
| 566 | - | log.Warn().Err(closeErr).Msg("incremental close failed, falling back to rebuild") | |||
| 567 | - | // Fallback to full rebuild | |||
| 568 | - | if err := wsView.Rebuild(ctx); err != nil { | |||
| 569 | - | log.Error().Err(err).Msg("failed to rebuild workspace view") | |||
| 570 | - | } | |||
| 571 | - | c.contentCoord.ReleaseWebView(ctx, closingPaneID) | |||
| 572 | - | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |||
| 573 | - | } | |||
| 574 | - | // Update workspace view's active pane tracking | |||
| 575 | - | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 576 | - | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 577 | - | } | |||
| 578 | - | wsView.FocusPane(ws.ActivePaneID) | |||
| 579 | - | } else if wsView != nil { | |||
| 580 | - | // Fallback to rebuild if we couldn't get context | |||
| 581 | - | if err := wsView.Rebuild(ctx); err != nil { | |||
| 582 | - | log.Error().Err(err).Msg("failed to rebuild workspace view") | |||
| 583 | - | } | |||
| 584 | - | c.contentCoord.ReleaseWebView(ctx, closingPaneID) | |||
| 585 | - | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |||
| 586 | - | // Update workspace view's active pane tracking | |||
| 587 | - | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 588 | - | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 589 | - | } | |||
| 590 | - | wsView.FocusPane(ws.ActivePaneID) | |||
| 591 | - | } | |||
| 592 | 672 | ||||
| 593 | log.Info().Msg("pane closed") | 673 | log.Info().Msg("pane closed") | ||
| 594 | return nil | 674 | return nil | ||
| @@ -664,42 +744,18 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity. | |||||
| 664 | return err | 744 | return err | ||
| 665 | } | 745 | } | ||
| 666 | 746 | ||||
| 667 | - | // Try incremental close | 747 | + | c.finalizePaneClose( |
| 668 | - | if wsView != nil && parentNode != nil { | 748 | + | ctx, |
| 669 | - | var closeErr error | 749 | + | wsView, |
| 670 | - | 750 | + | ws, | |
| 671 | - | if parentNode.IsStacked { | 751 | + | paneID, |
| 672 | - | closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, parentNode) | 752 | + | parentNode, |
| 673 | - | } else if siblingNode != nil && parentWidget != nil { | 753 | + | siblingNode, |
| 674 | - | closeErr = c.doIncrementalClose(ctx, wsView, ws, paneID, siblingNode, parentNode, grandparentNode, parentWidget, siblingIsStartChild, parentIsStartInGrand) | 754 | + | grandparentNode, |
| 675 | - | } else { | 755 | + | parentWidget, |
| 676 | - | closeErr = fmt.Errorf("missing context for incremental close") | 756 | + | siblingIsStartChild, |
| 677 | - | } | 757 | + | parentIsStartInGrand, |
| 678 | - | 758 | + | ) | |
| 679 | - | if closeErr != nil { | |||
| 680 | - | log.Warn().Err(closeErr).Msg("incremental close failed, falling back to rebuild") | |||
| 681 | - | if err := wsView.Rebuild(ctx); err != nil { | |||
| 682 | - | log.Error().Err(err).Msg("failed to rebuild workspace view") | |||
| 683 | - | } | |||
| 684 | - | c.contentCoord.ReleaseWebView(ctx, paneID) | |||
| 685 | - | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |||
| 686 | - | } | |||
| 687 | - | ||||
| 688 | - | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 689 | - | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 690 | - | } | |||
| 691 | - | wsView.FocusPane(ws.ActivePaneID) | |||
| 692 | - | } else if wsView != nil { | |||
| 693 | - | if err := wsView.Rebuild(ctx); err != nil { | |||
| 694 | - | log.Error().Err(err).Msg("failed to rebuild workspace view") | |||
| 695 | - | } | |||
| 696 | - | c.contentCoord.ReleaseWebView(ctx, paneID) | |||
| 697 | - | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |||
| 698 | - | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 699 | - | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 700 | - | } | |||
| 701 | - | wsView.FocusPane(ws.ActivePaneID) | |||
| 702 | - | } | |||
| 703 | 759 | ||||
| 704 | log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID") | 760 | log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID") | ||
| 705 | return nil | 761 | return nil | ||
| @@ -709,7 +765,6 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity. | |||||
| 709 | func (c *WorkspaceCoordinator) doIncrementalClose( | 765 | func (c *WorkspaceCoordinator) doIncrementalClose( | ||
| 710 | ctx context.Context, | 766 | ctx context.Context, | ||
| 711 | wsView *component.WorkspaceView, | 767 | wsView *component.WorkspaceView, | ||
| 712 | - | ws *entity.Workspace, | |||
| 713 | closingPaneID entity.PaneID, | 768 | closingPaneID entity.PaneID, | ||
| 714 | siblingNode *entity.PaneNode, | 769 | siblingNode *entity.PaneNode, | ||
| 715 | parentNode *entity.PaneNode, | 770 | parentNode *entity.PaneNode, | ||
| @@ -835,24 +890,85 @@ func (c *WorkspaceCoordinator) doIncrementalClose( | |||||
| 835 | return nil | 890 | return nil | ||
| 836 | } | 891 | } | ||
| 837 | 892 | ||||
| 838 | - | // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree. | 893 | + | func (c *WorkspaceCoordinator) finalizePaneClose( |
| 839 | - | func (c *WorkspaceCoordinator) doIncrementalStackClose( | |||
| 840 | ctx context.Context, | 894 | ctx context.Context, | ||
| 841 | wsView *component.WorkspaceView, | 895 | wsView *component.WorkspaceView, | ||
| 842 | - | closingPaneID entity.PaneID, | 896 | + | ws *entity.Workspace, |
| 843 | - | stackNode *entity.PaneNode, | 897 | + | paneID entity.PaneID, |
| 844 | - | ) error { | 898 | + | parentNode *entity.PaneNode, |
| 845 | - | log := logging.FromContext(ctx) | 899 | + | siblingNode *entity.PaneNode, |
| 846 | - | tr := wsView.TreeRenderer() | 900 | + | grandparentNode *entity.PaneNode, |
| 847 | - | if tr == nil { | 901 | + | parentWidget layout.Widget, |
| 848 | - | return fmt.Errorf("tree renderer not available") | 902 | + | siblingIsStartChild bool, |
| 903 | + | parentIsStartInGrand bool, | |||
| 904 | + | ) { | |||
| 905 | + | if wsView == nil { | |||
| 906 | + | return | |||
| 849 | } | 907 | } | ||
| 850 | 908 | ||||
| 851 | - | log.Debug(). | 909 | + | log := logging.FromContext(ctx) |
| 852 | - | Str("closing_pane", string(closingPaneID)). | 910 | + | |
| 853 | - | Str("stack_node", stackNode.ID). | 911 | + | if parentNode != nil { |
| 854 | - | Int("stack_children", len(stackNode.Children)). | 912 | + | var closeErr error |
| 855 | - | Msg("performing incremental stack close") | 913 | + | |
| 914 | + | if parentNode.IsStacked { | |||
| 915 | + | closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, parentNode) | |||
| 916 | + | } else if siblingNode != nil && parentWidget != nil { | |||
| 917 | + | closeErr = c.doIncrementalClose( | |||
| 918 | + | ctx, | |||
| 919 | + | wsView, | |||
| 920 | + | paneID, | |||
| 921 | + | siblingNode, | |||
| 922 | + | parentNode, | |||
| 923 | + | grandparentNode, | |||
| 924 | + | parentWidget, | |||
| 925 | + | siblingIsStartChild, | |||
| 926 | + | parentIsStartInGrand, | |||
| 927 | + | ) | |||
| 928 | + | } else { | |||
| 929 | + | closeErr = fmt.Errorf("missing context for incremental close") | |||
| 930 | + | } | |||
| 931 | + | ||||
| 932 | + | if closeErr != nil { | |||
| 933 | + | log.Warn().Err(closeErr).Msg("incremental close failed, falling back to rebuild") | |||
| 934 | + | if err := wsView.Rebuild(ctx); err != nil { | |||
| 935 | + | log.Error().Err(err).Msg("failed to rebuild workspace view") | |||
| 936 | + | } | |||
| 937 | + | c.contentCoord.ReleaseWebView(ctx, paneID) | |||
| 938 | + | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |||
| 939 | + | } | |||
| 940 | + | } else { | |||
| 941 | + | if err := wsView.Rebuild(ctx); err != nil { | |||
| 942 | + | log.Error().Err(err).Msg("failed to rebuild workspace view") | |||
| 943 | + | } | |||
| 944 | + | c.contentCoord.ReleaseWebView(ctx, paneID) | |||
| 945 | + | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |||
| 946 | + | } | |||
| 947 | + | ||||
| 948 | + | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 949 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 950 | + | } | |||
| 951 | + | wsView.FocusPane(ws.ActivePaneID) | |||
| 952 | + | } | |||
| 953 | + | ||||
| 954 | + | // doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree. | |||
| 955 | + | func (c *WorkspaceCoordinator) doIncrementalStackClose( | |||
| 956 | + | ctx context.Context, | |||
| 957 | + | wsView *component.WorkspaceView, | |||
| 958 | + | closingPaneID entity.PaneID, | |||
| 959 | + | stackNode *entity.PaneNode, | |||
| 960 | + | ) error { | |||
| 961 | + | log := logging.FromContext(ctx) | |||
| 962 | + | tr := wsView.TreeRenderer() | |||
| 963 | + | if tr == nil { | |||
| 964 | + | return fmt.Errorf("tree renderer not available") | |||
| 965 | + | } | |||
| 966 | + | ||||
| 967 | + | log.Debug(). | |||
| 968 | + | Str("closing_pane", string(closingPaneID)). | |||
| 969 | + | Str("stack_node", stackNode.ID). | |||
| 970 | + | Int("stack_children", len(stackNode.Children)). | |||
| 971 | + | Msg("performing incremental stack close") | |||
| 856 | 972 | ||||
| 857 | // Get the StackedView for this pane | 973 | // Get the StackedView for this pane | ||
| 858 | stackedView := tr.GetStackedViewForPane(string(closingPaneID)) | 974 | stackedView := tr.GetStackedViewForPane(string(closingPaneID)) | ||
| @@ -984,104 +1100,18 @@ func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView | |||||
| 984 | func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | 1100 | func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | ||
| 985 | log := logging.FromContext(ctx) | 1101 | log := logging.FromContext(ctx) | ||
| 986 | 1102 | ||||
| 987 | - | if c.stackedPaneMgr == nil { | 1103 | + | stackCtx, ok := c.prepareStackPane(ctx) |
| 988 | - | log.Warn().Msg("stacked pane manager not available") | 1104 | + | if !ok { |
| 989 | - | return nil | |||
| 990 | - | } | |||
| 991 | - | ||||
| 992 | - | ws, wsView := c.getActiveWS() | |||
| 993 | - | if ws == nil { | |||
| 994 | - | log.Warn().Msg("no active workspace") | |||
| 995 | - | return nil | |||
| 996 | - | } | |||
| 997 | - | ||||
| 998 | - | activeNode := ws.ActivePane() | |||
| 999 | - | if activeNode == nil || activeNode.Pane == nil { | |||
| 1000 | - | log.Warn().Msg("no active pane") | |||
| 1001 | - | return nil | |||
| 1002 | - | } | |||
| 1003 | - | ||||
| 1004 | - | if wsView == nil { | |||
| 1005 | - | log.Warn().Msg("no workspace view") | |||
| 1006 | return nil | 1105 | return nil | ||
| 1007 | } | 1106 | } | ||
| 1008 | 1107 | ||||
| 1009 | - | activePaneID := activeNode.Pane.ID | |||
| 1010 | - | ||||
| 1011 | - | // Create a new pane entity | |||
| 1012 | newPaneID := entity.PaneID(c.generateID()) | 1108 | newPaneID := entity.PaneID(c.generateID()) | ||
| 1013 | newPane := entity.NewPane(newPaneID) | 1109 | newPane := entity.NewPane(newPaneID) | ||
| 1014 | newPane.URI = "about:blank" | 1110 | newPane.URI = "about:blank" | ||
| 1015 | - | newPane.Title = "Untitled" | 1111 | + | newPane.Title = defaultPaneTitle |
| 1016 | - | ||||
| 1017 | - | // Get the original pane's current title | |||
| 1018 | - | originalTitle := c.contentCoord.GetTitle(activePaneID) | |||
| 1019 | - | if originalTitle == "" { | |||
| 1020 | - | originalTitle = activeNode.Pane.Title | |||
| 1021 | - | } | |||
| 1022 | - | if originalTitle == "" { | |||
| 1023 | - | originalTitle = "Untitled" | |||
| 1024 | - | } | |||
| 1025 | - | ||||
| 1026 | - | // Update domain model: convert leaf to stacked if needed, add new pane | |||
| 1027 | - | var stackNode *entity.PaneNode | |||
| 1028 | - | var needsFirstPaneTitleUpdate bool | |||
| 1029 | - | if activeNode.Parent != nil && activeNode.Parent.IsStacked { | |||
| 1030 | - | // Active pane is already in a stack - add to parent stack | |||
| 1031 | - | stackNode = activeNode.Parent | |||
| 1032 | - | log.Debug(). | |||
| 1033 | - | Str("stack_id", stackNode.ID). | |||
| 1034 | - | Str("active_pane", string(activePaneID)). | |||
| 1035 | - | Int("stack_size", len(stackNode.Children)). | |||
| 1036 | - | Msg("adding to existing stack via parent") | |||
| 1037 | - | } else if activeNode.IsStacked { | |||
| 1038 | - | // Active node is itself a stack container - add to it | |||
| 1039 | - | stackNode = activeNode | |||
| 1040 | - | } else { | |||
| 1041 | - | // Convert leaf node to stacked container | |||
| 1042 | - | originalPane := activeNode.Pane | |||
| 1043 | - | originalPane.Title = originalTitle | |||
| 1044 | - | originalPaneChild := &entity.PaneNode{ | |||
| 1045 | - | ID: activeNode.ID + "_0", | |||
| 1046 | - | Pane: originalPane, | |||
| 1047 | - | Parent: activeNode, | |||
| 1048 | - | } | |||
| 1049 | 1112 | ||||
| 1050 | - | activeNode.Pane = nil | 1113 | + | stackNode, needsFirstPaneTitleUpdate := c.ensureStackNode(ctx, stackCtx.activeNode, stackCtx.activePaneID, stackCtx.originalTitle) |
| 1051 | - | activeNode.IsStacked = true | 1114 | + | insertIndex := insertStackChild(stackNode, newPaneID, newPane) |
| 1052 | - | activeNode.Children = []*entity.PaneNode{originalPaneChild} | |||
| 1053 | - | stackNode = activeNode | |||
| 1054 | - | needsFirstPaneTitleUpdate = true | |||
| 1055 | - | ||||
| 1056 | - | log.Debug(). | |||
| 1057 | - | Str("node_id", activeNode.ID). | |||
| 1058 | - | Str("original_pane", string(originalPane.ID)). | |||
| 1059 | - | Str("original_title", originalTitle). | |||
| 1060 | - | Msg("converted leaf to stacked node") | |||
| 1061 | - | } | |||
| 1062 | - | ||||
| 1063 | - | // Create new child node for the new pane | |||
| 1064 | - | newChildNode := &entity.PaneNode{ | |||
| 1065 | - | ID: stackNode.ID + "_" + string(newPaneID), | |||
| 1066 | - | Pane: newPane, | |||
| 1067 | - | Parent: stackNode, | |||
| 1068 | - | } | |||
| 1069 | - | ||||
| 1070 | - | // Insert right after current active pane (not at end) | |||
| 1071 | - | currentActiveIndex := stackNode.ActiveStackIndex | |||
| 1072 | - | if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) { | |||
| 1073 | - | currentActiveIndex = len(stackNode.Children) - 1 | |||
| 1074 | - | if currentActiveIndex < 0 { | |||
| 1075 | - | currentActiveIndex = -1 | |||
| 1076 | - | } | |||
| 1077 | - | } | |||
| 1078 | - | insertIndex := currentActiveIndex + 1 | |||
| 1079 | - | ||||
| 1080 | - | // Slice insertion at correct position | |||
| 1081 | - | stackNode.Children = append(stackNode.Children, nil) | |||
| 1082 | - | copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:]) | |||
| 1083 | - | stackNode.Children[insertIndex] = newChildNode | |||
| 1084 | - | stackNode.ActiveStackIndex = insertIndex | |||
| 1085 | 1115 | ||||
| 1086 | log.Debug(). | 1116 | log.Debug(). | ||
| 1087 | Int("stack_size", len(stackNode.Children)). | 1117 | Int("stack_size", len(stackNode.Children)). | ||
| @@ -1091,28 +1121,18 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||||
| 1091 | 1121 | ||||
| 1092 | // Create PaneView for the new pane | 1122 | // Create PaneView for the new pane | ||
| 1093 | newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil) | 1123 | newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil) | ||
| 1094 | - | setupPaneViewHover(ctx, newPaneView, wsView) | 1124 | + | setupPaneViewHover(ctx, newPaneView, stackCtx.wsView) |
| 1095 | - | wsView.RegisterPaneView(newPaneID, newPaneView) | 1125 | + | stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView) |
| 1096 | 1126 | ||||
| 1097 | // Add to the UI StackedView | 1127 | // Add to the UI StackedView | ||
| 1098 | - | if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, activePaneID, newPaneView, "Untitled"); err != nil { | 1128 | + | if err := c.stackedPaneMgr.AddPaneToStack(ctx, stackCtx.wsView, stackCtx.activePaneID, newPaneView, defaultPaneTitle); err != nil { |
| 1099 | log.Error().Err(err).Msg("failed to add pane to stack") | 1129 | log.Error().Err(err).Msg("failed to add pane to stack") | ||
| 1100 | return err | 1130 | return err | ||
| 1101 | } | 1131 | } | ||
| 1102 | 1132 | ||||
| 1103 | // Update the first pane's title if we just converted from leaf to stacked | 1133 | // Update the first pane's title if we just converted from leaf to stacked | ||
| 1104 | if needsFirstPaneTitleUpdate { | 1134 | if needsFirstPaneTitleUpdate { | ||
| 1105 | - | tr := wsView.TreeRenderer() | 1135 | + | c.updateFirstStackTitle(ctx, stackCtx.wsView, stackCtx.activePaneID, stackCtx.originalTitle) |
| 1106 | - | if tr != nil { | |||
| 1107 | - | stackedView := tr.GetStackedViewForPane(string(activePaneID)) | |||
| 1108 | - | if stackedView != nil { | |||
| 1109 | - | if err := stackedView.UpdateTitle(0, originalTitle); err != nil { | |||
| 1110 | - | log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title") | |||
| 1111 | - | } else { | |||
| 1112 | - | log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView") | |||
| 1113 | - | } | |||
| 1114 | - | } | |||
| 1115 | - | } | |||
| 1116 | } | 1136 | } | ||
| 1117 | 1137 | ||||
| 1118 | // Get WebView and attach | 1138 | // Get WebView and attach | ||
| @@ -1131,17 +1151,17 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||||
| 1131 | } | 1151 | } | ||
| 1132 | 1152 | ||||
| 1133 | // Update workspace active pane ID | 1153 | // Update workspace active pane ID | ||
| 1134 | - | ws.ActivePaneID = newPaneID | 1154 | + | stackCtx.ws.ActivePaneID = newPaneID |
| 1135 | 1155 | ||||
| 1136 | // Update workspace view | 1156 | // Update workspace view | ||
| 1137 | - | if err := wsView.SetActivePaneID(newPaneID); err != nil { | 1157 | + | if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil { |
| 1138 | log.Warn().Err(err).Msg("failed to set active pane") | 1158 | log.Warn().Err(err).Msg("failed to set active pane") | ||
| 1139 | } | 1159 | } | ||
| 1140 | 1160 | ||||
| 1141 | // Set up title bar click callback | 1161 | // Set up title bar click callback | ||
| 1142 | - | tr := wsView.TreeRenderer() | 1162 | + | tr := stackCtx.wsView.TreeRenderer() |
| 1143 | if tr != nil { | 1163 | if tr != nil { | ||
| 1144 | - | stackedView := tr.GetStackedViewForPane(string(activePaneID)) | 1164 | + | stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID)) |
| 1145 | if stackedView != nil { | 1165 | if stackedView != nil { | ||
| 1146 | capturedStackNode := stackNode | 1166 | capturedStackNode := stackNode | ||
| 1147 | stackedView.SetOnActivate(func(index int) { | 1167 | stackedView.SetOnActivate(func(index int) { | ||
| @@ -1151,7 +1171,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||||
| 1151 | } | 1171 | } | ||
| 1152 | 1172 | ||||
| 1153 | log.Info(). | 1173 | log.Info(). | ||
| 1154 | - | Str("original_pane", string(activePaneID)). | 1174 | + | Str("original_pane", string(stackCtx.activePaneID)). |
| 1155 | Str("new_pane", string(newPaneID)). | 1175 | Str("new_pane", string(newPaneID)). | ||
| 1156 | Int("stack_size", len(stackNode.Children)). | 1176 | Int("stack_size", len(stackNode.Children)). | ||
| 1157 | Msg("stacked new pane") | 1177 | Msg("stacked new pane") | ||
| @@ -1159,6 +1179,137 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||||
| 1159 | return nil | 1179 | return nil | ||
| 1160 | } | 1180 | } | ||
| 1161 | 1181 | ||||
| 1182 | + | func (c *WorkspaceCoordinator) prepareStackPane( | |||
| 1183 | + | ctx context.Context, | |||
| 1184 | + | ) (*stackPaneContext, bool) { | |||
| 1185 | + | log := logging.FromContext(ctx) | |||
| 1186 | + | if c.stackedPaneMgr == nil { | |||
| 1187 | + | log.Warn().Msg("stacked pane manager not available") | |||
| 1188 | + | return nil, false | |||
| 1189 | + | } | |||
| 1190 | + | ||||
| 1191 | + | ws, wsView := c.getActiveWS() | |||
| 1192 | + | if ws == nil { | |||
| 1193 | + | log.Warn().Msg("no active workspace") | |||
| 1194 | + | return nil, false | |||
| 1195 | + | } | |||
| 1196 | + | ||||
| 1197 | + | activeNode := ws.ActivePane() | |||
| 1198 | + | if activeNode == nil || activeNode.Pane == nil { | |||
| 1199 | + | log.Warn().Msg("no active pane") | |||
| 1200 | + | return nil, false | |||
| 1201 | + | } | |||
| 1202 | + | ||||
| 1203 | + | if wsView == nil { | |||
| 1204 | + | log.Warn().Msg("no workspace view") | |||
| 1205 | + | return nil, false | |||
| 1206 | + | } | |||
| 1207 | + | ||||
| 1208 | + | activePaneID := activeNode.Pane.ID | |||
| 1209 | + | originalTitle := c.contentCoord.GetTitle(activePaneID) | |||
| 1210 | + | if originalTitle == "" { | |||
| 1211 | + | originalTitle = activeNode.Pane.Title | |||
| 1212 | + | } | |||
| 1213 | + | if originalTitle == "" { | |||
| 1214 | + | originalTitle = defaultPaneTitle | |||
| 1215 | + | } | |||
| 1216 | + | ||||
| 1217 | + | return &stackPaneContext{ | |||
| 1218 | + | ws: ws, | |||
| 1219 | + | wsView: wsView, | |||
| 1220 | + | activeNode: activeNode, | |||
| 1221 | + | activePaneID: activePaneID, | |||
| 1222 | + | originalTitle: originalTitle, | |||
| 1223 | + | }, true | |||
| 1224 | + | } | |||
| 1225 | + | ||||
| 1226 | + | func (c *WorkspaceCoordinator) ensureStackNode( | |||
| 1227 | + | ctx context.Context, | |||
| 1228 | + | activeNode *entity.PaneNode, | |||
| 1229 | + | activePaneID entity.PaneID, | |||
| 1230 | + | originalTitle string, | |||
| 1231 | + | ) (*entity.PaneNode, bool) { | |||
| 1232 | + | log := logging.FromContext(ctx) | |||
| 1233 | + | if activeNode.Parent != nil && activeNode.Parent.IsStacked { | |||
| 1234 | + | stackNode := activeNode.Parent | |||
| 1235 | + | log.Debug(). | |||
| 1236 | + | Str("stack_id", stackNode.ID). | |||
| 1237 | + | Str("active_pane", string(activePaneID)). | |||
| 1238 | + | Int("stack_size", len(stackNode.Children)). | |||
| 1239 | + | Msg("adding to existing stack via parent") | |||
| 1240 | + | return stackNode, false | |||
| 1241 | + | } | |||
| 1242 | + | ||||
| 1243 | + | if activeNode.IsStacked { | |||
| 1244 | + | return activeNode, false | |||
| 1245 | + | } | |||
| 1246 | + | ||||
| 1247 | + | originalPane := activeNode.Pane | |||
| 1248 | + | originalPane.Title = originalTitle | |||
| 1249 | + | originalPaneChild := &entity.PaneNode{ | |||
| 1250 | + | ID: activeNode.ID + "_0", | |||
| 1251 | + | Pane: originalPane, | |||
| 1252 | + | Parent: activeNode, | |||
| 1253 | + | } | |||
| 1254 | + | ||||
| 1255 | + | activeNode.Pane = nil | |||
| 1256 | + | activeNode.IsStacked = true | |||
| 1257 | + | activeNode.Children = []*entity.PaneNode{originalPaneChild} | |||
| 1258 | + | ||||
| 1259 | + | log.Debug(). | |||
| 1260 | + | Str("node_id", activeNode.ID). | |||
| 1261 | + | Str("original_pane", string(originalPane.ID)). | |||
| 1262 | + | Str("original_title", originalTitle). | |||
| 1263 | + | Msg("converted leaf to stacked node") | |||
| 1264 | + | ||||
| 1265 | + | return activeNode, true | |||
| 1266 | + | } | |||
| 1267 | + | ||||
| 1268 | + | func insertStackChild(stackNode *entity.PaneNode, newPaneID entity.PaneID, newPane *entity.Pane) int { | |||
| 1269 | + | newChildNode := &entity.PaneNode{ | |||
| 1270 | + | ID: stackNode.ID + "_" + string(newPaneID), | |||
| 1271 | + | Pane: newPane, | |||
| 1272 | + | Parent: stackNode, | |||
| 1273 | + | } | |||
| 1274 | + | ||||
| 1275 | + | currentActiveIndex := stackNode.ActiveStackIndex | |||
| 1276 | + | if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) { | |||
| 1277 | + | currentActiveIndex = len(stackNode.Children) - 1 | |||
| 1278 | + | if currentActiveIndex < 0 { | |||
| 1279 | + | currentActiveIndex = -1 | |||
| 1280 | + | } | |||
| 1281 | + | } | |||
| 1282 | + | insertIndex := currentActiveIndex + 1 | |||
| 1283 | + | ||||
| 1284 | + | stackNode.Children = append(stackNode.Children, nil) | |||
| 1285 | + | copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:]) | |||
| 1286 | + | stackNode.Children[insertIndex] = newChildNode | |||
| 1287 | + | stackNode.ActiveStackIndex = insertIndex | |||
| 1288 | + | return insertIndex | |||
| 1289 | + | } | |||
| 1290 | + | ||||
| 1291 | + | func (c *WorkspaceCoordinator) updateFirstStackTitle( | |||
| 1292 | + | ctx context.Context, | |||
| 1293 | + | wsView *component.WorkspaceView, | |||
| 1294 | + | activePaneID entity.PaneID, | |||
| 1295 | + | originalTitle string, | |||
| 1296 | + | ) { | |||
| 1297 | + | log := logging.FromContext(ctx) | |||
| 1298 | + | tr := wsView.TreeRenderer() | |||
| 1299 | + | if tr == nil { | |||
| 1300 | + | return | |||
| 1301 | + | } | |||
| 1302 | + | stackedView := tr.GetStackedViewForPane(string(activePaneID)) | |||
| 1303 | + | if stackedView == nil { | |||
| 1304 | + | return | |||
| 1305 | + | } | |||
| 1306 | + | if err := stackedView.UpdateTitle(0, originalTitle); err != nil { | |||
| 1307 | + | log.Warn().Err(err).Str("title", originalTitle).Msg("failed to update first pane title") | |||
| 1308 | + | return | |||
| 1309 | + | } | |||
| 1310 | + | log.Debug().Str("title", originalTitle).Msg("updated first pane title in StackedView") | |||
| 1311 | + | } | |||
| 1312 | + | ||||
| 1162 | // NavigateStack navigates up or down within a stacked pane container. | 1313 | // NavigateStack navigates up or down within a stacked pane container. | ||
| 1163 | func (c *WorkspaceCoordinator) NavigateStack(ctx context.Context, direction string) error { | 1314 | func (c *WorkspaceCoordinator) NavigateStack(ctx context.Context, direction string) error { | ||
| 1164 | log := logging.FromContext(ctx) | 1315 | log := logging.FromContext(ctx) | ||
| @@ -1324,40 +1475,14 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input Inser | |||||
| 1324 | return fmt.Errorf("parent pane not found: %s", input.ParentPaneID) | 1475 | return fmt.Errorf("parent pane not found: %s", input.ParentPaneID) | ||
| 1325 | } | 1476 | } | ||
| 1326 | 1477 | ||||
| 1327 | - | // Map placement to split direction | 1478 | + | direction := resolvePopupSplitDirection(input.Placement) |
| 1328 | - | var direction usecase.SplitDirection | |||
| 1329 | - | switch input.Placement { | |||
| 1330 | - | case "left": | |||
| 1331 | - | direction = usecase.SplitLeft | |||
| 1332 | - | case "right": | |||
| 1333 | - | direction = usecase.SplitRight | |||
| 1334 | - | case "top", "up": | |||
| 1335 | - | direction = usecase.SplitUp | |||
| 1336 | - | case "bottom", "down": | |||
| 1337 | - | direction = usecase.SplitDown | |||
| 1338 | - | default: | |||
| 1339 | - | direction = usecase.SplitRight | |||
| 1340 | - | } | |||
| 1341 | 1479 | ||||
| 1342 | // Check if parent is in a stack - if so, split around the stack | 1480 | // Check if parent is in a stack - if so, split around the stack | ||
| 1343 | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && | 1481 | isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) && | ||
| 1344 | parentNode.Parent != nil && parentNode.Parent.IsStacked | 1482 | parentNode.Parent != nil && parentNode.Parent.IsStacked | ||
| 1345 | 1483 | ||||
| 1346 | // Get existing widget before domain changes | 1484 | // Get existing widget before domain changes | ||
| 1347 | - | var existingWidget layout.Widget | 1485 | + | existingWidget := c.resolvePopupSplitWidget(wsView, parentNode, isStackSplit) |
| 1348 | - | if wsView != nil { | |||
| 1349 | - | if isStackSplit { | |||
| 1350 | - | tr := wsView.TreeRenderer() | |||
| 1351 | - | if tr != nil { | |||
| 1352 | - | stackedView := tr.GetStackedViewForPane(string(parentNode.Pane.ID)) | |||
| 1353 | - | if stackedView != nil { | |||
| 1354 | - | existingWidget = stackedView.Widget() | |||
| 1355 | - | } | |||
| 1356 | - | } | |||
| 1357 | - | } else { | |||
| 1358 | - | existingWidget = wsView.GetRootWidget() | |||
| 1359 | - | } | |||
| 1360 | - | } | |||
| 1361 | 1486 | ||||
| 1362 | // Perform the split with pre-created pane | 1487 | // Perform the split with pre-created pane | ||
| 1363 | output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{ | 1488 | output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{ | ||
| @@ -1374,38 +1499,9 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input Inser | |||||
| 1374 | ws.ActivePaneID = input.PopupPane.ID | 1499 | ws.ActivePaneID = input.PopupPane.ID | ||
| 1375 | 1500 | ||||
| 1376 | // Update UI | 1501 | // Update UI | ||
| 1377 | - | if wsView != nil && existingWidget != nil { | 1502 | + | if wsView != nil { |
| 1378 | - | var splitErr error | 1503 | + | c.applySplitToView(ctx, wsView, ws, output, direction, existingWidget, isStackSplit, input.ParentPaneID) |
| 1379 | - | if isStackSplit { | 1504 | + | c.attachPopupWebView(ctx, wsView, input) |
| 1380 | - | splitErr = c.doIncrementalStackSplit(ctx, wsView, ws, output, direction, existingWidget, input.ParentPaneID) | |||
| 1381 | - | } else { | |||
| 1382 | - | splitErr = c.doIncrementalSplit(ctx, wsView, ws, output, direction, existingWidget, input.ParentPaneID) | |||
| 1383 | - | } | |||
| 1384 | - | ||||
| 1385 | - | if splitErr != nil { | |||
| 1386 | - | log.Warn().Err(splitErr).Msg("incremental popup split failed, falling back to rebuild") | |||
| 1387 | - | if err := wsView.Rebuild(ctx); err != nil { | |||
| 1388 | - | log.Error().Err(err).Msg("failed to rebuild workspace view") | |||
| 1389 | - | } | |||
| 1390 | - | c.contentCoord.AttachToWorkspace(ctx, ws, wsView) | |||
| 1391 | - | } | |||
| 1392 | - | ||||
| 1393 | - | // Update active pane tracking | |||
| 1394 | - | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | |||
| 1395 | - | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | |||
| 1396 | - | } | |||
| 1397 | - | wsView.FocusPane(ws.ActivePaneID) | |||
| 1398 | - | } | |||
| 1399 | - | ||||
| 1400 | - | // Attach WebView widget to the new pane | |||
| 1401 | - | if wsView != nil && input.WebView != nil { | |||
| 1402 | - | widget := c.contentCoord.WrapWidget(ctx, input.WebView) | |||
| 1403 | - | if widget != nil { | |||
| 1404 | - | paneView := wsView.GetPaneView(input.PopupPane.ID) | |||
| 1405 | - | if paneView != nil { | |||
| 1406 | - | paneView.SetWebViewWidget(widget) | |||
| 1407 | - | } | |||
| 1408 | - | } | |||
| 1409 | } | 1505 | } | ||
| 1410 | 1506 | ||||
| 1411 | log.Info(). | 1507 | log.Info(). | ||
| @@ -1416,6 +1512,68 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input Inser | |||||
| 1416 | return nil | 1512 | return nil | ||
| 1417 | } | 1513 | } | ||
| 1418 | 1514 | ||||
| 1515 | + | func resolvePopupSplitDirection(placement string) usecase.SplitDirection { | |||
| 1516 | + | switch placement { | |||
| 1517 | + | case string(usecase.SplitLeft): | |||
| 1518 | + | return usecase.SplitLeft | |||
| 1519 | + | case string(usecase.SplitRight): | |||
| 1520 | + | return usecase.SplitRight | |||
| 1521 | + | case "top", string(usecase.SplitUp): | |||
| 1522 | + | return usecase.SplitUp | |||
| 1523 | + | case "bottom", string(usecase.SplitDown): | |||
| 1524 | + | return usecase.SplitDown | |||
| 1525 | + | default: | |||
| 1526 | + | return usecase.SplitRight | |||
| 1527 | + | } | |||
| 1528 | + | } | |||
| 1529 | + | ||||
| 1530 | + | func (c *WorkspaceCoordinator) resolvePopupSplitWidget( | |||
| 1531 | + | wsView *component.WorkspaceView, | |||
| 1532 | + | parentNode *entity.PaneNode, | |||
| 1533 | + | isStackSplit bool, | |||
| 1534 | + | ) layout.Widget { | |||
| 1535 | + | if wsView == nil { | |||
| 1536 | + | return nil | |||
| 1537 | + | } | |||
| 1538 | + | ||||
| 1539 | + | if !isStackSplit { | |||
| 1540 | + | return wsView.GetRootWidget() | |||
| 1541 | + | } | |||
| 1542 | + | ||||
| 1543 | + | tr := wsView.TreeRenderer() | |||
| 1544 | + | if tr == nil { | |||
| 1545 | + | return nil | |||
| 1546 | + | } | |||
| 1547 | + | ||||
| 1548 | + | stackedView := tr.GetStackedViewForPane(string(parentNode.Pane.ID)) | |||
| 1549 | + | if stackedView == nil { | |||
| 1550 | + | return nil | |||
| 1551 | + | } | |||
| 1552 | + | ||||
| 1553 | + | return stackedView.Widget() | |||
| 1554 | + | } | |||
| 1555 | + | ||||
| 1556 | + | func (c *WorkspaceCoordinator) attachPopupWebView( | |||
| 1557 | + | ctx context.Context, | |||
| 1558 | + | wsView *component.WorkspaceView, | |||
| 1559 | + | input InsertPopupInput, | |||
| 1560 | + | ) { | |||
| 1561 | + | if wsView == nil || input.WebView == nil { | |||
| 1562 | + | return | |||
| 1563 | + | } | |||
| 1564 | + | ||||
| 1565 | + | widget := c.contentCoord.WrapWidget(ctx, input.WebView) | |||
| 1566 | + | if widget == nil { | |||
| 1567 | + | return | |||
| 1568 | + | } | |||
| 1569 | + | ||||
| 1570 | + | paneView := wsView.GetPaneView(input.PopupPane.ID) | |||
| 1571 | + | if paneView == nil { | |||
| 1572 | + | return | |||
| 1573 | + | } | |||
| 1574 | + | paneView.SetWebViewWidget(widget) | |||
| 1575 | + | } | |||
| 1576 | + | ||||
| 1419 | // insertPopupStacked inserts a popup as a stacked pane on top of the parent. | 1577 | // insertPopupStacked inserts a popup as a stacked pane on top of the parent. | ||
| 1420 | func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error { | 1578 | func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error { | ||
| 1421 | log := logging.FromContext(ctx) | 1579 | log := logging.FromContext(ctx) | ||
| @@ -1425,64 +1583,87 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins | |||||
| 1425 | return fmt.Errorf("no active workspace") | 1583 | return fmt.Errorf("no active workspace") | ||
| 1426 | } | 1584 | } | ||
| 1427 | 1585 | ||||
| 1428 | - | // Find parent pane node | |||
| 1429 | parentNode := ws.FindPane(input.ParentPaneID) | 1586 | parentNode := ws.FindPane(input.ParentPaneID) | ||
| 1430 | if parentNode == nil { | 1587 | if parentNode == nil { | ||
| 1431 | return fmt.Errorf("parent pane not found: %s", input.ParentPaneID) | 1588 | return fmt.Errorf("parent pane not found: %s", input.ParentPaneID) | ||
| 1432 | } | 1589 | } | ||
| 1433 | 1590 | ||||
| 1434 | - | // Determine stack node - either parent is already a stack, or we need to convert it | 1591 | + | stackNode := c.resolveStackNode(input, parentNode, log) |
| 1435 | - | var stackNode *entity.PaneNode | 1592 | + | if stackNode == nil { |
| 1436 | - | var needsStackConversion bool | 1593 | + | return fmt.Errorf("failed to resolve stack node for popup") |
| 1594 | + | } | |||
| 1437 | 1595 | ||||
| 1438 | - | if parentNode.Parent != nil && parentNode.Parent.IsStacked { | 1596 | + | c.insertStackChild(stackNode, input.PopupPane) |
| 1439 | - | // Parent is already in a stack - add to existing stack | 1597 | + | |
| 1440 | - | stackNode = parentNode.Parent | 1598 | + | // Update workspace active pane |
| 1441 | - | } else if parentNode.IsStacked { | 1599 | + | ws.ActivePaneID = input.PopupPane.ID |
| 1442 | - | // Parent itself is a stack - add to it | 1600 | + | |
| 1443 | - | stackNode = parentNode | 1601 | + | if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil { |
| 1444 | - | } else { | 1602 | + | return err |
| 1445 | - | // Need to convert parent leaf to a stack | |||
| 1446 | - | needsStackConversion = true | |||
| 1447 | - | stackNode = parentNode | |||
| 1448 | } | 1603 | } | ||
| 1449 | 1604 | ||||
| 1450 | - | if needsStackConversion { | 1605 | + | log.Info(). |
| 1451 | - | // Convert leaf node to stacked container | 1606 | + | Str("popup_pane", string(input.PopupPane.ID)). |
| 1452 | - | originalPane := parentNode.Pane | 1607 | + | Int("stack_size", len(stackNode.Children)). |
| 1453 | - | originalTitle := c.contentCoord.GetTitle(input.ParentPaneID) | 1608 | + | Msg("popup inserted as stacked pane") |
| 1454 | - | if originalTitle == "" { | |||
| 1455 | - | originalTitle = originalPane.Title | |||
| 1456 | - | } | |||
| 1457 | - | if originalTitle == "" { | |||
| 1458 | - | originalTitle = "Untitled" | |||
| 1459 | - | } | |||
| 1460 | - | originalPane.Title = originalTitle | |||
| 1461 | 1609 | ||||
| 1462 | - | originalPaneChild := &entity.PaneNode{ | 1610 | + | return nil |
| 1463 | - | ID: parentNode.ID + "_0", | 1611 | + | } |
| 1464 | - | Pane: originalPane, | |||
| 1465 | - | Parent: parentNode, | |||
| 1466 | - | } | |||
| 1467 | 1612 | ||||
| 1468 | - | parentNode.Pane = nil | 1613 | + | func (c *WorkspaceCoordinator) resolveStackNode( |
| 1469 | - | parentNode.IsStacked = true | 1614 | + | input InsertPopupInput, |
| 1470 | - | parentNode.Children = []*entity.PaneNode{originalPaneChild} | 1615 | + | parentNode *entity.PaneNode, |
| 1616 | + | log *zerolog.Logger, | |||
| 1617 | + | ) *entity.PaneNode { | |||
| 1618 | + | if parentNode.Parent != nil && parentNode.Parent.IsStacked { | |||
| 1619 | + | return parentNode.Parent | |||
| 1620 | + | } | |||
| 1621 | + | if parentNode.IsStacked { | |||
| 1622 | + | return parentNode | |||
| 1623 | + | } | |||
| 1624 | + | return c.convertToStackedNode(input, parentNode, log) | |||
| 1625 | + | } | |||
| 1471 | 1626 | ||||
| 1472 | - | log.Debug(). | 1627 | + | func (c *WorkspaceCoordinator) convertToStackedNode( |
| 1473 | - | Str("node_id", parentNode.ID). | 1628 | + | input InsertPopupInput, |
| 1474 | - | Str("original_pane", string(originalPane.ID)). | 1629 | + | parentNode *entity.PaneNode, |
| 1475 | - | Msg("converted leaf to stacked node for popup") | 1630 | + | log *zerolog.Logger, |
| 1631 | + | ) *entity.PaneNode { | |||
| 1632 | + | originalPane := parentNode.Pane | |||
| 1633 | + | originalTitle := c.contentCoord.GetTitle(input.ParentPaneID) | |||
| 1634 | + | if originalTitle == "" { | |||
| 1635 | + | originalTitle = originalPane.Title | |||
| 1636 | + | } | |||
| 1637 | + | if originalTitle == "" { | |||
| 1638 | + | originalTitle = defaultPaneTitle | |||
| 1476 | } | 1639 | } | ||
| 1640 | + | originalPane.Title = originalTitle | |||
| 1641 | + | ||||
| 1642 | + | originalPaneChild := &entity.PaneNode{ | |||
| 1643 | + | ID: parentNode.ID + "_0", | |||
| 1644 | + | Pane: originalPane, | |||
| 1645 | + | Parent: parentNode, | |||
| 1646 | + | } | |||
| 1647 | + | ||||
| 1648 | + | parentNode.Pane = nil | |||
| 1649 | + | parentNode.IsStacked = true | |||
| 1650 | + | parentNode.Children = []*entity.PaneNode{originalPaneChild} | |||
| 1651 | + | ||||
| 1652 | + | log.Debug(). | |||
| 1653 | + | Str("node_id", parentNode.ID). | |||
| 1654 | + | Str("original_pane", string(originalPane.ID)). | |||
| 1655 | + | Msg("converted leaf to stacked node for popup") | |||
| 1477 | 1656 | ||||
| 1478 | - | // Create new child node for the popup pane | 1657 | + | return parentNode |
| 1658 | + | } | |||
| 1659 | + | ||||
| 1660 | + | func (c *WorkspaceCoordinator) insertStackChild(stackNode *entity.PaneNode, pane *entity.Pane) { | |||
| 1479 | newChildNode := &entity.PaneNode{ | 1661 | newChildNode := &entity.PaneNode{ | ||
| 1480 | - | ID: stackNode.ID + "_popup_" + string(input.PopupPane.ID), | 1662 | + | ID: stackNode.ID + "_popup_" + string(pane.ID), |
| 1481 | - | Pane: input.PopupPane, | 1663 | + | Pane: pane, |
| 1482 | Parent: stackNode, | 1664 | Parent: stackNode, | ||
| 1483 | } | 1665 | } | ||
| 1484 | 1666 | ||||
| 1485 | - | // Insert right after current active pane (not at end) | |||
| 1486 | currentActiveIndex := stackNode.ActiveStackIndex | 1667 | currentActiveIndex := stackNode.ActiveStackIndex | ||
| 1487 | if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) { | 1668 | if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) { | ||
| 1488 | currentActiveIndex = len(stackNode.Children) - 1 | 1669 | currentActiveIndex = len(stackNode.Children) - 1 | ||
| @@ -1492,58 +1673,52 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins | |||||
| 1492 | } | 1673 | } | ||
| 1493 | insertIndex := currentActiveIndex + 1 | 1674 | insertIndex := currentActiveIndex + 1 | ||
| 1494 | 1675 | ||||
| 1495 | - | // Slice insertion at correct position | |||
| 1496 | stackNode.Children = append(stackNode.Children, nil) | 1676 | stackNode.Children = append(stackNode.Children, nil) | ||
| 1497 | copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:]) | 1677 | copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:]) | ||
| 1498 | stackNode.Children[insertIndex] = newChildNode | 1678 | stackNode.Children[insertIndex] = newChildNode | ||
| 1499 | stackNode.ActiveStackIndex = insertIndex | 1679 | stackNode.ActiveStackIndex = insertIndex | ||
| 1680 | + | } | |||
| 1500 | 1681 | ||||
| 1501 | - | // Update workspace active pane | 1682 | + | func (c *WorkspaceCoordinator) attachPopupPaneView( |
| 1502 | - | ws.ActivePaneID = input.PopupPane.ID | 1683 | + | ctx context.Context, |
| 1503 | - | 1684 | + | input InsertPopupInput, | |
| 1504 | - | // Create PaneView for the popup | 1685 | + | wsView *component.WorkspaceView, |
| 1505 | - | if wsView != nil { | 1686 | + | stackNode *entity.PaneNode, |
| 1506 | - | newPaneView := component.NewPaneView(c.widgetFactory, input.PopupPane.ID, nil) | 1687 | + | log *zerolog.Logger, |
| 1507 | - | setupPaneViewHover(ctx, newPaneView, wsView) | 1688 | + | ) error { |
| 1508 | - | wsView.RegisterPaneView(input.PopupPane.ID, newPaneView) | 1689 | + | if wsView == nil { |
| 1690 | + | return nil | |||
| 1691 | + | } | |||
| 1692 | + | newPaneView := component.NewPaneView(c.widgetFactory, input.PopupPane.ID, nil) | |||
| 1693 | + | setupPaneViewHover(ctx, newPaneView, wsView) | |||
| 1694 | + | wsView.RegisterPaneView(input.PopupPane.ID, newPaneView) | |||
| 1509 | 1695 | ||||
| 1510 | - | // Add to StackedView | 1696 | + | if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, input.ParentPaneID, newPaneView, input.PopupPane.Title); err != nil { |
| 1511 | - | if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, input.ParentPaneID, newPaneView, input.PopupPane.Title); err != nil { | 1697 | + | log.Error().Err(err).Msg("failed to add popup pane to stack") |
| 1512 | - | log.Error().Err(err).Msg("failed to add popup pane to stack") | 1698 | + | return err |
| 1513 | - | return err | 1699 | + | } |
| 1514 | - | } | |||
| 1515 | 1700 | ||||
| 1516 | - | // Attach WebView widget | 1701 | + | if input.WebView != nil { |
| 1517 | - | if input.WebView != nil { | 1702 | + | widget := c.contentCoord.WrapWidget(ctx, input.WebView) |
| 1518 | - | widget := c.contentCoord.WrapWidget(ctx, input.WebView) | 1703 | + | if widget != nil { |
| 1519 | - | if widget != nil { | 1704 | + | newPaneView.SetWebViewWidget(widget) |
| 1520 | - | newPaneView.SetWebViewWidget(widget) | |||
| 1521 | - | } | |||
| 1522 | } | 1705 | } | ||
| 1706 | + | } | |||
| 1523 | 1707 | ||||
| 1524 | - | // Update active pane tracking | 1708 | + | if err := wsView.SetActivePaneID(input.PopupPane.ID); err != nil { |
| 1525 | - | if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil { | 1709 | + | log.Warn().Err(err).Msg("failed to set active pane in workspace view") |
| 1526 | - | log.Warn().Err(err).Msg("failed to set active pane in workspace view") | 1710 | + | } |
| 1527 | - | } | |||
| 1528 | 1711 | ||||
| 1529 | - | // Setup title bar click callback | 1712 | + | tr := wsView.TreeRenderer() |
| 1530 | - | tr := wsView.TreeRenderer() | 1713 | + | if tr != nil { |
| 1531 | - | if tr != nil { | 1714 | + | stackedView := tr.GetStackedViewForPane(string(input.ParentPaneID)) |
| 1532 | - | stackedView := tr.GetStackedViewForPane(string(input.ParentPaneID)) | 1715 | + | if stackedView != nil { |
| 1533 | - | if stackedView != nil { | 1716 | + | capturedStackNode := stackNode |
| 1534 | - | capturedStackNode := stackNode | 1717 | + | stackedView.SetOnActivate(func(index int) { |
| 1535 | - | stackedView.SetOnActivate(func(index int) { | 1718 | + | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) |
| 1536 | - | c.onTitleBarClick(ctx, capturedStackNode, stackedView, index) | 1719 | + | }) |
| 1537 | - | }) | |||
| 1538 | - | } | |||
| 1539 | } | 1720 | } | ||
| 1540 | } | 1721 | } | ||
| 1541 | - | ||||
| 1542 | - | log.Info(). | |||
| 1543 | - | Str("popup_pane", string(input.PopupPane.ID)). | |||
| 1544 | - | Int("stack_size", len(stackNode.Children)). | |||
| 1545 | - | Msg("popup inserted as stacked pane") | |||
| 1546 | - | ||||
| 1547 | return nil | 1722 | return nil | ||
| 1548 | } | 1723 | } | ||
| 1549 | 1724 | ||||
M internal/ui/dispatcher/keyboard.go +132 -125
| @@ -2,6 +2,7 @@ package dispatcher | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/dumber/internal/application/usecase" | 7 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -19,6 +20,7 @@ type KeyboardDispatcher struct { | |||||
| 19 | navCoord *coordinator.NavigationCoordinator | 20 | navCoord *coordinator.NavigationCoordinator | ||
| 20 | zoomUC *usecase.ManageZoomUseCase | 21 | zoomUC *usecase.ManageZoomUseCase | ||
| 21 | copyURLUC *usecase.CopyURLUseCase | 22 | copyURLUC *usecase.CopyURLUseCase | ||
| 23 | + | actionHandlers map[input.Action]func(ctx context.Context) error | |||
| 22 | onQuit func() | 24 | onQuit func() | ||
| 23 | onFindOpen func(ctx context.Context) error | 25 | onFindOpen func(ctx context.Context) error | ||
| 24 | onFindNext func(ctx context.Context) error | 26 | onFindNext func(ctx context.Context) error | ||
| @@ -38,13 +40,15 @@ func NewKeyboardDispatcher( | |||||
| 38 | log := logging.FromContext(ctx) | 40 | log := logging.FromContext(ctx) | ||
| 39 | log.Debug().Msg("creating keyboard dispatcher") | 41 | log.Debug().Msg("creating keyboard dispatcher") | ||
| 40 | 42 | ||||
| 41 | - | return &KeyboardDispatcher{ | 43 | + | dispatcher := &KeyboardDispatcher{ |
| 42 | tabCoord: tabCoord, | 44 | tabCoord: tabCoord, | ||
| 43 | wsCoord: wsCoord, | 45 | wsCoord: wsCoord, | ||
| 44 | navCoord: navCoord, | 46 | navCoord: navCoord, | ||
| 45 | zoomUC: zoomUC, | 47 | zoomUC: zoomUC, | ||
| 46 | copyURLUC: copyURLUC, | 48 | copyURLUC: copyURLUC, | ||
| 47 | } | 49 | } | ||
| 50 | + | dispatcher.initActionHandlers() | |||
| 51 | + | return dispatcher | |||
| 48 | } | 52 | } | ||
| 49 | 53 | ||||
| 50 | // SetOnQuit sets the callback for quit action. | 54 | // SetOnQuit sets the callback for quit action. | ||
| @@ -72,135 +76,139 @@ func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) | |||||
| 72 | d.onFindClose = fn | 76 | d.onFindClose = fn | ||
| 73 | } | 77 | } | ||
| 74 | 78 | ||||
| 79 | + | func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 80 | + | const ( | |||
| 81 | + | firstTabIndex = 0 | |||
| 82 | + | secondTabIndex = 1 | |||
| 83 | + | thirdTabIndex = 2 | |||
| 84 | + | fourthTabIndex = 3 | |||
| 85 | + | fifthTabIndex = 4 | |||
| 86 | + | sixthTabIndex = 5 | |||
| 87 | + | seventhTabIndex = 6 | |||
| 88 | + | eighthTabIndex = 7 | |||
| 89 | + | ninthTabIndex = 8 | |||
| 90 | + | tenthTabIndex = 9 | |||
| 91 | + | ) | |||
| 92 | + | d.actionHandlers = map[input.Action]func(ctx context.Context) error{ | |||
| 93 | + | // Tab actions | |||
| 94 | + | input.ActionNewTab: func(ctx context.Context) error { _, err := d.tabCoord.Create(ctx, "about:blank"); return err }, | |||
| 95 | + | input.ActionCloseTab: d.tabCoord.Close, | |||
| 96 | + | input.ActionNextTab: d.tabCoord.SwitchNext, | |||
| 97 | + | input.ActionPreviousTab: d.tabCoord.SwitchPrev, | |||
| 98 | + | input.ActionSwitchLastTab: d.tabCoord.SwitchToLastActive, | |||
| 99 | + | input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, firstTabIndex) }, | |||
| 100 | + | input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, secondTabIndex) }, | |||
| 101 | + | input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, thirdTabIndex) }, | |||
| 102 | + | input.ActionSwitchTabIndex4: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, fourthTabIndex) }, | |||
| 103 | + | input.ActionSwitchTabIndex5: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, fifthTabIndex) }, | |||
| 104 | + | input.ActionSwitchTabIndex6: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, sixthTabIndex) }, | |||
| 105 | + | input.ActionSwitchTabIndex7: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, seventhTabIndex) }, | |||
| 106 | + | input.ActionSwitchTabIndex8: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, eighthTabIndex) }, | |||
| 107 | + | input.ActionSwitchTabIndex9: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, ninthTabIndex) }, | |||
| 108 | + | input.ActionSwitchTabIndex10: func(ctx context.Context) error { return d.tabCoord.SwitchByIndex(ctx, tenthTabIndex) }, | |||
| 109 | + | input.ActionRenameTab: func(ctx context.Context) error { | |||
| 110 | + | return d.logNoop(ctx, "rename tab action (not yet implemented)") | |||
| 111 | + | }, | |||
| 112 | + | // Pane actions | |||
| 113 | + | input.ActionSplitRight: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitRight) }, | |||
| 114 | + | input.ActionSplitLeft: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitLeft) }, | |||
| 115 | + | input.ActionSplitUp: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitUp) }, | |||
| 116 | + | input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) }, | |||
| 117 | + | input.ActionClosePane: d.wsCoord.ClosePane, | |||
| 118 | + | input.ActionStackPane: d.wsCoord.StackPane, | |||
| 119 | + | input.ActionFocusRight: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavRight) }, | |||
| 120 | + | input.ActionFocusLeft: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavLeft) }, | |||
| 121 | + | input.ActionFocusUp: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavUp) }, | |||
| 122 | + | input.ActionFocusDown: func(ctx context.Context) error { return d.wsCoord.FocusPane(ctx, usecase.NavDown) }, | |||
| 123 | + | // Stack navigation | |||
| 124 | + | input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") }, | |||
| 125 | + | input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") }, | |||
| 126 | + | // Navigation | |||
| 127 | + | input.ActionGoBack: d.navCoord.GoBack, | |||
| 128 | + | input.ActionGoForward: d.navCoord.GoForward, | |||
| 129 | + | input.ActionReload: d.navCoord.Reload, | |||
| 130 | + | input.ActionHardReload: d.navCoord.HardReload, | |||
| 131 | + | // Zoom actions | |||
| 132 | + | input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") }, | |||
| 133 | + | input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") }, | |||
| 134 | + | input.ActionZoomReset: func(ctx context.Context) error { return d.handleZoom(ctx, "reset") }, | |||
| 135 | + | // UI | |||
| 136 | + | input.ActionOpenOmnibox: d.navCoord.OpenOmnibox, | |||
| 137 | + | input.ActionOpenFind: d.handleFindOpen, | |||
| 138 | + | input.ActionFindNext: d.handleFindNext, | |||
| 139 | + | input.ActionFindPrev: d.handleFindPrev, | |||
| 140 | + | input.ActionCloseFind: d.handleFindClose, | |||
| 141 | + | input.ActionOpenDevTools: d.navCoord.OpenDevTools, | |||
| 142 | + | input.ActionToggleFullscreen: func(ctx context.Context) error { | |||
| 143 | + | return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)") | |||
| 144 | + | }, | |||
| 145 | + | // Clipboard | |||
| 146 | + | input.ActionCopyURL: d.handleCopyURL, | |||
| 147 | + | // Application | |||
| 148 | + | input.ActionQuit: d.handleQuit, | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 75 | // Dispatch routes a keyboard action to the appropriate coordinator. | 152 | // Dispatch routes a keyboard action to the appropriate coordinator. | ||
| 76 | func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error { | 153 | func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error { | ||
| 77 | log := logging.FromContext(ctx) | 154 | log := logging.FromContext(ctx) | ||
| 78 | log.Debug().Str("action", string(action)).Msg("dispatching keyboard action") | 155 | log.Debug().Str("action", string(action)).Msg("dispatching keyboard action") | ||
| 79 | 156 | ||||
| 80 | - | switch action { | 157 | + | if handler, ok := d.actionHandlers[action]; ok { |
| 81 | - | // Tab actions | 158 | + | return handler(ctx) |
| 82 | - | case input.ActionNewTab: | 159 | + | } |
| 83 | - | _, err := d.tabCoord.Create(ctx, "about:blank") | 160 | + | |
| 84 | - | return err | 161 | + | log.Warn().Str("action", string(action)).Msg("unhandled keyboard action") |
| 85 | - | case input.ActionCloseTab: | 162 | + | return nil |
| 86 | - | return d.tabCoord.Close(ctx) | 163 | + | } |
| 87 | - | case input.ActionNextTab: | |||
| 88 | - | return d.tabCoord.SwitchNext(ctx) | |||
| 89 | - | case input.ActionPreviousTab: | |||
| 90 | - | return d.tabCoord.SwitchPrev(ctx) | |||
| 91 | - | case input.ActionSwitchLastTab: | |||
| 92 | - | return d.tabCoord.SwitchToLastActive(ctx) | |||
| 93 | - | case input.ActionSwitchTabIndex1: | |||
| 94 | - | return d.tabCoord.SwitchByIndex(ctx, 0) | |||
| 95 | - | case input.ActionSwitchTabIndex2: | |||
| 96 | - | return d.tabCoord.SwitchByIndex(ctx, 1) | |||
| 97 | - | case input.ActionSwitchTabIndex3: | |||
| 98 | - | return d.tabCoord.SwitchByIndex(ctx, 2) | |||
| 99 | - | case input.ActionSwitchTabIndex4: | |||
| 100 | - | return d.tabCoord.SwitchByIndex(ctx, 3) | |||
| 101 | - | case input.ActionSwitchTabIndex5: | |||
| 102 | - | return d.tabCoord.SwitchByIndex(ctx, 4) | |||
| 103 | - | case input.ActionSwitchTabIndex6: | |||
| 104 | - | return d.tabCoord.SwitchByIndex(ctx, 5) | |||
| 105 | - | case input.ActionSwitchTabIndex7: | |||
| 106 | - | return d.tabCoord.SwitchByIndex(ctx, 6) | |||
| 107 | - | case input.ActionSwitchTabIndex8: | |||
| 108 | - | return d.tabCoord.SwitchByIndex(ctx, 7) | |||
| 109 | - | case input.ActionSwitchTabIndex9: | |||
| 110 | - | return d.tabCoord.SwitchByIndex(ctx, 8) | |||
| 111 | - | case input.ActionSwitchTabIndex10: | |||
| 112 | - | return d.tabCoord.SwitchByIndex(ctx, 9) | |||
| 113 | - | case input.ActionRenameTab: | |||
| 114 | - | log.Debug().Msg("rename tab action (not yet implemented)") | |||
| 115 | - | ||||
| 116 | - | // Pane actions | |||
| 117 | - | case input.ActionSplitRight: | |||
| 118 | - | return d.wsCoord.Split(ctx, usecase.SplitRight) | |||
| 119 | - | case input.ActionSplitLeft: | |||
| 120 | - | return d.wsCoord.Split(ctx, usecase.SplitLeft) | |||
| 121 | - | case input.ActionSplitUp: | |||
| 122 | - | return d.wsCoord.Split(ctx, usecase.SplitUp) | |||
| 123 | - | case input.ActionSplitDown: | |||
| 124 | - | return d.wsCoord.Split(ctx, usecase.SplitDown) | |||
| 125 | - | case input.ActionClosePane: | |||
| 126 | - | return d.wsCoord.ClosePane(ctx) | |||
| 127 | - | case input.ActionStackPane: | |||
| 128 | - | return d.wsCoord.StackPane(ctx) | |||
| 129 | - | case input.ActionFocusRight: | |||
| 130 | - | return d.wsCoord.FocusPane(ctx, usecase.NavRight) | |||
| 131 | - | case input.ActionFocusLeft: | |||
| 132 | - | return d.wsCoord.FocusPane(ctx, usecase.NavLeft) | |||
| 133 | - | case input.ActionFocusUp: | |||
| 134 | - | return d.wsCoord.FocusPane(ctx, usecase.NavUp) | |||
| 135 | - | case input.ActionFocusDown: | |||
| 136 | - | return d.wsCoord.FocusPane(ctx, usecase.NavDown) | |||
| 137 | - | ||||
| 138 | - | // Stack navigation | |||
| 139 | - | case input.ActionStackNavUp: | |||
| 140 | - | return d.wsCoord.NavigateStack(ctx, "up") | |||
| 141 | - | case input.ActionStackNavDown: | |||
| 142 | - | return d.wsCoord.NavigateStack(ctx, "down") | |||
| 143 | - | ||||
| 144 | - | // Navigation | |||
| 145 | - | case input.ActionGoBack: | |||
| 146 | - | return d.navCoord.GoBack(ctx) | |||
| 147 | - | case input.ActionGoForward: | |||
| 148 | - | return d.navCoord.GoForward(ctx) | |||
| 149 | - | case input.ActionReload: | |||
| 150 | - | return d.navCoord.Reload(ctx) | |||
| 151 | - | case input.ActionHardReload: | |||
| 152 | - | return d.navCoord.HardReload(ctx) | |||
| 153 | - | ||||
| 154 | - | // Zoom actions | |||
| 155 | - | case input.ActionZoomIn: | |||
| 156 | - | return d.handleZoom(ctx, "in") | |||
| 157 | - | case input.ActionZoomOut: | |||
| 158 | - | return d.handleZoom(ctx, "out") | |||
| 159 | - | case input.ActionZoomReset: | |||
| 160 | - | return d.handleZoom(ctx, "reset") | |||
| 161 | - | ||||
| 162 | - | // UI | |||
| 163 | - | case input.ActionOpenOmnibox: | |||
| 164 | - | return d.navCoord.OpenOmnibox(ctx) | |||
| 165 | - | case input.ActionOpenFind: | |||
| 166 | - | if d.onFindOpen != nil { | |||
| 167 | - | return d.onFindOpen(ctx) | |||
| 168 | - | } | |||
| 169 | - | log.Debug().Msg("find open action (no handler)") | |||
| 170 | - | case input.ActionFindNext: | |||
| 171 | - | if d.onFindNext != nil { | |||
| 172 | - | return d.onFindNext(ctx) | |||
| 173 | - | } | |||
| 174 | - | log.Debug().Msg("find next action (no handler)") | |||
| 175 | - | case input.ActionFindPrev: | |||
| 176 | - | if d.onFindPrev != nil { | |||
| 177 | - | return d.onFindPrev(ctx) | |||
| 178 | - | } | |||
| 179 | - | log.Debug().Msg("find prev action (no handler)") | |||
| 180 | - | case input.ActionCloseFind: | |||
| 181 | - | if d.onFindClose != nil { | |||
| 182 | - | return d.onFindClose(ctx) | |||
| 183 | - | } | |||
| 184 | - | log.Debug().Msg("find close action (no handler)") | |||
| 185 | - | case input.ActionOpenDevTools: | |||
| 186 | - | return d.navCoord.OpenDevTools(ctx) | |||
| 187 | - | case input.ActionToggleFullscreen: | |||
| 188 | - | log.Debug().Msg("toggle fullscreen action (not yet implemented)") | |||
| 189 | - | ||||
| 190 | - | // Clipboard | |||
| 191 | - | case input.ActionCopyURL: | |||
| 192 | - | return d.handleCopyURL(ctx) | |||
| 193 | - | ||||
| 194 | - | // Application | |||
| 195 | - | case input.ActionQuit: | |||
| 196 | - | if d.onQuit != nil { | |||
| 197 | - | d.onQuit() | |||
| 198 | - | } | |||
| 199 | 164 | ||||
| 200 | - | default: | 165 | + | func (d *KeyboardDispatcher) handleFindOpen(ctx context.Context) error { |
| 201 | - | log.Warn().Str("action", string(action)).Msg("unhandled keyboard action") | 166 | + | if d.onFindOpen != nil { |
| 167 | + | return d.onFindOpen(ctx) | |||
| 202 | } | 168 | } | ||
| 169 | + | logging.FromContext(ctx).Debug().Msg("find open action (no handler)") | |||
| 170 | + | return nil | |||
| 171 | + | } | |||
| 203 | 172 | ||||
| 173 | + | func (d *KeyboardDispatcher) handleFindNext(ctx context.Context) error { | |||
| 174 | + | if d.onFindNext != nil { | |||
| 175 | + | return d.onFindNext(ctx) | |||
| 176 | + | } | |||
| 177 | + | logging.FromContext(ctx).Debug().Msg("find next action (no handler)") | |||
| 178 | + | return nil | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func (d *KeyboardDispatcher) handleFindPrev(ctx context.Context) error { | |||
| 182 | + | if d.onFindPrev != nil { | |||
| 183 | + | return d.onFindPrev(ctx) | |||
| 184 | + | } | |||
| 185 | + | logging.FromContext(ctx).Debug().Msg("find prev action (no handler)") | |||
| 186 | + | return nil | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error { | |||
| 190 | + | if d.onFindClose != nil { | |||
| 191 | + | return d.onFindClose(ctx) | |||
| 192 | + | } | |||
| 193 | + | logging.FromContext(ctx).Debug().Msg("find close action (no handler)") | |||
| 194 | + | return nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error { | |||
| 198 | + | if ctx == nil { | |||
| 199 | + | return fmt.Errorf("missing context") | |||
| 200 | + | } | |||
| 201 | + | logging.FromContext(ctx).Debug().Msg(message) | |||
| 202 | + | return nil | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error { | |||
| 206 | + | if d.onQuit != nil { | |||
| 207 | + | d.onQuit() | |||
| 208 | + | } | |||
| 209 | + | if ctx == nil { | |||
| 210 | + | return fmt.Errorf("missing context") | |||
| 211 | + | } | |||
| 204 | return nil | 212 | return nil | ||
| 205 | } | 213 | } | ||
| 206 | 214 | ||||
| @@ -297,11 +305,10 @@ func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error { | |||||
| 297 | } | 305 | } | ||
| 298 | 306 | ||||
| 299 | // Show toast on GTK main thread | 307 | // Show toast on GTK main thread | ||
| 300 | - | var cb glib.SourceFunc | 308 | + | cb := glib.SourceFunc(func(_ uintptr) bool { |
| 301 | - | cb = func(_ uintptr) bool { | |||
| 302 | d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess) | 309 | d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess) | ||
| 303 | return false | 310 | return false | ||
| 304 | - | } | 311 | + | }) |
| 305 | glib.IdleAdd(&cb, 0) | 312 | glib.IdleAdd(&cb, 0) | ||
| 306 | }() | 313 | }() | ||
| 307 | 314 | ||||
M internal/ui/input/gesture.go +1 -1
| @@ -64,7 +64,7 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) { | |||||
| 64 | h.clickGesture.SetButton(0) | 64 | h.clickGesture.SetButton(0) | ||
| 65 | 65 | ||||
| 66 | // Connect pressed handler | 66 | // Connect pressed handler | ||
| 67 | - | pressedCb := func(gesture gtk.GestureClick, nPress int, x float64, y float64) { | 67 | + | pressedCb := func(_ gtk.GestureClick, nPress int, _ float64, _ float64) { |
| 68 | h.handlePressed(nPress) | 68 | h.handlePressed(nPress) | ||
| 69 | } | 69 | } | ||
| 70 | h.clickGesture.ConnectPressed(&pressedCb) | 70 | h.clickGesture.ConnectPressed(&pressedCb) | ||
M internal/ui/input/hover.go +2 -2
| @@ -64,13 +64,13 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) { | |||||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | // Connect enter handler with debounce | 66 | // Connect enter handler with debounce | ||
| 67 | - | enterCb := func(ctrl gtk.EventControllerMotion, x, y float64) { | 67 | + | enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) { |
| 68 | h.handleEnter() | 68 | h.handleEnter() | ||
| 69 | } | 69 | } | ||
| 70 | h.motionCtrl.ConnectEnter(&enterCb) | 70 | h.motionCtrl.ConnectEnter(&enterCb) | ||
| 71 | 71 | ||||
| 72 | // Connect leave handler to cancel pending focus | 72 | // Connect leave handler to cancel pending focus | ||
| 73 | - | leaveCb := func(ctrl gtk.EventControllerMotion) { | 73 | + | leaveCb := func(_ gtk.EventControllerMotion) { |
| 74 | h.handleLeave() | 74 | h.handleLeave() | ||
| 75 | } | 75 | } | ||
| 76 | h.motionCtrl.ConnectLeave(&leaveCb) | 76 | h.motionCtrl.ConnectLeave(&leaveCb) | ||
M internal/ui/input/keyboard.go +20 -3
| @@ -25,6 +25,8 @@ type KeyboardHandler struct { | |||||
| 25 | 25 | ||||
| 26 | // Action handler callback | 26 | // Action handler callback | ||
| 27 | onAction ActionHandler | 27 | onAction ActionHandler | ||
| 28 | + | // Optional bypass check (e.g., omnibox visible) | |||
| 29 | + | shouldBypass func() bool | |||
| 28 | 30 | ||||
| 29 | // GTK controller (nil until attached) | 31 | // GTK controller (nil until attached) | ||
| 30 | controller *gtk.EventControllerKey | 32 | controller *gtk.EventControllerKey | ||
| @@ -64,6 +66,14 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | |||||
| 64 | h.modal.SetOnModeChange(fn) | 66 | h.modal.SetOnModeChange(fn) | ||
| 65 | } | 67 | } | ||
| 66 | 68 | ||||
| 69 | + | // SetShouldBypassInput sets a hook to bypass keyboard handling entirely. | |||
| 70 | + | // When true, events propagate to focused widgets instead. | |||
| 71 | + | func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) { | |||
| 72 | + | h.mu.Lock() | |||
| 73 | + | defer h.mu.Unlock() | |||
| 74 | + | h.shouldBypass = fn | |||
| 75 | + | } | |||
| 76 | + | ||||
| 67 | // Mode returns the current input mode. | 77 | // Mode returns the current input mode. | ||
| 68 | func (h *KeyboardHandler) Mode() Mode { | 78 | func (h *KeyboardHandler) Mode() Mode { | ||
| 69 | return h.modal.Mode() | 79 | return h.modal.Mode() | ||
| @@ -89,8 +99,8 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||||
| 89 | h.controller.SetPropagationPhase(gtk.PhaseCaptureValue) | 99 | h.controller.SetPropagationPhase(gtk.PhaseCaptureValue) | ||
| 90 | 100 | ||||
| 91 | // Connect key pressed handler | 101 | // Connect key pressed handler | ||
| 92 | - | keyPressedCb := func(ctrl gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool { | 102 | + | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool { |
| 93 | - | return h.handleKeyPress(keyval, keycode, state) | 103 | + | return h.handleKeyPress(keyval, state) |
| 94 | } | 104 | } | ||
| 95 | h.controller.ConnectKeyPressed(&keyPressedCb) | 105 | h.controller.ConnectKeyPressed(&keyPressedCb) | ||
| 96 | 106 | ||||
| @@ -109,7 +119,14 @@ func (h *KeyboardHandler) Detach() { | |||||
| 109 | 119 | ||||
| 110 | // handleKeyPress processes a key press event. | 120 | // handleKeyPress processes a key press event. | ||
| 111 | // Returns true if the event was handled and should not propagate further. | 121 | // Returns true if the event was handled and should not propagate further. | ||
| 112 | - | func (h *KeyboardHandler) handleKeyPress(keyval uint, keycode uint, state gdk.ModifierType) bool { | 122 | + | func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bool { |
| 123 | + | h.mu.RLock() | |||
| 124 | + | shouldBypass := h.shouldBypass | |||
| 125 | + | h.mu.RUnlock() | |||
| 126 | + | if shouldBypass != nil && shouldBypass() { | |||
| 127 | + | return false | |||
| 128 | + | } | |||
| 129 | + | ||||
| 113 | // Build KeyBinding from event | 130 | // Build KeyBinding from event | ||
| 114 | modifiers := Modifier(state) & modifierMask | 131 | modifiers := Modifier(state) & modifierMask | ||
| 115 | binding := KeyBinding{ | 132 | binding := KeyBinding{ | ||
M internal/ui/input/modal_test.go +1 -1
| @@ -225,7 +225,7 @@ func TestModalState_ExitCancelsTimeout(t *testing.T) { | |||||
| 225 | 225 | ||||
| 226 | // Callback should not have been called again | 226 | // Callback should not have been called again | ||
| 227 | if callCount != 2 { | 227 | if callCount != 2 { | ||
| 228 | - | t.Errorf("callback count = %d, want 2 (timeout should have been cancelled)", callCount) | 228 | + | t.Errorf("callback count = %d, want 2 (timeout should have been canceled)", callCount) |
| 229 | } | 229 | } | ||
| 230 | } | 230 | } | ||
| 231 | 231 | ||||
M internal/ui/input/shortcuts.go +107 -127
| @@ -27,6 +27,60 @@ const ( | |||||
| 27 | // modifierMask filters out non-standard modifiers from GDK state. | 27 | // modifierMask filters out non-standard modifiers from GDK state. | ||
| 28 | const modifierMask = ModCtrl | ModShift | ModAlt | 28 | const modifierMask = ModCtrl | ModShift | ModAlt | ||
| 29 | 29 | ||||
| 30 | + | var keyvalByName = map[string]uint{ | |||
| 31 | + | "escape": uint(gdk.KEY_Escape), | |||
| 32 | + | "esc": uint(gdk.KEY_Escape), | |||
| 33 | + | "return": uint(gdk.KEY_Return), | |||
| 34 | + | "enter": uint(gdk.KEY_Return), | |||
| 35 | + | "tab": uint(gdk.KEY_Tab), | |||
| 36 | + | "space": uint(gdk.KEY_space), | |||
| 37 | + | "backspace": uint(gdk.KEY_BackSpace), | |||
| 38 | + | "delete": uint(gdk.KEY_Delete), | |||
| 39 | + | "del": uint(gdk.KEY_Delete), | |||
| 40 | + | "home": uint(gdk.KEY_Home), | |||
| 41 | + | "end": uint(gdk.KEY_End), | |||
| 42 | + | "pageup": uint(gdk.KEY_Page_Up), | |||
| 43 | + | "page_up": uint(gdk.KEY_Page_Up), | |||
| 44 | + | "pagedown": uint(gdk.KEY_Page_Down), | |||
| 45 | + | "page_down": uint(gdk.KEY_Page_Down), | |||
| 46 | + | "left": uint(gdk.KEY_Left), | |||
| 47 | + | "arrowleft": uint(gdk.KEY_Left), | |||
| 48 | + | "right": uint(gdk.KEY_Right), | |||
| 49 | + | "arrowright": uint(gdk.KEY_Right), | |||
| 50 | + | "up": uint(gdk.KEY_Up), | |||
| 51 | + | "arrowup": uint(gdk.KEY_Up), | |||
| 52 | + | "down": uint(gdk.KEY_Down), | |||
| 53 | + | "arrowdown": uint(gdk.KEY_Down), | |||
| 54 | + | "f1": uint(gdk.KEY_F1), | |||
| 55 | + | "f2": uint(gdk.KEY_F2), | |||
| 56 | + | "f3": uint(gdk.KEY_F3), | |||
| 57 | + | "f4": uint(gdk.KEY_F4), | |||
| 58 | + | "f5": uint(gdk.KEY_F5), | |||
| 59 | + | "f6": uint(gdk.KEY_F6), | |||
| 60 | + | "f7": uint(gdk.KEY_F7), | |||
| 61 | + | "f8": uint(gdk.KEY_F8), | |||
| 62 | + | "f9": uint(gdk.KEY_F9), | |||
| 63 | + | "f10": uint(gdk.KEY_F10), | |||
| 64 | + | "f11": uint(gdk.KEY_F11), | |||
| 65 | + | "f12": uint(gdk.KEY_F12), | |||
| 66 | + | "plus": uint(gdk.KEY_plus), | |||
| 67 | + | "+": uint(gdk.KEY_plus), | |||
| 68 | + | "minus": uint(gdk.KEY_minus), | |||
| 69 | + | "-": uint(gdk.KEY_minus), | |||
| 70 | + | "equal": uint(gdk.KEY_equal), | |||
| 71 | + | "=": uint(gdk.KEY_equal), | |||
| 72 | + | "0": uint(gdk.KEY_0), | |||
| 73 | + | "1": uint(gdk.KEY_1), | |||
| 74 | + | "2": uint(gdk.KEY_2), | |||
| 75 | + | "3": uint(gdk.KEY_3), | |||
| 76 | + | "4": uint(gdk.KEY_4), | |||
| 77 | + | "5": uint(gdk.KEY_5), | |||
| 78 | + | "6": uint(gdk.KEY_6), | |||
| 79 | + | "7": uint(gdk.KEY_7), | |||
| 80 | + | "8": uint(gdk.KEY_8), | |||
| 81 | + | "9": uint(gdk.KEY_9), | |||
| 82 | + | } | |||
| 83 | + | ||||
| 30 | // KeyBinding represents a single key combination. | 84 | // KeyBinding represents a single key combination. | ||
| 31 | type KeyBinding struct { | 85 | type KeyBinding struct { | ||
| 32 | Keyval uint // GDK key value (e.g., gdk.KEY_t) | 86 | Keyval uint // GDK key value (e.g., gdk.KEY_t) | ||
| @@ -140,21 +194,48 @@ func NewShortcutSet(ctx context.Context, cfg *config.WorkspaceConfig) *ShortcutS | |||||
| 140 | 194 | ||||
| 141 | // buildGlobalShortcuts populates global shortcuts from config. | 195 | // buildGlobalShortcuts populates global shortcuts from config. | ||
| 142 | func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) { | 196 | func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) { | ||
| 197 | + | s.registerActivationShortcuts(ctx, cfg) | |||
| 198 | + | s.registerConfiguredShortcuts(cfg) | |||
| 199 | + | s.registerStandardShortcuts() | |||
| 200 | + | s.registerPaneNavigationShortcuts() | |||
| 201 | + | s.registerTabSwitchShortcuts() | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // buildTabModeShortcuts populates tab mode shortcuts from config. | |||
| 205 | + | func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) { | |||
| 206 | + | s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab") | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // buildPaneModeShortcuts populates pane mode shortcuts from config. | |||
| 210 | + | func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) { | |||
| 211 | + | s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane") | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) { | |||
| 143 | log := logging.FromContext(ctx) | 215 | log := logging.FromContext(ctx) | ||
| 144 | - | // Mode entry from activation shortcuts | |||
| 145 | if binding, ok := ParseKeyString(cfg.TabMode.ActivationShortcut); ok { | 216 | if binding, ok := ParseKeyString(cfg.TabMode.ActivationShortcut); ok { | ||
| 146 | s.Global[binding] = ActionEnterTabMode | 217 | s.Global[binding] = ActionEnterTabMode | ||
| 147 | - | log.Trace().Str("shortcut", cfg.TabMode.ActivationShortcut).Uint("keyval", binding.Keyval).Uint("mod", uint(binding.Modifiers)).Msg("tab mode activation registered") | 218 | + | log.Trace(). |
| 219 | + | Str("shortcut", cfg.TabMode.ActivationShortcut). | |||
| 220 | + | Uint("keyval", binding.Keyval). | |||
| 221 | + | Uint("mod", uint(binding.Modifiers)). | |||
| 222 | + | Msg("tab mode activation registered") | |||
| 148 | } else { | 223 | } else { | ||
| 149 | log.Warn().Str("shortcut", cfg.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut") | 224 | log.Warn().Str("shortcut", cfg.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut") | ||
| 150 | } | 225 | } | ||
| 151 | if binding, ok := ParseKeyString(cfg.PaneMode.ActivationShortcut); ok { | 226 | if binding, ok := ParseKeyString(cfg.PaneMode.ActivationShortcut); ok { | ||
| 152 | s.Global[binding] = ActionEnterPaneMode | 227 | s.Global[binding] = ActionEnterPaneMode | ||
| 153 | - | log.Trace().Str("shortcut", cfg.PaneMode.ActivationShortcut).Uint("keyval", binding.Keyval).Uint("mod", uint(binding.Modifiers)).Msg("pane mode activation registered") | 228 | + | log.Trace(). |
| 229 | + | Str("shortcut", cfg.PaneMode.ActivationShortcut). | |||
| 230 | + | Uint("keyval", binding.Keyval). | |||
| 231 | + | Uint("mod", uint(binding.Modifiers)). | |||
| 232 | + | Msg("pane mode activation registered") | |||
| 154 | } else { | 233 | } else { | ||
| 155 | log.Warn().Str("shortcut", cfg.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut") | 234 | log.Warn().Str("shortcut", cfg.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut") | ||
| 156 | } | 235 | } | ||
| 236 | + | } | |||
| 157 | 237 | ||||
| 238 | + | func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) { | |||
| 158 | // Note: Ctrl+T is NOT registered globally - it enters tab mode. | 239 | // Note: Ctrl+T is NOT registered globally - it enters tab mode. | ||
| 159 | // In tab mode, use: | 240 | // In tab mode, use: | ||
| 160 | // n = new tab | 241 | // n = new tab | ||
| @@ -174,8 +255,9 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work | |||||
| 174 | if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok { | 255 | if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok { | ||
| 175 | s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab | 256 | s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab | ||
| 176 | } | 257 | } | ||
| 258 | + | } | |||
| 177 | 259 | ||||
| 178 | - | // Standard browser shortcuts (hardcoded defaults) | 260 | + | func (s *ShortcutSet) registerStandardShortcuts() { |
| 179 | s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox | 261 | s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox | ||
| 180 | s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind | 262 | s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind | ||
| 181 | s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext | 263 | s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext | ||
| @@ -196,18 +278,19 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work | |||||
| 196 | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | 278 | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | ||
| 197 | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | 279 | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | ||
| 198 | s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL | 280 | s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL | ||
| 281 | + | } | |||
| 199 | 282 | ||||
| 200 | - | // Pane navigation (Alt+HJKL like vim) | 283 | + | func (s *ShortcutSet) registerPaneNavigationShortcuts() { |
| 201 | s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft | 284 | s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft | ||
| 202 | s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight | 285 | s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight | ||
| 203 | s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp | 286 | s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp | ||
| 204 | s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown | 287 | s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown | ||
| 205 | 288 | ||||
| 206 | - | // Also support Alt+Arrow keys for pane navigation (except Left/Right used for back/forward) | |||
| 207 | s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp | 289 | s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp | ||
| 208 | s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown | 290 | s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown | ||
| 291 | + | } | |||
| 209 | 292 | ||||
| 210 | - | // Direct tab switching (Alt+1-9, Alt+0 for tab 10) | 293 | + | func (s *ShortcutSet) registerTabSwitchShortcuts() { |
| 211 | s.Global[KeyBinding{uint(gdk.KEY_1), ModAlt}] = ActionSwitchTabIndex1 | 294 | s.Global[KeyBinding{uint(gdk.KEY_1), ModAlt}] = ActionSwitchTabIndex1 | ||
| 212 | s.Global[KeyBinding{uint(gdk.KEY_2), ModAlt}] = ActionSwitchTabIndex2 | 295 | s.Global[KeyBinding{uint(gdk.KEY_2), ModAlt}] = ActionSwitchTabIndex2 | ||
| 213 | s.Global[KeyBinding{uint(gdk.KEY_3), ModAlt}] = ActionSwitchTabIndex3 | 296 | s.Global[KeyBinding{uint(gdk.KEY_3), ModAlt}] = ActionSwitchTabIndex3 | ||
| @@ -219,55 +302,38 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work | |||||
| 219 | s.Global[KeyBinding{uint(gdk.KEY_9), ModAlt}] = ActionSwitchTabIndex9 | 302 | s.Global[KeyBinding{uint(gdk.KEY_9), ModAlt}] = ActionSwitchTabIndex9 | ||
| 220 | s.Global[KeyBinding{uint(gdk.KEY_0), ModAlt}] = ActionSwitchTabIndex10 | 303 | s.Global[KeyBinding{uint(gdk.KEY_0), ModAlt}] = ActionSwitchTabIndex10 | ||
| 221 | 304 | ||||
| 222 | - | // Alt+Tab style switching (switch to last active tab) | |||
| 223 | s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt}] = ActionSwitchLastTab | 305 | s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt}] = ActionSwitchLastTab | ||
| 224 | s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab | 306 | s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab | ||
| 225 | } | 307 | } | ||
| 226 | 308 | ||||
| 227 | - | // buildTabModeShortcuts populates tab mode shortcuts from config. | 309 | + | func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) { |
| 228 | - | func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) { | |||
| 229 | - | log := logging.FromContext(ctx) | |||
| 230 | - | bindings := cfg.TabMode.GetKeyBindings() | |||
| 231 | - | var registered, parseErrors, unknownActions int | |||
| 232 | - | for key, configAction := range bindings { | |||
| 233 | - | if binding, ok := ParseKeyString(key); ok { | |||
| 234 | - | if action := mapConfigAction(configAction); action != "" { | |||
| 235 | - | s.TabMode[binding] = action | |||
| 236 | - | registered++ | |||
| 237 | - | log.Trace().Str("key", key).Str("configAction", configAction).Str("action", string(action)).Uint("keyval", binding.Keyval).Msg("tab mode shortcut registered") | |||
| 238 | - | } else { | |||
| 239 | - | unknownActions++ | |||
| 240 | - | log.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in tab mode") | |||
| 241 | - | } | |||
| 242 | - | } else { | |||
| 243 | - | parseErrors++ | |||
| 244 | - | log.Warn().Str("key", key).Msg("failed to parse tab mode key") | |||
| 245 | - | } | |||
| 246 | - | } | |||
| 247 | - | log.Debug().Int("registered", registered).Int("parseErrors", parseErrors).Int("unknownActions", unknownActions).Msg("tab mode shortcuts built") | |||
| 248 | - | } | |||
| 249 | - | ||||
| 250 | - | // buildPaneModeShortcuts populates pane mode shortcuts from config. | |||
| 251 | - | func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) { | |||
| 252 | log := logging.FromContext(ctx) | 310 | log := logging.FromContext(ctx) | ||
| 253 | - | bindings := cfg.PaneMode.GetKeyBindings() | |||
| 254 | var registered, parseErrors, unknownActions int | 311 | var registered, parseErrors, unknownActions int | ||
| 255 | for key, configAction := range bindings { | 312 | for key, configAction := range bindings { | ||
| 256 | if binding, ok := ParseKeyString(key); ok { | 313 | if binding, ok := ParseKeyString(key); ok { | ||
| 257 | if action := mapConfigAction(configAction); action != "" { | 314 | if action := mapConfigAction(configAction); action != "" { | ||
| 258 | - | s.PaneMode[binding] = action | 315 | + | dest[binding] = action |
| 259 | registered++ | 316 | registered++ | ||
| 260 | - | log.Trace().Str("key", key).Str("configAction", configAction).Str("action", string(action)).Uint("keyval", binding.Keyval).Msg("pane mode shortcut registered") | 317 | + | log.Trace(). |
| 318 | + | Str("key", key). | |||
| 319 | + | Str("configAction", configAction). | |||
| 320 | + | Str("action", string(action)). | |||
| 321 | + | Uint("keyval", binding.Keyval). | |||
| 322 | + | Msg(mode + " mode shortcut registered") | |||
| 261 | } else { | 323 | } else { | ||
| 262 | unknownActions++ | 324 | unknownActions++ | ||
| 263 | - | log.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in pane mode") | 325 | + | log.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in " + mode + " mode") |
| 264 | } | 326 | } | ||
| 265 | } else { | 327 | } else { | ||
| 266 | parseErrors++ | 328 | parseErrors++ | ||
| 267 | - | log.Warn().Str("key", key).Msg("failed to parse pane mode key") | 329 | + | log.Warn().Str("key", key).Msg("failed to parse " + mode + " mode key") |
| 268 | } | 330 | } | ||
| 269 | } | 331 | } | ||
| 270 | - | log.Debug().Int("registered", registered).Int("parseErrors", parseErrors).Int("unknownActions", unknownActions).Msg("pane mode shortcuts built") | 332 | + | log.Debug(). |
| 333 | + | Int("registered", registered). | |||
| 334 | + | Int("parseErrors", parseErrors). | |||
| 335 | + | Int("unknownActions", unknownActions). | |||
| 336 | + | Msg(mode + " mode shortcuts built") | |||
| 271 | } | 337 | } | ||
| 272 | 338 | ||||
| 273 | // mapConfigAction maps config action names to Action constants. | 339 | // mapConfigAction maps config action names to Action constants. | ||
| @@ -368,94 +434,8 @@ func ParseKeyString(s string) (KeyBinding, bool) { | |||||
| 368 | 434 | ||||
| 369 | // stringToKeyval converts a key name to its GDK keyval. | 435 | // stringToKeyval converts a key name to its GDK keyval. | ||
| 370 | func stringToKeyval(s string) (uint, bool) { | 436 | func stringToKeyval(s string) (uint, bool) { | ||
| 371 | - | // Special keys | 437 | + | if keyval, ok := keyvalByName[s]; ok { |
| 372 | - | switch s { | 438 | + | return keyval, true |
| 373 | - | case "escape", "esc": | |||
| 374 | - | return uint(gdk.KEY_Escape), true | |||
| 375 | - | case "return", "enter": | |||
| 376 | - | return uint(gdk.KEY_Return), true | |||
| 377 | - | case "tab": | |||
| 378 | - | return uint(gdk.KEY_Tab), true | |||
| 379 | - | case "space": | |||
| 380 | - | return uint(gdk.KEY_space), true | |||
| 381 | - | case "backspace": | |||
| 382 | - | return uint(gdk.KEY_BackSpace), true | |||
| 383 | - | case "delete", "del": | |||
| 384 | - | return uint(gdk.KEY_Delete), true | |||
| 385 | - | case "home": | |||
| 386 | - | return uint(gdk.KEY_Home), true | |||
| 387 | - | case "end": | |||
| 388 | - | return uint(gdk.KEY_End), true | |||
| 389 | - | case "pageup", "page_up": | |||
| 390 | - | return uint(gdk.KEY_Page_Up), true | |||
| 391 | - | case "pagedown", "page_down": | |||
| 392 | - | return uint(gdk.KEY_Page_Down), true | |||
| 393 | - | ||||
| 394 | - | // Arrow keys | |||
| 395 | - | case "left", "arrowleft": | |||
| 396 | - | return uint(gdk.KEY_Left), true | |||
| 397 | - | case "right", "arrowright": | |||
| 398 | - | return uint(gdk.KEY_Right), true | |||
| 399 | - | case "up", "arrowup": | |||
| 400 | - | return uint(gdk.KEY_Up), true | |||
| 401 | - | case "down", "arrowdown": | |||
| 402 | - | return uint(gdk.KEY_Down), true | |||
| 403 | - | ||||
| 404 | - | // Function keys | |||
| 405 | - | case "f1": | |||
| 406 | - | return uint(gdk.KEY_F1), true | |||
| 407 | - | case "f2": | |||
| 408 | - | return uint(gdk.KEY_F2), true | |||
| 409 | - | case "f3": | |||
| 410 | - | return uint(gdk.KEY_F3), true | |||
| 411 | - | case "f4": | |||
| 412 | - | return uint(gdk.KEY_F4), true | |||
| 413 | - | case "f5": | |||
| 414 | - | return uint(gdk.KEY_F5), true | |||
| 415 | - | case "f6": | |||
| 416 | - | return uint(gdk.KEY_F6), true | |||
| 417 | - | case "f7": | |||
| 418 | - | return uint(gdk.KEY_F7), true | |||
| 419 | - | case "f8": | |||
| 420 | - | return uint(gdk.KEY_F8), true | |||
| 421 | - | case "f9": | |||
| 422 | - | return uint(gdk.KEY_F9), true | |||
| 423 | - | case "f10": | |||
| 424 | - | return uint(gdk.KEY_F10), true | |||
| 425 | - | case "f11": | |||
| 426 | - | return uint(gdk.KEY_F11), true | |||
| 427 | - | case "f12": | |||
| 428 | - | return uint(gdk.KEY_F12), true | |||
| 429 | - | ||||
| 430 | - | // Symbols | |||
| 431 | - | case "plus", "+": | |||
| 432 | - | return uint(gdk.KEY_plus), true | |||
| 433 | - | case "minus", "-": | |||
| 434 | - | return uint(gdk.KEY_minus), true | |||
| 435 | - | case "equal", "=": | |||
| 436 | - | return uint(gdk.KEY_equal), true | |||
| 437 | - | ||||
| 438 | - | // Numbers | |||
| 439 | - | case "0": | |||
| 440 | - | return uint(gdk.KEY_0), true | |||
| 441 | - | case "1": | |||
| 442 | - | return uint(gdk.KEY_1), true | |||
| 443 | - | case "2": | |||
| 444 | - | return uint(gdk.KEY_2), true | |||
| 445 | - | case "3": | |||
| 446 | - | return uint(gdk.KEY_3), true | |||
| 447 | - | case "4": | |||
| 448 | - | return uint(gdk.KEY_4), true | |||
| 449 | - | case "5": | |||
| 450 | - | return uint(gdk.KEY_5), true | |||
| 451 | - | case "6": | |||
| 452 | - | return uint(gdk.KEY_6), true | |||
| 453 | - | case "7": | |||
| 454 | - | return uint(gdk.KEY_7), true | |||
| 455 | - | case "8": | |||
| 456 | - | return uint(gdk.KEY_8), true | |||
| 457 | - | case "9": | |||
| 458 | - | return uint(gdk.KEY_9), true | |||
| 459 | } | 439 | } | ||
| 460 | 440 | ||||
| 461 | // Single letter keys (a-z) | 441 | // Single letter keys (a-z) | ||
M internal/ui/layout/gtk_widgets.go +4 -4
| @@ -156,7 +156,7 @@ func (p *gtkPaned) SetWideHandle(wide bool) { p.inner.SetWideHandle(wide) } | |||||
| 156 | func (p *gtkPaned) GetWideHandle() bool { return p.inner.GetWideHandle() } | 156 | func (p *gtkPaned) GetWideHandle() bool { return p.inner.GetWideHandle() } | ||
| 157 | 157 | ||||
| 158 | func (p *gtkPaned) ConnectMap(callback func()) uint32 { | 158 | func (p *gtkPaned) ConnectMap(callback func()) uint32 { | ||
| 159 | - | cb := func(w gtk.Widget) { | 159 | + | cb := func(_ gtk.Widget) { |
| 160 | callback() | 160 | callback() | ||
| 161 | } | 161 | } | ||
| 162 | return p.inner.ConnectMap(&cb) | 162 | return p.inner.ConnectMap(&cb) | ||
| @@ -245,7 +245,7 @@ func (b *gtkBox) Remove(child Widget) { | |||||
| 245 | b.inner.Remove(child.GtkWidget()) | 245 | b.inner.Remove(child.GtkWidget()) | ||
| 246 | } | 246 | } | ||
| 247 | 247 | ||||
| 248 | - | func (b *gtkBox) InsertChildAfter(child Widget, sibling Widget) { | 248 | + | func (b *gtkBox) InsertChildAfter(child, sibling Widget) { |
| 249 | if child == nil { | 249 | if child == nil { | ||
| 250 | return | 250 | return | ||
| 251 | } | 251 | } | ||
| @@ -256,7 +256,7 @@ func (b *gtkBox) InsertChildAfter(child Widget, sibling Widget) { | |||||
| 256 | b.inner.InsertChildAfter(child.GtkWidget(), sibGtk) | 256 | b.inner.InsertChildAfter(child.GtkWidget(), sibGtk) | ||
| 257 | } | 257 | } | ||
| 258 | 258 | ||||
| 259 | - | func (b *gtkBox) ReorderChildAfter(child Widget, sibling Widget) { | 259 | + | func (b *gtkBox) ReorderChildAfter(child, sibling Widget) { |
| 260 | if child == nil { | 260 | if child == nil { | ||
| 261 | return | 261 | return | ||
| 262 | } | 262 | } | ||
| @@ -524,7 +524,7 @@ func (btn *gtkButton) GetChild() Widget { | |||||
| 524 | } | 524 | } | ||
| 525 | 525 | ||||
| 526 | func (btn *gtkButton) ConnectClicked(callback func()) uint32 { | 526 | func (btn *gtkButton) ConnectClicked(callback func()) uint32 { | ||
| 527 | - | cb := func(b gtk.Button) { | 527 | + | cb := func(_ gtk.Button) { |
| 528 | callback() | 528 | callback() | ||
| 529 | } | 529 | } | ||
| 530 | return btn.inner.ConnectClicked(&cb) | 530 | return btn.inner.ConnectClicked(&cb) | ||
M internal/ui/layout/split.go +4 -1
| @@ -27,7 +27,10 @@ const maxRetryFrames = 120 | |||||
| 27 | 27 | ||||
| 28 | // NewSplitView creates a new split view with the given orientation and children. | 28 | // NewSplitView creates a new split view with the given orientation and children. | ||
| 29 | // The ratio determines the initial divider position (0.0-1.0). | 29 | // The ratio determines the initial divider position (0.0-1.0). | ||
| 30 | - | func NewSplitView(ctx context.Context, factory WidgetFactory, orientation Orientation, startChild, endChild Widget, ratio float64) *SplitView { | 30 | + | func NewSplitView( |
| 31 | + | ctx context.Context, factory WidgetFactory, orientation Orientation, | |||
| 32 | + | startChild, endChild Widget, ratio float64, | |||
| 33 | + | ) *SplitView { | |||
| 31 | log := logging.FromContext(ctx) | 34 | log := logging.FromContext(ctx) | ||
| 32 | 35 | ||||
| 33 | paned := factory.NewPaned(orientation) | 36 | paned := factory.NewPaned(orientation) | ||
M internal/ui/layout/split_test.go +4 -4
| @@ -55,7 +55,7 @@ func TestNewSplitView_Horizontal(t *testing.T) { | |||||
| 55 | // Assert | 55 | // Assert | ||
| 56 | require.NotNil(t, sv) | 56 | require.NotNil(t, sv) | ||
| 57 | assert.Equal(t, layout.OrientationHorizontal, sv.Orientation()) | 57 | assert.Equal(t, layout.OrientationHorizontal, sv.Orientation()) | ||
| 58 | - | assert.Equal(t, 0.5, sv.GetRatio()) | 58 | + | assert.InEpsilon(t, 0.5, sv.GetRatio(), 0.0001) |
| 59 | assert.Equal(t, mockStartChild, sv.StartChild()) | 59 | assert.Equal(t, mockStartChild, sv.StartChild()) | ||
| 60 | assert.Equal(t, mockEndChild, sv.EndChild()) | 60 | assert.Equal(t, mockEndChild, sv.EndChild()) | ||
| 61 | } | 61 | } | ||
| @@ -131,7 +131,7 @@ func TestSetRatio_ValidRange(t *testing.T) { | |||||
| 131 | sv.SetRatio(tt.input) | 131 | sv.SetRatio(tt.input) | ||
| 132 | 132 | ||||
| 133 | // Assert | 133 | // Assert | ||
| 134 | - | assert.Equal(t, tt.expected, sv.GetRatio()) | 134 | + | assert.InEpsilon(t, tt.expected, sv.GetRatio(), 0.0001) |
| 135 | }) | 135 | }) | ||
| 136 | } | 136 | } | ||
| 137 | } | 137 | } | ||
| @@ -164,7 +164,7 @@ func TestSetRatio_OutOfRange_Clamped(t *testing.T) { | |||||
| 164 | sv.SetRatio(tt.input) | 164 | sv.SetRatio(tt.input) | ||
| 165 | 165 | ||||
| 166 | // Assert | 166 | // Assert | ||
| 167 | - | assert.Equal(t, tt.expected, sv.GetRatio()) | 167 | + | assert.InEpsilon(t, tt.expected, sv.GetRatio(), 0.0001) |
| 168 | }) | 168 | }) | ||
| 169 | } | 169 | } | ||
| 170 | } | 170 | } | ||
| @@ -368,7 +368,7 @@ func TestNewSplitView_InitialRatio_Clamped(t *testing.T) { | |||||
| 368 | sv := layout.NewSplitView(ctx, mockFactory, layout.OrientationHorizontal, nil, nil, tt.input) | 368 | sv := layout.NewSplitView(ctx, mockFactory, layout.OrientationHorizontal, nil, nil, tt.input) | ||
| 369 | 369 | ||||
| 370 | // Assert | 370 | // Assert | ||
| 371 | - | assert.Equal(t, tt.expected, sv.GetRatio()) | 371 | + | assert.InEpsilon(t, tt.expected, sv.GetRatio(), 0.0001) |
| 372 | }) | 372 | }) | ||
| 373 | } | 373 | } | ||
| 374 | } | 374 | } | ||
M internal/ui/layout/stacked.go +4 -2
| @@ -17,6 +17,8 @@ var ErrIndexOutOfBounds = errors.New("index out of bounds") | |||||
| 17 | // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack. | 17 | // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack. | ||
| 18 | var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack") | 18 | var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack") | ||
| 19 | 19 | ||||
| 20 | + | const stackedTitleMaxWidthChars = 30 | |||
| 21 | + | ||||
| 20 | // stackedPane represents a single pane within a stacked container. | 22 | // stackedPane represents a single pane within a stacked container. | ||
| 21 | type stackedPane struct { | 23 | type stackedPane struct { | ||
| 22 | titleBar BoxWidget // horizontal box with favicon + title label | 24 | titleBar BoxWidget // horizontal box with favicon + title label | ||
| @@ -85,7 +87,7 @@ func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName strin | |||||
| 85 | // Create title label | 87 | // Create title label | ||
| 86 | label := sv.factory.NewLabel(title) | 88 | label := sv.factory.NewLabel(title) | ||
| 87 | label.SetEllipsize(EllipsizeEnd) | 89 | label.SetEllipsize(EllipsizeEnd) | ||
| 88 | - | label.SetMaxWidthChars(30) | 90 | + | label.SetMaxWidthChars(stackedTitleMaxWidthChars) |
| 89 | label.SetHexpand(true) | 91 | label.SetHexpand(true) | ||
| 90 | label.SetXalign(0.0) | 92 | label.SetXalign(0.0) | ||
| 91 | titleBar.Append(label) | 93 | titleBar.Append(label) | ||
| @@ -186,7 +188,7 @@ func (sv *StackedView) InsertPaneAfter( | |||||
| 186 | // Create title label | 188 | // Create title label | ||
| 187 | label := sv.factory.NewLabel(title) | 189 | label := sv.factory.NewLabel(title) | ||
| 188 | label.SetEllipsize(EllipsizeEnd) | 190 | label.SetEllipsize(EllipsizeEnd) | ||
| 189 | - | label.SetMaxWidthChars(30) | 191 | + | label.SetMaxWidthChars(stackedTitleMaxWidthChars) |
| 190 | label.SetHexpand(true) | 192 | label.SetHexpand(true) | ||
| 191 | label.SetXalign(0.0) | 193 | label.SetXalign(0.0) | ||
| 192 | titleBar.Append(label) | 194 | titleBar.Append(label) | ||
M internal/ui/layout/stacked_test.go +81 -23
| @@ -86,7 +86,10 @@ func TestAddPane_SinglePane_BecomesActive(t *testing.T) { | |||||
| 86 | // Arrange | 86 | // Arrange | ||
| 87 | ctx := context.Background() | 87 | ctx := context.Background() | ||
| 88 | mockFactory, mockBox := setupMockFactory(t) | 88 | mockFactory, mockBox := setupMockFactory(t) | ||
| 89 | - | mockTitleBar, _, _, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | 89 | + | mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox) |
| 90 | + | _ = mockFavicon | |||
| 91 | + | _ = mockLabel | |||
| 92 | + | _ = mockButton | |||
| 90 | 93 | ||||
| 91 | // Visibility updates for active pane | 94 | // Visibility updates for active pane | ||
| 92 | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 95 | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | ||
| @@ -110,13 +113,19 @@ func TestAddPane_MultiplePanes_LastBecomesActive(t *testing.T) { | |||||
| 110 | mockFactory, mockBox := setupMockFactory(t) | 113 | mockFactory, mockBox := setupMockFactory(t) | ||
| 111 | 114 | ||||
| 112 | // First pane | 115 | // First pane | ||
| 113 | - | mockTitleBar1, _, _, _, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | 116 | + | mockTitleBar1, mockFavicon1, mockLabel1, mockButton1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) |
| 117 | + | _ = mockFavicon1 | |||
| 118 | + | _ = mockLabel1 | |||
| 119 | + | _ = mockButton1 | |||
| 114 | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | 120 | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | ||
| 115 | mockContainer1.EXPECT().SetVisible(true).Once() | 121 | mockContainer1.EXPECT().SetVisible(true).Once() | ||
| 116 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | 122 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | ||
| 117 | 123 | ||||
| 118 | // Second pane - first pane becomes inactive | 124 | // Second pane - first pane becomes inactive | ||
| 119 | - | mockTitleBar2, _, _, _, mockContainer2 := setupPaneMocks(t, mockFactory, mockBox) | 125 | + | mockTitleBar2, mockFavicon2, mockLabel2, mockButton2, mockContainer2 := setupPaneMocks(t, mockFactory, mockBox) |
| 126 | + | _ = mockFavicon2 | |||
| 127 | + | _ = mockLabel2 | |||
| 128 | + | _ = mockButton2 | |||
| 120 | 129 | ||||
| 121 | // When second pane is added, update visibility for both | 130 | // When second pane is added, update visibility for both | ||
| 122 | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | 131 | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | ||
| @@ -187,7 +196,10 @@ func TestRemovePane_LastPane_ReturnsError(t *testing.T) { | |||||
| 187 | // Arrange | 196 | // Arrange | ||
| 188 | ctx := context.Background() | 197 | ctx := context.Background() | ||
| 189 | mockFactory, mockBox := setupMockFactory(t) | 198 | mockFactory, mockBox := setupMockFactory(t) | ||
| 190 | - | mockTitleBar, _, _, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | 199 | + | mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox) |
| 200 | + | _ = mockFavicon | |||
| 201 | + | _ = mockLabel | |||
| 202 | + | _ = mockButton | |||
| 191 | 203 | ||||
| 192 | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 204 | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | ||
| 193 | mockContainer.EXPECT().SetVisible(true).Once() | 205 | mockContainer.EXPECT().SetVisible(true).Once() | ||
| @@ -200,7 +212,7 @@ func TestRemovePane_LastPane_ReturnsError(t *testing.T) { | |||||
| 200 | err := sv.RemovePane(ctx, 0) | 212 | err := sv.RemovePane(ctx, 0) | ||
| 201 | 213 | ||||
| 202 | // Assert | 214 | // Assert | ||
| 203 | - | assert.ErrorIs(t, err, layout.ErrCannotRemoveLastPane) | 215 | + | require.ErrorIs(t, err, layout.ErrCannotRemoveLastPane) |
| 204 | assert.Equal(t, 1, sv.Count()) | 216 | assert.Equal(t, 1, sv.Count()) | ||
| 205 | } | 217 | } | ||
| 206 | 218 | ||||
| @@ -214,14 +226,17 @@ func TestRemovePane_EmptyStack_ReturnsError(t *testing.T) { | |||||
| 214 | err := sv.RemovePane(ctx, 0) | 226 | err := sv.RemovePane(ctx, 0) | ||
| 215 | 227 | ||||
| 216 | // Assert | 228 | // Assert | ||
| 217 | - | assert.ErrorIs(t, err, layout.ErrStackEmpty) | 229 | + | require.ErrorIs(t, err, layout.ErrStackEmpty) |
| 218 | } | 230 | } | ||
| 219 | 231 | ||||
| 220 | func TestRemovePane_IndexOutOfBounds(t *testing.T) { | 232 | func TestRemovePane_IndexOutOfBounds(t *testing.T) { | ||
| 221 | // Arrange | 233 | // Arrange | ||
| 222 | ctx := context.Background() | 234 | ctx := context.Background() | ||
| 223 | mockFactory, mockBox := setupMockFactory(t) | 235 | mockFactory, mockBox := setupMockFactory(t) | ||
| 224 | - | mockTitleBar, _, _, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | 236 | + | mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox) |
| 237 | + | _ = mockFavicon | |||
| 238 | + | _ = mockLabel | |||
| 239 | + | _ = mockButton | |||
| 225 | 240 | ||||
| 226 | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 241 | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | ||
| 227 | mockContainer.EXPECT().SetVisible(true).Once() | 242 | mockContainer.EXPECT().SetVisible(true).Once() | ||
| @@ -234,7 +249,7 @@ func TestRemovePane_IndexOutOfBounds(t *testing.T) { | |||||
| 234 | err := sv.RemovePane(ctx, 5) | 249 | err := sv.RemovePane(ctx, 5) | ||
| 235 | 250 | ||||
| 236 | // Assert | 251 | // Assert | ||
| 237 | - | assert.ErrorIs(t, err, layout.ErrIndexOutOfBounds) | 252 | + | require.ErrorIs(t, err, layout.ErrIndexOutOfBounds) |
| 238 | } | 253 | } | ||
| 239 | 254 | ||||
| 240 | func TestSetActive_ValidIndex(t *testing.T) { | 255 | func TestSetActive_ValidIndex(t *testing.T) { | ||
| @@ -246,7 +261,14 @@ func TestSetActive_ValidIndex(t *testing.T) { | |||||
| 246 | titleBars := make([]*mocks.MockBoxWidget, 2) | 261 | titleBars := make([]*mocks.MockBoxWidget, 2) | ||
| 247 | 262 | ||||
| 248 | for i := 0; i < 2; i++ { | 263 | for i := 0; i < 2; i++ { | ||
| 249 | - | titleBars[i], _, _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox) | 264 | + | titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox) |
| 265 | + | _ = mockFavicon | |||
| 266 | + | _ = mockOverlay | |||
| 267 | + | _ = mockSpinner | |||
| 268 | + | ||||
| 269 | + | titleBars[i] = titleBar | |||
| 270 | + | containers[i] = container | |||
| 271 | + | ||||
| 250 | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | 272 | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | ||
| 251 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | 273 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | ||
| 252 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | 274 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | ||
| @@ -269,7 +291,10 @@ func TestSetActive_OutOfBounds(t *testing.T) { | |||||
| 269 | // Arrange | 291 | // Arrange | ||
| 270 | ctx := context.Background() | 292 | ctx := context.Background() | ||
| 271 | mockFactory, mockBox := setupMockFactory(t) | 293 | mockFactory, mockBox := setupMockFactory(t) | ||
| 272 | - | mockTitleBar, _, _, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | 294 | + | mockTitleBar, mockFavicon, mockLabel, mockButton, mockContainer := setupPaneMocks(t, mockFactory, mockBox) |
| 295 | + | _ = mockFavicon | |||
| 296 | + | _ = mockLabel | |||
| 297 | + | _ = mockButton | |||
| 273 | 298 | ||||
| 274 | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 299 | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | ||
| 275 | mockContainer.EXPECT().SetVisible(true).Once() | 300 | mockContainer.EXPECT().SetVisible(true).Once() | ||
| @@ -282,7 +307,7 @@ func TestSetActive_OutOfBounds(t *testing.T) { | |||||
| 282 | err := sv.SetActive(ctx, 5) | 307 | err := sv.SetActive(ctx, 5) | ||
| 283 | 308 | ||||
| 284 | // Assert | 309 | // Assert | ||
| 285 | - | assert.ErrorIs(t, err, layout.ErrIndexOutOfBounds) | 310 | + | require.ErrorIs(t, err, layout.ErrIndexOutOfBounds) |
| 286 | } | 311 | } | ||
| 287 | 312 | ||||
| 288 | func TestSetActive_EmptyStack(t *testing.T) { | 313 | func TestSetActive_EmptyStack(t *testing.T) { | ||
| @@ -295,7 +320,7 @@ func TestSetActive_EmptyStack(t *testing.T) { | |||||
| 295 | err := sv.SetActive(ctx, 0) | 320 | err := sv.SetActive(ctx, 0) | ||
| 296 | 321 | ||||
| 297 | // Assert | 322 | // Assert | ||
| 298 | - | assert.ErrorIs(t, err, layout.ErrStackEmpty) | 323 | + | require.ErrorIs(t, err, layout.ErrStackEmpty) |
| 299 | } | 324 | } | ||
| 300 | 325 | ||||
| 301 | func TestUpdateTitle(t *testing.T) { | 326 | func TestUpdateTitle(t *testing.T) { | ||
| @@ -329,7 +354,7 @@ func TestUpdateTitle_InvalidIndex(t *testing.T) { | |||||
| 329 | err := sv.UpdateTitle(0, "Title") | 354 | err := sv.UpdateTitle(0, "Title") | ||
| 330 | 355 | ||||
| 331 | // Assert | 356 | // Assert | ||
| 332 | - | assert.ErrorIs(t, err, layout.ErrIndexOutOfBounds) | 357 | + | require.ErrorIs(t, err, layout.ErrIndexOutOfBounds) |
| 333 | } | 358 | } | ||
| 334 | 359 | ||||
| 335 | func TestUpdateFavicon(t *testing.T) { | 360 | func TestUpdateFavicon(t *testing.T) { | ||
| @@ -358,7 +383,10 @@ func TestGetContainer(t *testing.T) { | |||||
| 358 | // Arrange | 383 | // Arrange | ||
| 359 | ctx := context.Background() | 384 | ctx := context.Background() | ||
| 360 | mockFactory, mockBox := setupMockFactory(t) | 385 | mockFactory, mockBox := setupMockFactory(t) | ||
| 361 | - | mockTitleBar, _, _, _, mockContainer := setupPaneMocks(t, mockFactory, mockBox) | 386 | + | mockTitleBar, mockFavicon, mockOverlay, mockSpinner, mockContainer := setupPaneMocks(t, mockFactory, mockBox) |
| 387 | + | _ = mockFavicon | |||
| 388 | + | _ = mockOverlay | |||
| 389 | + | _ = mockSpinner | |||
| 362 | 390 | ||||
| 363 | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 391 | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | ||
| 364 | mockContainer.EXPECT().SetVisible(true).Once() | 392 | mockContainer.EXPECT().SetVisible(true).Once() | ||
| @@ -384,7 +412,7 @@ func TestGetContainer_InvalidIndex(t *testing.T) { | |||||
| 384 | container, err := sv.GetContainer(0) | 412 | container, err := sv.GetContainer(0) | ||
| 385 | 413 | ||||
| 386 | // Assert | 414 | // Assert | ||
| 387 | - | assert.ErrorIs(t, err, layout.ErrIndexOutOfBounds) | 415 | + | require.ErrorIs(t, err, layout.ErrIndexOutOfBounds) |
| 388 | assert.Nil(t, container) | 416 | assert.Nil(t, container) | ||
| 389 | } | 417 | } | ||
| 390 | 418 | ||||
| @@ -409,7 +437,14 @@ func TestNavigateNext_WrapsAround(t *testing.T) { | |||||
| 409 | titleBars := make([]*mocks.MockBoxWidget, 2) | 437 | titleBars := make([]*mocks.MockBoxWidget, 2) | ||
| 410 | 438 | ||||
| 411 | for i := 0; i < 2; i++ { | 439 | for i := 0; i < 2; i++ { | ||
| 412 | - | titleBars[i], _, _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox) | 440 | + | titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox) |
| 441 | + | _ = mockFavicon | |||
| 442 | + | _ = mockOverlay | |||
| 443 | + | _ = mockSpinner | |||
| 444 | + | ||||
| 445 | + | titleBars[i] = titleBar | |||
| 446 | + | containers[i] = container | |||
| 447 | + | ||||
| 413 | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | 448 | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | ||
| 414 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | 449 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | ||
| 415 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | 450 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | ||
| @@ -440,7 +475,14 @@ func TestNavigatePrevious_WrapsAround(t *testing.T) { | |||||
| 440 | titleBars := make([]*mocks.MockBoxWidget, 2) | 475 | titleBars := make([]*mocks.MockBoxWidget, 2) | ||
| 441 | 476 | ||||
| 442 | for i := 0; i < 2; i++ { | 477 | for i := 0; i < 2; i++ { | ||
| 443 | - | titleBars[i], _, _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox) | 478 | + | titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox) |
| 479 | + | _ = mockFavicon | |||
| 480 | + | _ = mockOverlay | |||
| 481 | + | _ = mockSpinner | |||
| 482 | + | ||||
| 483 | + | titleBars[i] = titleBar | |||
| 484 | + | containers[i] = container | |||
| 485 | + | ||||
| 444 | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | 486 | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | ||
| 445 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | 487 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | ||
| 446 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | 488 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | ||
| @@ -472,7 +514,7 @@ func TestNavigateNext_EmptyStack(t *testing.T) { | |||||
| 472 | err := sv.NavigateNext(ctx) | 514 | err := sv.NavigateNext(ctx) | ||
| 473 | 515 | ||||
| 474 | // Assert | 516 | // Assert | ||
| 475 | - | assert.ErrorIs(t, err, layout.ErrStackEmpty) | 517 | + | require.ErrorIs(t, err, layout.ErrStackEmpty) |
| 476 | } | 518 | } | ||
| 477 | 519 | ||||
| 478 | func TestNavigatePrevious_EmptyStack(t *testing.T) { | 520 | func TestNavigatePrevious_EmptyStack(t *testing.T) { | ||
| @@ -485,7 +527,7 @@ func TestNavigatePrevious_EmptyStack(t *testing.T) { | |||||
| 485 | err := sv.NavigatePrevious(ctx) | 527 | err := sv.NavigatePrevious(ctx) | ||
| 486 | 528 | ||||
| 487 | // Assert | 529 | // Assert | ||
| 488 | - | assert.ErrorIs(t, err, layout.ErrStackEmpty) | 530 | + | require.ErrorIs(t, err, layout.ErrStackEmpty) |
| 489 | } | 531 | } | ||
| 490 | 532 | ||||
| 491 | func TestSetOnActivate_Callback(t *testing.T) { | 533 | func TestSetOnActivate_Callback(t *testing.T) { | ||
| @@ -566,7 +608,10 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) { | |||||
| 566 | mockFactory, mockBox := setupMockFactory(t) | 608 | mockFactory, mockBox := setupMockFactory(t) | ||
| 567 | 609 | ||||
| 568 | // First pane - appended normally | 610 | // First pane - appended normally | ||
| 569 | - | mockTitleBar1, _, _, _, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | 611 | + | mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) |
| 612 | + | _ = mockFavicon1 | |||
| 613 | + | _ = mockOverlay1 | |||
| 614 | + | _ = mockSpinner1 | |||
| 570 | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | 615 | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | ||
| 571 | mockContainer1.EXPECT().SetVisible(true).Once() | 616 | mockContainer1.EXPECT().SetVisible(true).Once() | ||
| 572 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | 617 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | ||
| @@ -641,7 +686,10 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) { | |||||
| 641 | mockFactory, mockBox := setupMockFactory(t) | 686 | mockFactory, mockBox := setupMockFactory(t) | ||
| 642 | 687 | ||||
| 643 | // First pane | 688 | // First pane | ||
| 644 | - | mockTitleBar1, _, _, _, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | 689 | + | mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) |
| 690 | + | _ = mockFavicon1 | |||
| 691 | + | _ = mockOverlay1 | |||
| 692 | + | _ = mockSpinner1 | |||
| 645 | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | 693 | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | ||
| 646 | mockContainer1.EXPECT().SetVisible(true).Once() | 694 | mockContainer1.EXPECT().SetVisible(true).Once() | ||
| 647 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | 695 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | ||
| @@ -679,7 +727,14 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) { | |||||
| 679 | titleBars := make([]*mocks.MockBoxWidget, 3) | 727 | titleBars := make([]*mocks.MockBoxWidget, 3) | ||
| 680 | 728 | ||||
| 681 | for i := 0; i < 3; i++ { | 729 | for i := 0; i < 3; i++ { | ||
| 682 | - | titleBars[i], _, _, _, containers[i] = setupPaneMocks(t, mockFactory, mockBox) | 730 | + | titleBar, mockFavicon, mockOverlay, mockSpinner, container := setupPaneMocks(t, mockFactory, mockBox) |
| 731 | + | _ = mockFavicon | |||
| 732 | + | _ = mockOverlay | |||
| 733 | + | _ = mockSpinner | |||
| 734 | + | ||||
| 735 | + | titleBars[i] = titleBar | |||
| 736 | + | containers[i] = container | |||
| 737 | + | ||||
| 683 | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | 738 | titleBars[i].EXPECT().GetParent().Return(nil).Maybe() | ||
| 684 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | 739 | containers[i].EXPECT().SetVisible(mock.Anything).Maybe() | ||
| 685 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | 740 | titleBars[i].EXPECT().AddCssClass("active").Maybe() | ||
| @@ -727,7 +782,10 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) { | |||||
| 727 | mockFactory, mockBox := setupMockFactory(t) | 782 | mockFactory, mockBox := setupMockFactory(t) | ||
| 728 | 783 | ||||
| 729 | // First pane | 784 | // First pane | ||
| 730 | - | mockTitleBar1, _, _, _, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) | 785 | + | mockTitleBar1, mockFavicon1, mockOverlay1, mockSpinner1, mockContainer1 := setupPaneMocks(t, mockFactory, mockBox) |
| 786 | + | _ = mockFavicon1 | |||
| 787 | + | _ = mockOverlay1 | |||
| 788 | + | _ = mockSpinner1 | |||
| 731 | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | 789 | mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe() | ||
| 732 | mockContainer1.EXPECT().SetVisible(true).Once() | 790 | mockContainer1.EXPECT().SetVisible(true).Once() | ||
| 733 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | 791 | mockTitleBar1.EXPECT().AddCssClass("active").Once() | ||
M internal/ui/layout/tree_test.go +4 -1
| @@ -19,7 +19,10 @@ func setupStackedLeafMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory) ( | |||||
| 19 | mockStackBox.EXPECT().SetHexpand(true).Once() | 19 | mockStackBox.EXPECT().SetHexpand(true).Once() | ||
| 20 | mockStackBox.EXPECT().SetVexpand(true).Once() | 20 | mockStackBox.EXPECT().SetVexpand(true).Once() | ||
| 21 | 21 | ||||
| 22 | - | mockTitleBar, _, _, _, mockContainer := setupPaneMocks(t, mockFactory, mockStackBox) | 22 | + | mockTitleBar, mockFavicon, mockOverlay, mockSpinner, mockContainer := setupPaneMocks(t, mockFactory, mockStackBox) |
| 23 | + | _ = mockFavicon | |||
| 24 | + | _ = mockOverlay | |||
| 25 | + | _ = mockSpinner | |||
| 23 | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | 26 | mockTitleBar.EXPECT().GetParent().Return(nil).Maybe() | ||
| 24 | mockContainer.EXPECT().SetVisible(true).Once() | 27 | mockContainer.EXPECT().SetVisible(true).Once() | ||
| 25 | mockTitleBar.EXPECT().AddCssClass("active").Once() | 28 | mockTitleBar.EXPECT().AddCssClass("active").Once() | ||
M internal/ui/theme/find_highlight.go +2 -1
| @@ -9,7 +9,8 @@ import ( | |||||
| 9 | // GenerateFindHighlightCSS builds CSS to style find-in-page matches using the accent color. | 9 | // GenerateFindHighlightCSS builds CSS to style find-in-page matches using the accent color. | ||
| 10 | func GenerateFindHighlightCSS(p Palette) string { | 10 | func GenerateFindHighlightCSS(p Palette) string { | ||
| 11 | accent := strings.TrimSpace(p.Accent) | 11 | accent := strings.TrimSpace(p.Accent) | ||
| 12 | - | dim := rgbaFromHex(accent, 0.50) | 12 | + | const highlightDimAlpha = 0.5 |
| 13 | + | dim := rgbaFromHex(accent, highlightDimAlpha) | |||
| 13 | active := accent | 14 | active := accent | ||
| 14 | 15 | ||||
| 15 | return fmt.Sprintf(`/* Find highlight styling */ | 16 | return fmt.Sprintf(`/* Find highlight styling */ | ||