detached 0bdd398

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 - goconst14 - goconst
15 - gocritic15 - gocritic
16 - gocyclo16 - gocyclo
17- - godox17+ # - godox # TODOs/FIXMEs are intentional markers
18 - mnd18 - mnd
19 - goprintffuncname19 - goprintffuncname
20 - gosec20 - gosec
@@ -34,7 +34,7 @@ linters:
34 34
35 settings:35 settings:
36 dupl:36 dupl:
37- threshold: 10037+ threshold: 150
38 funlen:38 funlen:
39 lines: -139 lines: -1
40 statements: 5040 statements: 50
@@ -53,13 +53,11 @@ linters:
53 - ifElseChain53 - ifElseChain
54 - octalLiteral54 - octalLiteral
55 - whyNoLint55 - whyNoLint
56+ - unnamedResult
57+ - hugeParam
58+ - singleCaseSwitch
56 gocyclo:59 gocyclo:
57- min-complexity: 1560+ min-complexity: 20
58- godox:
59- keywords:
60- - FIXME
61- - TODO
62- - BUG
63 mnd:61 mnd:
64 checks:62 checks:
65 - argument63 - 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.SplitN81 - 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 - nilness90 - nilness
@@ -80,7 +92,7 @@ linters:
80 errorlint:92 errorlint:
81 asserts: false93 asserts: false
82 lll:94 lll:
83- line-length: 12095+ line-length: 140
84 misspell:96 misspell:
85 locale: US97 locale: US
86 nolintlint:98 nolintlint:
@@ -98,27 +110,54 @@ linters:
98 paths:110 paths:
99 - vendor111 - 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\.go114 - path: _test\.go
108 linters:115 linters:
109 - dupl116 - dupl
110 - mnd117 - mnd
111 - lll118 - lll
112 - funlen119 - funlen
120+ - goconst
121+ - errcheck
122+ - gosec
123+ # Generated SQL code
113 - path: internal/db/.*\.sql\.go124 - path: internal/db/.*\.sql\.go
114 linters:125 linters:
115 - all126 - all
116 - path: internal/db/models\.go127 - path: internal/db/models\.go
117 linters:128 linters:
118 - unused129 - unused
119- - path: internal/db/130+ # gosec false positives
131+ - text: "G115:"
120 linters:132 linters:
121- - lll133+ - 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: 20m163 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 them48 // 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 return51 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 thread67 // GTK requires all GTK calls to be made from the main thread
64 runtime.LockOSThread()68 runtime.LockOSThread()
65 69
66- // Initialize configuration70+ 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 run75 // Generate session ID for this browser run
78 sessionID := logging.GenerateSessionID()76 sessionID := logging.GenerateSessionID()
79 77
80- // Initialize logger with session file output78+ 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 WebKit236+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 repositories261+ 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 webui267+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 enabled286+ 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- // Repositories300+ HistoryRepo: historyRepo,
261- HistoryRepo: historyRepo,301+ FavoriteRepo: favoriteRepo,
262- FavoriteRepo: favoriteRepo,302+ ZoomRepo: zoomRepo,
263- ZoomRepo: zoomRepo,303+ TabsUC: tabsUC,
264- // Use Cases304+ 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 installed45 // 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 requested53 // 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.MinGTK4Version76 minGTK := input.MinGTK4Version
@@ -156,12 +159,12 @@ func compareVersion(a, b string) (cmp int, ok bool) {
156 return 0, false159 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 := 0168 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 return95 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 := true392+ var caseInsensitive bool
391 if uc.caseMode == caseModeManual {393 if uc.caseMode == caseModeManual {
392 caseInsensitive = !uc.caseSensitive394 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 direction469 // Check if we can move in the desired direction
457- targetIndex := childIndex470+ var targetIndex int
458 if isForward {471 if isForward {
459 targetIndex = childIndex + 1472 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 nil808 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 0817 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 // ok88 // 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 file156 // 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 config39 // 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 config42 // 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 exists79 // 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 fails129 // Return default config if manager fails
132- return config.DefaultConfig(), nil130+ 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 fails134 // Return default config if loading fails
137- return config.DefaultConfig(), nil135+ return config.DefaultConfig()
138 }136 }
139 137
140- return mgr.Get(), nil138+ 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 := true52+ runRuntime := !doctorOnlyMedia
53- runMedia := true53+ 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 int16 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 bool24 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.Time59 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 first195 // 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, nil198 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 ID202 // Try partial match on full session ID
201 var matches []SessionInfo203 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 lines237 // Read all lines
232 var allLines []string238 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 end269 // 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 bool21 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 remove81+ dirs := purgeDirList(home)
61- dirs := []struct {82+ existing := collectExistingDirs(dirs)
62- path string83+
63- desc string84+ 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 exist90+ // Check if there's anything to remove
72- var existing []struct {91+ if len(existing) == 0 && desktopStatus == nil {
73- path string92+ fmt.Println("No dumber directories found.")
74- desc string93+ 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 string100+ fmt.Println("Aborted.")
83- desc string101+ return nil
84- size int64102+ }
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 set121+ fmt.Println()
90- var desktopStatus *struct {122+ fmt.Println("Purge complete.")
91- desktopInstalled bool123+ return nil
92- iconInstalled bool124+}
93- isDefault bool125+
94- desktopPath string126+func purgeDirList(home string) []purgeDir {
95- iconPath string127+ 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 bool138+ if info, err := os.Stat(d.path); err == nil && info.IsDir() {
103- iconInstalled bool139+ existing = append(existing, purgeDirWithSize{
104- isDefault bool140+ path: d.path,
105- desktopPath string141+ desc: d.desc,
106- iconPath string142+ 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 remove149+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 nil153 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 deleted164+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 --force184+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 directories195+func removeDirs(existing []purgeDirWithSize) []string {
156 var errors []string196 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 integration207+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 nil220+ 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 string268 path string
242 desc string269 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 commands22 // Skip initialization for help commands
23 if cmd.Name() == "help" || cmd.Name() == "completion" {23 if cmd.Name() == "help" || cmd.Name() == "completion" {
24 return nil24 return nil
@@ -33,7 +33,7 @@ var (
33 app.BuildInfo = buildInfo33 app.BuildInfo = buildInfo
34 return nil34 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 homepage66 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 runs69 // 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 nil105 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 nil43 return nil
41 }44 }
42 45
@@ -69,7 +72,7 @@ func (m CleanupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
69 m.cleanup = cleanup72 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.Quit76 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 error64 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.Width178+ 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 handling180+ 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 handling182+ 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 confirmation186+ 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 domain189+ 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 modal192+ 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.err200+ m.cleanup = &cleanup
319- } else {201+ if m.cleanup.Done() {
320- m.topDomains = msg.domains202+ 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.err212+ m.confirm = &confirm
326- } else {213+ if m.confirm.Done() {
327- // Reload history214+ 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.err224+ m.height = msg.Height
334- } else {225+ m.help.Width = msg.Width
335- // Reload history226+ 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 Linux526 // Use xdg-open on Linux
486 _ = exec.Command("xdg-open", urlStr).Start()527 _ = exec.Command("xdg-open", urlStr).Start()
487 return nil528 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 externally661 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 characters210 // Build sparkline using block characters
211 blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}211 blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
212+ const (
213+ sparklineMaxDays = 30
214+ sparklineMaxIndex = 7
215+ )
212 var result string216 var result string
213 217
214 // Show last 30 days or available data218 // Show last 30 days or available data
215 days := m.analytics.DailyVisits219 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 = 7228+ 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 []CleanupRangeInfo67 Ranges []CleanupRangeInfo
68 Selected int68 Selected int
69 Confirmed bool69 Confirmed bool
70- Cancelled bool70+ Canceled bool
71 theme *Theme71 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 nil106 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 = true125 m.Confirmed = true
123 case key.Matches(msg, keys.Cancel):126 case key.Matches(msg, keys.Cancel):
124- m.Cancelled = true127+ 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.Cancelled204+ 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 string11 Message string
12 Yes bool // Current selection12 Yes bool // Current selection
13 Confirmed bool // User pressed enter13 Confirmed bool // User pressed enter
14- Cancelled bool // User pressed escape14+ Canceled bool // User pressed escape
15 theme *Theme15 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 nil54 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 = true73 m.Confirmed = true
71 case key.Matches(msg, keys.Cancel):74 case key.Matches(msg, keys.Cancel):
72- m.Cancelled = true75+ 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.Cancelled116+ 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 *Theme16 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 := IconCheck144 gstIcon := IconCheck
135 gstStyle := r.theme.SuccessStyle145 gstStyle := r.theme.SuccessStyle
136- gstText := "Yes"146+ gstText := statusYes
137 if !m.GStreamerAvailable {147 if !m.GStreamerAvailable {
138 gstIcon = IconX148 gstIcon = IconX
139 gstStyle = r.theme.ErrorStyle149 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 := IconWarning154 hwIcon := IconWarning
145 hwStyle := r.theme.WarningStyle155 hwStyle := r.theme.WarningStyle
146- hwText := "No"156+ hwText := statusNo
147 if m.HWAccelAvailable {157 if m.HWAccelAvailable {
148 hwIcon = IconCheck158 hwIcon = IconCheck
149 hwStyle = r.theme.SuccessStyle159 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 := IconWarning164 av1Icon := IconWarning
155 av1Style := r.theme.WarningStyle165 av1Style := r.theme.WarningStyle
156- av1Text := "No"166+ av1Text := statusNo
157 if m.AV1HWAvailable {167 if m.AV1HWAvailable {
158 av1Icon = IconCheck168 av1Icon = IconCheck
159 av1Style = r.theme.SuccessStyle169 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 261 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 069 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 nil77 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.Theme87 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 line95 // 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 line101 // URL/description line
88 url := hi.URL102 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 badges107 // 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 1199 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 0207 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 nil215 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 += tab64 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 []string8 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 []string47 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 errs55 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 exists84+ 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.ConfigFileNotFoundError107 var configFileNotFoundError viper.ConfigFileNotFoundError
89 if errors.As(err, &configFileNotFoundError) {108 if errors.As(err, &configFileNotFoundError) {
90- // Config file not found, create default one109+ 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 file117+ 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 struct135+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 specified148+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 = dbPath154+ 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 statement160+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 = RenderingModeAuto163 config.RenderingMode = RenderingModeAuto
@@ -134,19 +169,14 @@ func (m *Manager) Load() error {
134 config.RenderingMode = RenderingModeAuto169 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 = ThemeDefault175 config.Appearance.ColorScheme = ThemeDefault
144 default:176 default:
145- // Invalid value - reset to default
146 config.Appearance.ColorScheme = ThemeDefault177 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 = HardwareDecodingAuto182 config.Media.HardwareDecodingMode = HardwareDecodingAuto
@@ -158,16 +188,7 @@ func (m *Manager) Load() error {
158 config.Media.HardwareDecodingMode = HardwareDecodingAuto188 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 needed280 // Note: Database.Path is set dynamically in Load(), no defaults needed
260 281
261- // History defaults282+ 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 defaults302+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 defaults307+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 defaults318+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 defaults327+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 defaults331+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 defaults341+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 defaults347+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 filtering375+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 defaults380+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 defaults384+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 defaults390+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 filtering22 // 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 decoding65 // 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 keyToAction217 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 context272 // 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 opened275 // 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 popups277 // 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 intents280 // 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 redirects283 // 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 set297 // 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 PaneModeBorderColor303 // 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 animations306 // 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 []string12 var validationErrors []string
13 13
14- // Validate numeric ranges14+ 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 engine97+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 values140+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 timeout157+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] = action180 seenKeys[key] = action
133 }181 }
134 }182 }
183+ return validationErrors
184+}
135 185
136- // Validate tab bar position using switch statement186+func validateTabBar(config *Config) []string {
137 switch config.Workspace.TabBarPosition {187 switch config.Workspace.TabBarPosition {
138 case "top", "bottom":188 case "top", "bottom":
139- // Valid189+ 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 timeout195+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] = action218 tabSeenKeys[key] = action
165 }219 }
166 }220 }
221+ return validationErrors
222+}
167 223
168- // Validate logging values224+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 statement248+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- // Valid251+ 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 statement260+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 statement269+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 config22 // 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 statement84 // 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 needed87 // 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 symlinks105 // Resolve symlinks
106- resolved, err := filepath.EvalSymlinks(execPath)106+ resolved, symlinkErr := filepath.EvalSymlinks(execPath)
107- if err == nil {107+ if symlinkErr == nil {
108 execPath = resolved108 execPath = resolved
109 }109 }
110 return execPath, nil110 return execPath, nil
@@ -130,7 +130,7 @@ func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus
130 }130 }
131 status.DesktopFilePath = desktopPath131 status.DesktopFilePath = desktopPath
132 132
133- if _, err := os.Stat(desktopPath); err == nil {133+ if _, statErr := os.Stat(desktopPath); statErr == nil {
134 status.DesktopFileInstalled = true134 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 = iconPath142 status.IconFilePath = iconPath
143 143
144- if _, err := os.Stat(iconPath); err == nil {144+ if _, iconStatErr := os.Stat(iconPath); iconStatErr == nil {
145 status.IconInstalled = true145 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 nil192 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 nil197 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, err57 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 string23 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 exists116 // 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 rename204 // 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 exist55 // 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 defaults63 // Use provided implementations or create defaults
58 store := cfg.Store64 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.Downloader73 downloader := cfg.Downloader
@@ -243,7 +250,7 @@ func (m *Manager) mergeJSONFiles(ctx context.Context, paths []string) (string, e
243 250
244 // Write merged file251 // 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 changes74 // Track status changes
75 var statuses []filtering.FilterStatus75 var statuses []filtering.FilterStatus
76 var mu sync.Mutex76 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 changes165 // Track status changes
166 var statuses []filtering.FilterStatus166 var statuses []filtering.FilterStatus
167 var mu sync.Mutex167 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 invocations470 // Track callback invocations
471 callCount := 0471 callCount := 0
472 var mu sync.Mutex472 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, nil91 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, nil143 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, nil193 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 nil238 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 first170 // 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 installed228 // 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 things234 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 available245 // 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 VAAPI257 // 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 exists25 // 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, nil51 return nil, nil
51 }52 }
52 return nil, err53 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, nil62 return nil, nil
62 }63 }
63 return nil, err64 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, nil96 return nil, nil
96 }97 }
97 return nil, err98 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 favorites162 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, nil201 return nil, nil
201 }202 }
202 return nil, err203 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, nil295 return nil, nil
295 }296 }
296 return nil, err297 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, nil306 return nil, nil
306 }307 }
307 return nil, err308 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, nil39 return nil, nil
39 }40 }
40 return nil, err41 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 bm2568 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, nil87 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_visits121 // Handle the interface{} type for total_visits
120 var totalVisits int64122 var totalVisits int64
121- if v, ok := row.TotalVisits.(int64); ok {123+ switch v := row.TotalVisits.(type) {
124+ case int64:
122 totalVisits = v125 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 migrations38 // 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 migration43 // 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, nil30 return nil, nil
30 }31 }
31 return nil, err32 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 WebView76 // 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.WebUIConfig30 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 setShortcutRequest55 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 getByShortcutRequest84 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 setFolderRequest115 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 createFolderRequest55 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 deleteFolderRequest90 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 updateFolderRequest122 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 timelineRequest36 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 searchRequest68 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 deleteEntryRequest107 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 deleteRangeRequest136 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 domainStatsRequest232 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 deleteDomainRequest262 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 createTagRequest55 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 deleteTagRequest90 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 updateTagRequest122 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 assign170+ 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), nil172 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), nil182 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 nil267 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 = 866 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, destroy211 // Pool full, destroy
205 wv.Destroy()212 wv.Destroy()
206- break
207 }213 }
208 214
209 // Small delay between creations to avoid overwhelming the system215 // 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.RWMutex66 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 nil124 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 nil133 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.SansFont155+
178- resp.Appearance.SerifFont = defaults.Appearance.SerifFont156+ data, err := json.Marshal(resp)
179- resp.Appearance.MonospaceFont = defaults.Appearance.MonospaceFont157+ 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 string273 var relPath string
303 switch {274 switch {
304- // dumb://home or dumb://home/ → index.html275+ // Home root maps to index.html.
305 case host == HomePath && (path == "" || path == "/"):276 case host == HomePath && (path == "" || path == "/"):
306 relPath = IndexHTML277 relPath = IndexHTML
307- // dumb://home/<asset> → serve asset278+ // Home asset paths map directly to assets.
308 case host == HomePath && path != "":279 case host == HomePath && path != "":
309 relPath = path280 relPath = path
310- // dumb://config or dumb://config/ → config.html281+ // 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 asset284+ // Config asset paths map directly to assets.
314 case host == ConfigPath && path != "":285 case host == ConfigPath && path != "":
315 relPath = path286 relPath = path
316- // dumb://error or dumb://error/ → error.html287+ // 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 asset290+ // Error asset paths map directly to assets.
320 case host == ErrorPath && path != "":291 case host == ErrorPath && path != "":
321 relPath = path292 relPath = path
322- // dumb:home (opaque form) → index.html293+ // Opaque home form maps to index.html.
323 case u.Opaque == HomePath:294 case u.Opaque == HomePath:
324 relPath = IndexHTML295 relPath = IndexHTML
325- // dumb:error (opaque form) → error.html296+ // 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 first333 // Try standard mime type first
@@ -451,7 +425,7 @@ func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
451 return425 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 settings52+ 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 config75+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 mode91+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 settings102+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 experience107+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 settings113+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: // HardwareDecodingAuto132+ 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 storage139+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 navigation144+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 acceleration149+func applyCanvasSettings(settings *webkit.Settings) {
129 settings.SetEnable2dCanvasAcceleration(true)150 settings.SetEnable2dCanvasAcceleration(true)
151+}
130 152
131- // WebRTC153+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 signal308+ 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 signal353+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 handling363+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 popup366 return gtk.Widget{} // Block popup
354 }367 }
@@ -378,20 +391,21 @@ func (wv *WebView) connectSignals() {
378 391
379 return newWV.inner.Widget392 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 display398+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 changes408+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 changes423+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 changes439+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 updates450+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 links465+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 handle468 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 false473 return false
@@ -465,24 +478,19 @@ func (wv *WebView) connectSignals() {
465 return false478 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 false482 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 == 2487 isMiddleClick := mouseButton == 2
479 isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0488 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 handle491+ 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 false496 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 true513 return true
508 }514 }
509 }515 }
510 516
511- return false // Let WebKit handle if not handled517+ 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 = &baseURI572 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 nil576 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 nil586 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 nil596 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 nil606 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 nil619 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 nil632 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 nil693 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 configuration18 // Config holds logging configuration
13 type Config struct {19 type Config struct {
14 Level zerolog.Level20 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.Stderr44 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 format53 // JSON is the default zerolog format
48 output = os.Stderr54 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.Writer73 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, err98 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, err104 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 configured111 // 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 = format143 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 coordinator364 // 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 := newCfg764 cfgCopy := newCfg
754- var cb glib.SourceFunc765+ cb := glib.SourceFunc(func(_ uintptr) bool {
755- cb = func(_ uintptr) bool {
756 a.applyAppearanceConfig(ctx, cfgCopy)766 a.applyAppearanceConfig(ctx, cfgCopy)
757 return false767 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 closure839 statusCopy := status // Capture for closure
830- var cb glib.SourceFunc840+ 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 repeat842 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 true358+ 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 false360+ 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 pixels24 omniboxMaxWidth = 800 // Maximum width in pixels
25 omniboxHeightPct = 0.6 // 60% of parent window height25 omniboxHeightPct = 0.6 // 60% of parent window height
26 omniboxMaxResults = 1026 omniboxMaxResults = 10
27+ omniboxTopMarginPct = 0.2 // 20% from top
27 debounceDelayMs = 15028 debounceDelayMs = 150
28 )29 )
29 30
@@ -236,7 +237,8 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
236 rowHeight = o.measuredHeights.singleRow237 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 window244 // 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 horizontally325 o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally
285 o.outerBox.SetValign(gtk.AlignStartValue) // Align to top326 o.outerBox.SetValign(gtk.AlignStartValue) // Align to top
286 o.outerBox.SetVisible(false) // Hidden by default327 o.outerBox.SetVisible(false) // Hidden by default
328+ return nil
329+}
287 330
288- // Main content box331+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 toggle340+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 clicks363+ 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 list396+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 selection413+ 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 = -1416 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-8511+ const firstShortcutKey = uint(gdk.KEY_1)
512+ index := int(keyval - firstShortcutKey) // 0-8
488 o.selectAndNavigate(index)513 o.selectAndNavigate(index)
489 return true514 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 item519+ const tenthItemIndex = 9
520+ o.selectAndNavigate(tenthItemIndex)
495 return true521 return true
496 }522 }
497 }523 }
@@ -597,11 +623,8 @@ func (o *Omnibox) loadInitialHistory() {
597 o.idleAddUpdateSuggestions(nil)623 o.idleAddUpdateSuggestions(nil)
598 return624 return
599 625
600- case "most_visited":626+ case "most_visited", "recent", "":
601 // TODO: Implement GetMostVisited in use case if needed627 // 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 nil788 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 cache794 // 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 nil814 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 nil821 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 := title840 displayTitle := title
817 if displayTitle == "" {841 if displayTitle == "" {
818- displayTitle = url842+ 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_END860 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 string975+ 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 selection978 // 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/search982 // 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].URL986+ targetURL = suggestions[idx].URL
962 }987 }
963 } else {988 } else {
964 if idx < len(favorites) {989 if idx < len(favorites) {
965- url = favorites[idx].URL990+ targetURL = favorites[idx].URL
966 }991 }
967 }992 }
968 993
969- if url == "" {994+ if targetURL == "" {
970 return995 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.ctx1043 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 return1056 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 favorites1062 // Remove from favorites
@@ -1082,39 +1107,38 @@ func (o *Omnibox) yankSelectedURL() {
1082 favorites := o.favorites1107 favorites := o.favorites
1083 o.mu.RUnlock()1108 o.mu.RUnlock()
1084 1109
1085- var url string1110+ 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 return1114 return
1090 }1115 }
1091- url = suggestions[idx].URL1116+ 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 return1120 return
1096 }1121 }
1097- url = favorites[idx].URL1122+ 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 return1127 return
1103 }1128 }
1104 1129
1105 go func() {1130 go func() {
1106 ctx := o.ctx1131 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 error1133 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.SourceFunc1138+ 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 repeat1140 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 = omniboxMaxWidth1226 width = omniboxMaxWidth
1203 }1227 }
1204- marginTop := int(float64(parentHeight) * 0.20) // 20% from top1228+ 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.SourceFunc97+ 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 false118 return false
120 }119 }
121 return true120 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 overflow48 // 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 child54 // 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.SourceFunc179+ 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 repeat188 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 pane169 // 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 nil176 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, mockOverlay33+ 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, mockTitleBar121+ return mockStackBox
122 }122 }
123 123
124 func TestNewWorkspaceView_CreatesEmptyContainer(t *testing.T) {124 func TestNewWorkspaceView_CreatesEmptyContainer(t *testing.T) {
125 // Arrange125 // 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 // Act129 // 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 // Arrange139 // 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 // Arrange153 // 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 // Arrange186 // 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 split198 // 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 creation205 // 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 // Arrange252 // 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 indicator149 // 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 return671 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 return684 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 return697 return
@@ -761,7 +773,7 @@ func (c *ContentCoordinator) handlePopupCreate(
761 }773 }
762 774
763 // Create related WebView for session sharing775 // 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 handling786 // 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 workspace876 // 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 failure890 // 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 ID924 // Find pane by WebView ID
913 var paneID entity.PaneID925 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 = pid928 paneID = pid
917 break929 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 // Progress975 // 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 navigation980 // SPA navigation
@@ -1094,7 +1106,7 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
1094 1106
1095 // Request insertion into workspace1107 // 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 failure1121 // 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() string45 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 nil144+ 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 nil150+ 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 nil156+ 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.IsStacked160 activePane.Parent != nil && activePane.Parent.IsStacked
104 161
105- // Get the existing widget BEFORE domain changes (for incremental operation)
106 var existingWidget layout.Widget162 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 err174+}
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 changing185+ if !isStackSplit {
134- oldActivePaneID := activePane.Pane.ID186+ return wsView.GetRootWidget()
187+ }
135 188
136- // Set the new pane as active189+ tr := wsView.TreeRenderer()
137- ws.ActivePaneID = output.NewPaneNode.Pane.ID190+ if tr == nil {
191+ return nil
192+ }
138 193
139- // Update the workspace view194+ 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 error216 var splitErr error
142 if isStackSplit {217 if isStackSplit {
143- // Incremental stack split: reuse existing stack widget218+ 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 tracking230+ } 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 nil239+ }
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.ID382 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 direction396+ orientation, existingFirst, ok := splitOrientation(direction)
333- var orientation layout.Orientation397+ 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 nil398 return nil
350 }399 }
351 400
352 // 7. Handle root vs non-root split differently401 // 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 root403+ if err := c.replaceRootSplit(
355- // Clear rootWidget ref first so SetRootWidgetDirect won't double-remove404+ ctx,
356- wsView.ClearRootWidgetRef()405+ wsView,
357- wsView.Container().Remove(existingRootWidget)406+ tr,
358-407+ factory,
359- var splitView *layout.SplitView408+ 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 TreeRenderer417+ 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 PanedWidget424+ 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 it432 // 8. Register the new pane in tracking maps and activate it
@@ -462,6 +460,117 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
462 return nil460 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.ID641 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 err657 return err
549 }658 }
550 659
551- // Try incremental close if we have the context660+ c.finalizePaneClose(
552- if wsView != nil && parentNode != nil {661+ ctx,
553- var closeErr error662+ wsView,
554-663+ ws,
555- if parentNode.IsStacked {664+ closingPaneID,
556- // Stacked close: remove pane from stack without rebuild665+ 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 rebuild668+ 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 nil674 return nil
@@ -664,42 +744,18 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.
664 return err744 return err
665 }745 }
666 746
667- // Try incremental close747+ c.finalizePaneClose(
668- if wsView != nil && parentNode != nil {748+ ctx,
669- var closeErr error749+ 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 nil761 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 nil890 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 pane973 // 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 nil1105 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 = nil1113+ stackNode, needsFirstPaneTitleUpdate := c.ensureStackNode(ctx, stackCtx.activeNode, stackCtx.activePaneID, stackCtx.originalTitle)
1051- activeNode.IsStacked = true1114+ 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 pane1122 // 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 StackedView1127 // 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 err1130 return err
1101 }1131 }
1102 1132
1103 // Update the first pane's title if we just converted from leaf to stacked1133 // 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 attach1138 // 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 ID1153 // Update workspace active pane ID
1134- ws.ActivePaneID = newPaneID1154+ stackCtx.ws.ActivePaneID = newPaneID
1135 1155
1136 // Update workspace view1156 // 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 callback1161 // 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 := stackNode1166 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 nil1179 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 direction1478+ 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 stack1480 // 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.IsStacked1482 parentNode.Parent != nil && parentNode.Parent.IsStacked
1345 1483
1346 // Get existing widget before domain changes1484 // Get existing widget before domain changes
1347- var existingWidget layout.Widget1485+ 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 pane1487 // 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.ID1499 ws.ActivePaneID = input.PopupPane.ID
1375 1500
1376 // Update UI1501 // Update UI
1377- if wsView != nil && existingWidget != nil {1502+ if wsView != nil {
1378- var splitErr error1503+ 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 nil1512 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 it1591+ stackNode := c.resolveStackNode(input, parentNode, log)
1435- var stackNode *entity.PaneNode1592+ if stackNode == nil {
1436- var needsStackConversion bool1593+ 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 stack1597+
1440- stackNode = parentNode.Parent1598+ // Update workspace active pane
1441- } else if parentNode.IsStacked {1599+ ws.ActivePaneID = input.PopupPane.ID
1442- // Parent itself is a stack - add to it1600+
1443- stackNode = parentNode1601+ 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 container1606+ Str("popup_pane", string(input.PopupPane.ID)).
1452- originalPane := parentNode.Pane1607+ 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 = nil1613+func (c *WorkspaceCoordinator) resolveStackNode(
1469- parentNode.IsStacked = true1614+ 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 pane1657+ 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.ActiveStackIndex1667 currentActiveIndex := stackNode.ActiveStackIndex
1487 if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {1668 if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
1488 currentActiveIndex = len(stackNode.Children) - 11669 currentActiveIndex = len(stackNode.Children) - 1
@@ -1492,58 +1673,52 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
1492 }1673 }
1493 insertIndex := currentActiveIndex + 11674 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] = newChildNode1678 stackNode.Children[insertIndex] = newChildNode
1499 stackNode.ActiveStackIndex = insertIndex1679 stackNode.ActiveStackIndex = insertIndex
1680+}
1500 1681
1501- // Update workspace active pane1682+func (c *WorkspaceCoordinator) attachPopupPaneView(
1502- ws.ActivePaneID = input.PopupPane.ID1683+ ctx context.Context,
1503-1684+ input InsertPopupInput,
1504- // Create PaneView for the popup1685+ 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 StackedView1696+ 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 err1699+ }
1514- }
1515 1700
1516- // Attach WebView widget1701+ 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 tracking1708+ 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 callback1712+ 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 := stackNode1717+ 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 nil1722 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.NavigationCoordinator20 navCoord *coordinator.NavigationCoordinator
20 zoomUC *usecase.ManageZoomUseCase21 zoomUC *usecase.ManageZoomUseCase
21 copyURLUC *usecase.CopyURLUseCase22 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) error25 onFindOpen func(ctx context.Context) error
24 onFindNext func(ctx context.Context) error26 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 = fn76 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 actions158+ return handler(ctx)
82- case input.ActionNewTab:159+ }
83- _, err := d.tabCoord.Create(ctx, "about:blank")160+
84- return err161+ 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 nil212 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 thread307 // Show toast on GTK main thread
300- var cb glib.SourceFunc308+ 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 false310 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 handler66 // 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 debounce66 // 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 focus72 // 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 callback26 // Action handler callback
27 onAction ActionHandler27 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.EventControllerKey32 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 handler101 // 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 event130 // Build KeyBinding from event
114 modifiers := Modifier(state) & modifierMask131 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 again226 // 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 | ModAlt28 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] = ActionEnterTabMode217 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] = ActionEnterPaneMode227 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 tab241 // 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+Tab256 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}] = ActionOpenOmnibox261 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
180 s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind262 s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
181 s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext263 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}] = ActionQuit278 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
197 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen279 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
198 s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL280 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}] = ActionFocusLeft284 s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft
202 s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight285 s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight
203 s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp286 s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp
204 s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown287 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}] = ActionFocusUp289 s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp
208 s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown290 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}] = ActionSwitchTabIndex1294 s.Global[KeyBinding{uint(gdk.KEY_1), ModAlt}] = ActionSwitchTabIndex1
212 s.Global[KeyBinding{uint(gdk.KEY_2), ModAlt}] = ActionSwitchTabIndex2295 s.Global[KeyBinding{uint(gdk.KEY_2), ModAlt}] = ActionSwitchTabIndex2
213 s.Global[KeyBinding{uint(gdk.KEY_3), ModAlt}] = ActionSwitchTabIndex3296 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}] = ActionSwitchTabIndex9302 s.Global[KeyBinding{uint(gdk.KEY_9), ModAlt}] = ActionSwitchTabIndex9
220 s.Global[KeyBinding{uint(gdk.KEY_0), ModAlt}] = ActionSwitchTabIndex10303 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}] = ActionSwitchLastTab305 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt}] = ActionSwitchLastTab
224 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab306 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 int311 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] = action315+ 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 keys437+ 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 return250 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 return261 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 // Assert55 // 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 // Assert133 // 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 // Assert166 // 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 // Assert370 // 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 label24 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 label87 // 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 label188 // 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 // Arrange86 // 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 pane94 // 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 pane115 // 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 inactive124 // 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 both130 // 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 // Arrange196 // 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 // Assert214 // 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 // Assert228 // 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 // Arrange233 // 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 // Assert251 // 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 // Arrange291 // 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 // Assert309 // 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 // Assert322 // 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 // Assert356 // 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 // Arrange383 // 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 // Assert414 // 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 // Assert516 // 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 // Assert529 // 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 normally610 // 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 pane688 // 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 pane784 // 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 := accent14 active := accent
14 15
15 return fmt.Sprintf(`/* Find highlight styling */16 return fmt.Sprintf(`/* Find highlight styling */

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close