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:
1414 - goconst
1515 - gocritic
1616 - gocyclo
17- - godox
17+ # - godox # TODOs/FIXMEs are intentional markers
1818 - mnd
1919 - goprintffuncname
2020 - gosec
@@ -34,7 +34,7 @@ linters:
3434
3535 settings:
3636 dupl:
37- threshold: 100
37+ threshold: 150
3838 funlen:
3939 lines: -1
4040 statements: 50
@@ -53,13 +53,11 @@ linters:
5353 - ifElseChain
5454 - octalLiteral
5555 - whyNoLint
56+ - unnamedResult
57+ - hugeParam
58+ - singleCaseSwitch
5659 gocyclo:
57- min-complexity: 15
58- godox:
59- keywords:
60- - FIXME
61- - TODO
62- - BUG
60+ min-complexity: 20
6361 mnd:
6462 checks:
6563 - argument
@@ -71,8 +69,22 @@ linters:
7169 - '1'
7270 - '2'
7371 - '3'
72+ - '4'
73+ - '5'
74+ - '10'
75+ - '16'
76+ - '32'
77+ - '64'
78+ - '100'
79+ - '1000'
7480 ignored-functions:
7581 - strings.SplitN
82+ - make
83+ - time.Sleep
84+ - time.Duration
85+ - strconv.ParseInt
86+ - strconv.ParseUint
87+ - strconv.ParseFloat
7688 govet:
7789 enable:
7890 - nilness
@@ -80,7 +92,7 @@ linters:
8092 errorlint:
8193 asserts: false
8294 lll:
83- line-length: 120
95+ line-length: 140
8496 misspell:
8597 locale: US
8698 nolintlint:
@@ -98,27 +110,54 @@ linters:
98110 paths:
99111 - vendor
100112 rules:
101- - text: "Error return value of `resp.Body.Close` is not checked"
102- linters:
103- - errcheck
104- - text: "Error return value of `os.Remove` is not checked"
105- linters:
106- - errcheck
113+ # Test files get relaxed rules
107114 - path: _test\.go
108115 linters:
109116 - dupl
110117 - mnd
111118 - lll
112119 - funlen
120+ - goconst
121+ - errcheck
122+ - gosec
123+ # Generated SQL code
113124 - path: internal/db/.*\.sql\.go
114125 linters:
115126 - all
116127 - path: internal/db/models\.go
117128 linters:
118129 - unused
119- - path: internal/db/
130+ # gosec false positives
131+ - text: "G115:"
120132 linters:
121- - lll
133+ - gosec
134+ - text: "G404:"
135+ linters:
136+ - gosec
137+ - text: "G301:"
138+ linters:
139+ - gosec
140+ - text: "G302:"
141+ linters:
142+ - gosec
143+ - text: "G306:"
144+ linters:
145+ - gosec
146+ - text: "G204:"
147+ linters:
148+ - gosec
149+ - text: "G304:"
150+ linters:
151+ - gosec
152+ # GTK callbacks require unused parameters
153+ - path: internal/ui/
154+ text: "unused-parameter"
155+ linters:
156+ - revive
157+ - path: internal/ui/
158+ text: "unused-receiver"
159+ linters:
160+ - revive
122161
123162 run:
124163 timeout: 20m
M assets/webui_embed.go +0 -1
@@ -2,7 +2,6 @@ package assets
22
33 import (
44 "embed"
5- _ "embed"
65 )
76
87 // 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
22
33 import (
44 "context"
5+ "database/sql"
56 "fmt"
67 "os"
78 "path/filepath"
89 "runtime"
910
10- "github.com/bnema/dumber/assets"
11+ "github.com/bnema/dumber/internal/application/port"
1112 "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/bootstrap"
1214 "github.com/bnema/dumber/internal/cli/cmd"
1315 "github.com/bnema/dumber/internal/domain/build"
16+ "github.com/bnema/dumber/internal/domain/repository"
1417 "github.com/bnema/dumber/internal/infrastructure/clipboard"
1518 "github.com/bnema/dumber/internal/infrastructure/config"
1619 "github.com/bnema/dumber/internal/infrastructure/deps"
@@ -22,6 +25,7 @@ import (
2225 "github.com/bnema/dumber/internal/logging"
2326 "github.com/bnema/dumber/internal/ui"
2427 "github.com/bnema/dumber/internal/ui/theme"
28+ "github.com/rs/zerolog"
2529 )
2630
2731 // Build-time variables (set via ldflags)
@@ -43,7 +47,7 @@ func main() {
4347 }
4448 // Strip "browse" and URL from args so GTK doesn't see them
4549 os.Args = os.Args[:1]
46- runGUI()
50+ os.Exit(runGUI())
4751 return
4852 }
4953
@@ -59,17 +63,11 @@ func main() {
5963 cmd.Execute()
6064 }
6165
62-func runGUI() {
66+func runGUI() int {
6367 // GTK requires all GTK calls to be made from the main thread
6468 runtime.LockOSThread()
6569
66- // Initialize configuration
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()
70+ cfg := initConfig()
7371
7472 // Apply optional /opt-style runtime prefix overrides (if configured)
7573 deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
@@ -77,7 +75,101 @@ func runGUI() {
7775 // Generate session ID for this browser run
7876 sessionID := logging.GenerateSessionID()
7977
80- // Initialize logger with session file output
78+ logger, logCleanup := initLogger(cfg, sessionID)
79+ if logCleanup != nil {
80+ defer logCleanup()
81+ }
82+
83+ logger.Info().
84+ Str("version", version).
85+ Str("commit", commit).
86+ Str("build_date", buildDate).
87+ Msg("starting dumber")
88+
89+ // Create root context with logger
90+ ctx := logging.WithContext(context.Background(), logger)
91+
92+ checkRuntimeRequirements(ctx, cfg, logger)
93+
94+ checkMediaRequirements(ctx, cfg, logger)
95+
96+ dataDir, cacheDir := resolveWebKitDirs(logger)
97+
98+ db, dbCleanup := openDatabase(ctx, logger, dataDir)
99+ if dbCleanup != nil {
100+ defer dbCleanup()
101+ }
102+
103+ // Create repositories
104+ historyRepo := sqlite.NewHistoryRepository(db)
105+ favoriteRepo := sqlite.NewFavoriteRepository(db)
106+ folderRepo := sqlite.NewFolderRepository(db)
107+ tagRepo := sqlite.NewTagRepository(db)
108+ zoomRepo := sqlite.NewZoomRepository(db)
109+
110+ // Theme management
111+ themeManager := theme.NewManager(ctx, cfg)
112+
113+ stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger)
114+
115+ // Create use cases
116+ const idAlphabetSize = 26
117+ idCounter := uint64(0)
118+ idGenerator := func() string {
119+ idCounter++
120+ return fmt.Sprintf("%c%d", 'a'+rune(idCounter%idAlphabetSize), idCounter/idAlphabetSize)
121+ }
122+ tabsUC := usecase.NewManageTabsUseCase(idGenerator)
123+ panesUC := usecase.NewManagePanesUseCase(idGenerator)
124+ historyUC := usecase.NewSearchHistoryUseCase(historyRepo)
125+ favoritesUC := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
126+ zoomUC := usecase.NewManageZoomUseCase(zoomRepo, cfg.DefaultWebpageZoom)
127+ navigateUC := usecase.NewNavigateUseCase(historyRepo, zoomRepo, cfg.DefaultWebpageZoom)
128+ clipboardAdapter := clipboard.New()
129+ copyURLUC := usecase.NewCopyURLUseCase(clipboardAdapter)
130+
131+ // Create favicon service
132+ faviconCacheDir, _ := config.GetFaviconCacheDir()
133+ faviconService := favicon.NewService(faviconCacheDir)
134+
135+ uiDeps := buildUIDependencies(
136+ ctx,
137+ cfg,
138+ themeManager,
139+ stack.Context,
140+ stack.Pool,
141+ stack.Settings,
142+ stack.Injector,
143+ stack.MessageRouter,
144+ stack.FilterManager,
145+ historyRepo,
146+ favoriteRepo,
147+ zoomRepo,
148+ tabsUC,
149+ panesUC,
150+ historyUC,
151+ favoritesUC,
152+ zoomUC,
153+ navigateUC,
154+ copyURLUC,
155+ clipboardAdapter,
156+ faviconService,
157+ )
158+
159+ // Run the application
160+ exitCode := ui.RunWithArgs(ctx, uiDeps)
161+ return exitCode
162+}
163+
164+func initConfig() *config.Config {
165+ if err := config.Init(); err != nil {
166+ fmt.Fprintf(os.Stderr, "Failed to initialize configuration: %v\n", err)
167+ os.Exit(1)
168+ }
169+ return config.Get()
170+}
171+
172+func initLogger(cfg *config.Config, sessionID string) (zerolog.Logger, func()) {
81173 logger, logCleanup, err := logging.NewWithFile(
82174 logging.Config{
83175 Level: logging.ParseLevel(cfg.Logging.Level),
@@ -92,23 +184,14 @@ func runGUI() {
92184 },
93185 )
94186 if err != nil {
95- // Fall back to stderr-only logger
96187 logger = logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
97188 fmt.Fprintf(os.Stderr, "Warning: failed to create session log file: %v\n", err)
98- } else {
99- defer logCleanup()
189+ return logger, nil
100190 }
191+ return logger, logCleanup
192+}
101193
102- logger.Info().
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
194+func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
112195 probe := deps.NewPkgConfigProbe()
113196 checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
114197 runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
@@ -117,37 +200,41 @@ func runGUI() {
117200 if err != nil {
118201 logger.Fatal().Err(err).Msg("runtime requirements check failed")
119202 }
120- if !runtimeOut.OK {
121- for _, c := range runtimeOut.Checks {
122- if c.Installed {
123- logger.Error().
124- Str("dependency", c.PkgConfigName).
125- Str("have", c.Version).
126- Str("need", c.RequiredVersion).
127- Bool("ok", c.MeetsRequirement).
128- Msg("runtime dependency")
129- } else {
130- logger.Error().
131- Str("dependency", c.PkgConfigName).
132- Str("need", c.RequiredVersion).
133- Msg("runtime dependency missing")
134- }
203+ if runtimeOut.OK {
204+ return
205+ }
206+ for _, c := range runtimeOut.Checks {
207+ if c.Installed {
208+ logger.Error().
209+ Str("dependency", c.PkgConfigName).
210+ Str("have", c.Version).
211+ Str("need", c.RequiredVersion).
212+ Bool("ok", c.MeetsRequirement).
213+ Msg("runtime dependency")
214+ } else {
215+ logger.Error().
216+ Str("dependency", c.PkgConfigName).
217+ Str("need", c.RequiredVersion).
218+ Msg("runtime dependency missing")
135219 }
136- logger.Fatal().
137- Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
138- Msg("runtime requirements not met")
139220 }
221+ logger.Fatal().
222+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
223+ Msg("runtime requirements not met")
224+}
140225
141- // Check media playback requirements (GStreamer/VA-API)
226+func checkMediaRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
142227 mediaDiagAdapter := media.New()
143228 checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
144- if _, err := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
229+ if _, mediaErr := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
145230 ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
146- }); err != nil {
147- logger.Fatal().Err(err).Msg("media requirements check failed")
231+ }); mediaErr != nil {
232+ logger.Fatal().Err(mediaErr).Msg("media requirements check failed")
148233 }
234+}
149235
150- // Resolve data/cache directories for WebKit
236+func resolveWebKitDirs(logger zerolog.Logger) (string, string) {
237+ const cacheDirPerm = 0o755
151238 dataDir, err := config.GetDataDir()
152239 if err != nil {
153240 logger.Fatal().Err(err).Msg("failed to resolve data directory")
@@ -157,97 +244,50 @@ func runGUI() {
157244 logger.Fatal().Err(err).Msg("failed to resolve state directory")
158245 }
159246 cacheDir := filepath.Join(stateDir, "webkit-cache")
160- if mkErr := os.MkdirAll(cacheDir, 0o755); mkErr != nil {
247+ if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {
161248 logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory")
162249 }
250+ return dataDir, cacheDir
251+}
163252
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()) {
165254 dbPath := filepath.Join(dataDir, "dumber.db")
166255 db, err := sqlite.NewConnection(ctx, dbPath)
167256 if err != nil {
168257 logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database")
169258 }
170- defer sqlite.Close(db)
171-
172- // Create repositories
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")
259+ cleanup := func() {
260+ if err := sqlite.Close(db); err != nil {
261+ logger.Error().Err(err).Msg("failed to close database")
203262 }
204263 }
264+ return db, cleanup
265+}
205266
206- // Register dumb:// scheme handler for serving embedded webui
207- schemeHandler := webkit.NewDumbSchemeHandler(ctx)
208- schemeHandler.SetAssets(assets.WebUIAssets)
209- schemeHandler.RegisterWithContext(wkCtx)
210-
211- settings := webkit.NewSettingsManager(ctx, cfg)
212- injector := webkit.NewContentInjector(themeManager.PrefersDark())
213-
214- // Prepare theme CSS for WebUI pages (dumb://*)
215- prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
216- themeCSSText := themeManager.GetWebUIThemeCSS()
217- if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {
218- logger.Warn().Err(err).Msg("failed to prepare WebUI theme CSS")
219- }
220-
221- messageRouter := webkit.NewMessageRouter(ctx)
222- poolCfg := webkit.DefaultPoolConfig()
223- pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
224-
225- // Set filter applier on the pool if filtering is enabled
226- if filterManager != nil {
227- pool.SetFilterApplier(filterManager)
228- }
229-
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{
267+func buildUIDependencies(
268+ ctx context.Context,
269+ cfg *config.Config,
270+ themeManager *theme.Manager,
271+ wkCtx *webkit.WebKitContext,
272+ pool *webkit.WebViewPool,
273+ settings *webkit.SettingsManager,
274+ injector *webkit.ContentInjector,
275+ messageRouter *webkit.MessageRouter,
276+ filterManager *filtering.Manager,
277+ historyRepo repository.HistoryRepository,
278+ favoriteRepo repository.FavoriteRepository,
279+ zoomRepo repository.ZoomRepository,
280+ tabsUC *usecase.ManageTabsUseCase,
281+ panesUC *usecase.ManagePanesUseCase,
282+ historyUC *usecase.SearchHistoryUseCase,
283+ favoritesUC *usecase.ManageFavoritesUseCase,
284+ zoomUC *usecase.ManageZoomUseCase,
285+ navigateUC *usecase.NavigateUseCase,
286+ copyURLUC *usecase.CopyURLUseCase,
287+ clipboardAdapter port.Clipboard,
288+ faviconService *favicon.Service,
289+) *ui.Dependencies {
290+ return &ui.Dependencies{
251291 Ctx: ctx,
252292 Config: cfg,
253293 InitialURL: initialURL,
@@ -257,25 +297,18 @@ func runGUI() {
257297 Settings: settings,
258298 Injector: injector,
259299 MessageRouter: messageRouter,
260- // Repositories
261- HistoryRepo: historyRepo,
262- FavoriteRepo: favoriteRepo,
263- ZoomRepo: zoomRepo,
264- // Use Cases
265- TabsUC: tabsUC,
266- PanesUC: panesUC,
267- HistoryUC: historyUC,
268- FavoritesUC: favoritesUC,
269- ZoomUC: zoomUC,
270- NavigateUC: navigateUC,
271- CopyURLUC: copyURLUC,
272- // Infrastructure Adapters
273- Clipboard: clipboardAdapter,
300+ HistoryRepo: historyRepo,
301+ FavoriteRepo: favoriteRepo,
302+ ZoomRepo: zoomRepo,
303+ TabsUC: tabsUC,
304+ PanesUC: panesUC,
305+ HistoryUC: historyUC,
306+ FavoritesUC: favoritesUC,
307+ ZoomUC: zoomUC,
308+ NavigateUC: navigateUC,
309+ CopyURLUC: copyURLUC,
310+ Clipboard: clipboardAdapter,
274311 FaviconService: faviconService,
275312 FilterManager: filterManager,
276313 }
277-
278- // Run the application
279- exitCode := ui.RunWithArgs(ctx, deps)
280- os.Exit(exitCode)
281314 }
M internal/application/usecase/check_media.go +4 -1
@@ -44,7 +44,10 @@ func (uc *CheckMediaUseCase) Execute(ctx context.Context, input CheckMediaInput)
4444
4545 // GStreamer is required - fail early if not installed
4646 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+ )
4851 }
4952
5053 // Log warnings if requested
M internal/application/usecase/check_runtime_dependencies.go +8 -5
@@ -67,7 +67,10 @@ type CheckRuntimeDependenciesOutput struct {
6767 }
6868
6969 // 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) {
7174 log := logging.FromContext(ctx).With().Str("component", "runtime-check").Logger()
7275
7376 minGTK := input.MinGTK4Version
@@ -156,12 +159,12 @@ func compareVersion(a, b string) (cmp int, ok bool) {
156159 return 0, false
157160 }
158161
159- max := len(av)
160- if len(bv) > max {
161- max = len(bv)
162+ maxLen := len(av)
163+ if len(bv) > maxLen {
164+ maxLen = len(bv)
162165 }
163166
164- for i := 0; i < max; i++ {
167+ for i := 0; i < maxLen; i++ {
165168 x := 0
166169 if i < len(av) {
167170 x = av[i]
M internal/application/usecase/find_in_page.go +12 -10
@@ -95,15 +95,17 @@ func (uc *FindInPageUseCase) Bind(controller port.FindController) {
9595 return
9696 }
9797
98- uc.signalIDs = append(uc.signalIDs, controller.OnFoundText(func(matchCount uint) {
99- uc.onFoundText(matchCount)
100- }))
101- uc.signalIDs = append(uc.signalIDs, controller.OnFailedToFindText(func() {
102- uc.onFailedToFind()
103- }))
104- uc.signalIDs = append(uc.signalIDs, controller.OnCountedMatches(func(matchCount uint) {
105- uc.onCountedMatches(matchCount)
106- }))
98+ uc.signalIDs = append(uc.signalIDs,
99+ controller.OnFoundText(func(matchCount uint) {
100+ uc.onFoundText(matchCount)
101+ }),
102+ controller.OnFailedToFindText(func() {
103+ uc.onFailedToFind()
104+ }),
105+ controller.OnCountedMatches(func(matchCount uint) {
106+ uc.onCountedMatches(matchCount)
107+ }),
108+ )
107109
108110 log.Debug().Int("signalCount", len(uc.signalIDs)).Msg("controller bound")
109111 }
@@ -387,7 +389,7 @@ func (uc *FindInPageUseCase) finishSearch() {
387389 }
388390
389391 func (uc *FindInPageUseCase) buildOptionsLocked(text string) port.FindOptions {
390- caseInsensitive := true
392+ var caseInsensitive bool
391393 if uc.caseMode == caseModeManual {
392394 caseInsensitive = !uc.caseSensitive
393395 } else {
M internal/application/usecase/manage_favorites.go +2 -2
@@ -281,10 +281,10 @@ func (uc *ManageFavoritesUseCase) DeleteFolder(ctx context.Context, id entity.Fo
281281
282282 // Move favorites to parent folder (or root)
283283 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 {
285285 log.Warn().
286286 Int64("favorite_id", int64(fav.ID)).
287- Err(err).
287+ Err(setErr).
288288 Msg("failed to move favorite from deleted folder")
289289 }
290290 }
M internal/application/usecase/manage_panes.go +35 -5
@@ -170,6 +170,9 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
170170 // Returns the sibling node that was promoted, or nil if this was the last pane.
171171 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
172172 log := logging.FromContext(ctx)
173+ if uc == nil {
174+ return nil, fmt.Errorf("manage panes use case is nil")
175+ }
173176 log.Debug().Str("pane_id", paneNode.ID).Msg("closing pane")
174177
175178 if ws == nil {
@@ -252,6 +255,9 @@ func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, p
252255 // Focus sets the active pane in the workspace.
253256 func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, paneID entity.PaneID) error {
254257 log := logging.FromContext(ctx)
258+ if uc == nil {
259+ return fmt.Errorf("manage panes use case is nil")
260+ }
255261 log.Debug().Str("pane_id", string(paneID)).Msg("focusing pane")
256262
257263 if ws == nil {
@@ -276,7 +282,11 @@ func (uc *ManagePanesUseCase) Focus(ctx context.Context, ws *entity.Workspace, p
276282
277283 // NavigateFocus moves focus to an adjacent pane.
278284 // 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) {
280290 log := logging.FromContext(ctx)
281291 log.Debug().Str("direction", string(direction)).Msg("navigating focus")
282292
@@ -332,6 +342,9 @@ func (uc *ManagePanesUseCase) NavigateFocusGeometric(
332342 input GeometricNavigationInput,
333343 ) (*GeometricNavigationOutput, error) {
334344 log := logging.FromContext(ctx)
345+ if uc == nil {
346+ return nil, fmt.Errorf("manage panes use case is nil")
347+ }
335348 log.Debug().
336349 Str("direction", string(input.Direction)).
337350 Str("active", string(input.ActivePaneID)).
@@ -454,7 +467,7 @@ func (uc *ManagePanesUseCase) findAdjacentPane(node *entity.PaneNode, direction
454467 }
455468
456469 // Check if we can move in the desired direction
457- targetIndex := childIndex
470+ var targetIndex int
458471 if isForward {
459472 targetIndex = childIndex + 1
460473 } else {
@@ -524,7 +537,11 @@ type CreateStackOutput struct {
524537 //
525538 // The targetNode keeps its ID but becomes a stack container.
526539 // 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) {
528545 log := logging.FromContext(ctx)
529546 log.Debug().Str("pane_id", paneNode.ID).Msg("creating stack from pane")
530547
@@ -594,7 +611,12 @@ type AddToStackOutput struct {
594611
595612 // AddToStack adds a new pane to an existing stack.
596613 // 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) {
598620 log := logging.FromContext(ctx)
599621
600622 if ws == nil {
@@ -660,7 +682,12 @@ func (uc *ManagePanesUseCase) AddToStack(ctx context.Context, ws *entity.Workspa
660682
661683 // NavigateStack cycles through stacked panes.
662684 // 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) {
664691 log := logging.FromContext(ctx)
665692
666693 if stackNode == nil {
@@ -724,6 +751,7 @@ func (uc *ManagePanesUseCase) NavigateStack(ctx context.Context, stackNode *enti
724751
725752 // RemoveFromStack removes a pane from a stack.
726753 // If only one pane remains, the stack is dissolved.
754+//nolint:revive // receiver required for interface consistency
727755 func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *entity.PaneNode, paneID entity.PaneID) error {
728756 log := logging.FromContext(ctx)
729757
@@ -774,6 +802,7 @@ func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *en
774802 }
775803
776804 // GetAllPanes returns all leaf panes in a workspace.
805+//nolint:revive // receiver required for interface consistency
777806 func (uc *ManagePanesUseCase) GetAllPanes(ws *entity.Workspace) []*entity.Pane {
778807 if ws == nil || ws.Root == nil {
779808 return nil
@@ -782,6 +811,7 @@ func (uc *ManagePanesUseCase) GetAllPanes(ws *entity.Workspace) []*entity.Pane {
782811 }
783812
784813 // CountPanes returns the number of panes in a workspace.
814+//nolint:revive // receiver required for interface consistency
785815 func (uc *ManagePanesUseCase) CountPanes(ws *entity.Workspace) int {
786816 if ws == nil {
787817 return 0
M internal/application/usecase/manage_tabs.go +13 -2
@@ -82,9 +82,11 @@ func (uc *ManageTabsUseCase) Create(ctx context.Context, input CreateTabInput) (
8282 // Close removes a tab from the list.
8383 // Returns true if this was the last tab (caller should handle app exit).
8484 func (uc *ManageTabsUseCase) Close(ctx context.Context, tabs *entity.TabList, tabID entity.TabID) (wasLast bool, err error) {
85- log := logging.FromContext(ctx)
8685 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+ }
8890
8991 log.Debug().Msg("closing tab")
9092
@@ -121,6 +123,9 @@ func (uc *ManageTabsUseCase) Close(ctx context.Context, tabs *entity.TabList, ta
121123 // Switch changes the active tab.
122124 func (uc *ManageTabsUseCase) Switch(ctx context.Context, tabs *entity.TabList, tabID entity.TabID) error {
123125 log := logging.FromContext(ctx)
126+ if uc == nil {
127+ return fmt.Errorf("manage tabs use case is nil")
128+ }
124129 log.Debug().Str("tab_id", string(tabID)).Msg("switching to tab")
125130
126131 if tabs == nil {
@@ -146,6 +151,9 @@ func (uc *ManageTabsUseCase) Switch(ctx context.Context, tabs *entity.TabList, t
146151 // Move repositions a tab within the tab bar.
147152 func (uc *ManageTabsUseCase) Move(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, newPosition int) error {
148153 log := logging.FromContext(ctx)
154+ if uc == nil {
155+ return fmt.Errorf("manage tabs use case is nil")
156+ }
149157 log.Debug().
150158 Str("tab_id", string(tabID)).
151159 Int("new_position", newPosition).
@@ -168,6 +176,7 @@ func (uc *ManageTabsUseCase) Move(ctx context.Context, tabs *entity.TabList, tab
168176 }
169177
170178 // Rename changes a tab's custom name.
179+//nolint:revive // receiver required for interface consistency
171180 func (uc *ManageTabsUseCase) Rename(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, name string) error {
172181 log := logging.FromContext(ctx)
173182 log.Debug().
@@ -196,6 +205,7 @@ func (uc *ManageTabsUseCase) Rename(ctx context.Context, tabs *entity.TabList, t
196205
197206 // GetNext returns the next tab ID in the given direction.
198207 // direction: 1 for next, -1 for previous.
208+//nolint:revive // receiver required for interface consistency
199209 func (uc *ManageTabsUseCase) GetNext(tabs *entity.TabList, direction int) entity.TabID {
200210 if tabs == nil || tabs.Count() == 0 {
201211 return ""
@@ -283,6 +293,7 @@ func (uc *ManageTabsUseCase) SwitchToLastActive(ctx context.Context, tabs *entit
283293 }
284294
285295 // Pin toggles the pinned state of a tab.
296+//nolint:revive // receiver required for interface consistency
286297 func (uc *ManageTabsUseCase) Pin(ctx context.Context, tabs *entity.TabList, tabID entity.TabID, pinned bool) error {
287298 log := logging.FromContext(ctx)
288299 log.Debug().
M internal/application/usecase/navigate.go +4 -4
@@ -150,7 +150,7 @@ func (uc *NavigateUseCase) UpdateHistoryTitle(ctx context.Context, url, title st
150150
151151 // Reload reloads the current page.
152152 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()
154154 log.Debug().Bool("bypass_cache", bypassCache).Msg("reloading page")
155155
156156 if bypassCache {
@@ -161,7 +161,7 @@ func (uc *NavigateUseCase) Reload(ctx context.Context, webview port.WebView, byp
161161
162162 // GoBack navigates back in history.
163163 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()
165165
166166 if !webview.CanGoBack() {
167167 log.Debug().Msg("cannot go back - no history")
@@ -174,7 +174,7 @@ func (uc *NavigateUseCase) GoBack(ctx context.Context, webview port.WebView) err
174174
175175 // GoForward navigates forward in history.
176176 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()
178178
179179 if !webview.CanGoForward() {
180180 log.Debug().Msg("cannot go forward - no history")
@@ -187,7 +187,7 @@ func (uc *NavigateUseCase) GoForward(ctx context.Context, webview port.WebView)
187187
188188 // Stop stops the current page load.
189189 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()
191191 log.Debug().Msg("stopping page load")
192192 return webview.Stop(ctx)
193193 }
M internal/application/usecase/save_webui_config.go +4 -1
@@ -87,7 +87,10 @@ func validateWebUIConfig(cfg port.WebUIConfig) error {
8787 case "default", "prefer-dark", "prefer-light":
8888 // ok
8989 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+ ))
9194 }
9295
9396 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
148148
149149 // Unset as default browser first (if applicable)
150150 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+ }
152154 }
153155
154156 // 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 {
3535
3636 // NewApp creates a new CLI application with all dependencies.
3737 func NewApp() (*App, error) {
38+ const dataDirPerm = 0o755
3839 // Load config
39- cfg, err := loadConfig()
40- if err != nil {
41- return nil, fmt.Errorf("load config: %w", err)
42- }
40+ cfg := loadConfig()
4341
4442 // Create theme from config
4543 theme := styles.NewTheme(cfg)
@@ -79,7 +77,7 @@ func NewApp() (*App, error) {
7977 dbPath := filepath.Dir(dbFile)
8078
8179 // Ensure data directory exists
82- if err := os.MkdirAll(dbPath, 0755); err != nil {
80+ if err = os.MkdirAll(dbPath, dataDirPerm); err != nil {
8381 return nil, fmt.Errorf("create data dir: %w", err)
8482 }
8583
@@ -125,17 +123,17 @@ func (a *App) Ctx() context.Context {
125123 }
126124
127125 // loadConfig loads configuration from standard locations.
128-func loadConfig() (*config.Config, error) {
126+func loadConfig() *config.Config {
129127 mgr, err := config.NewManager()
130128 if err != nil {
131129 // Return default config if manager fails
132- return config.DefaultConfig(), nil
130+ return config.DefaultConfig()
133131 }
134132
135133 if err := mgr.Load(); err != nil {
136134 // Return default config if loading fails
137- return config.DefaultConfig(), nil
135+ return config.DefaultConfig()
138136 }
139137
140- return mgr.Get(), nil
138+ return mgr.Get()
141139 }
M internal/cli/cmd/about.go +1 -1
@@ -19,7 +19,7 @@ func init() {
1919 rootCmd.AddCommand(aboutCmd)
2020 }
2121
22-func runAbout(cmd *cobra.Command, args []string) error {
22+func runAbout(_ *cobra.Command, _ []string) error {
2323 app := GetApp()
2424 if app == nil {
2525 return fmt.Errorf("app not initialized")
M internal/cli/cmd/dmenu.go +1 -1
@@ -44,7 +44,7 @@ func init() {
4444 dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)")
4545 }
4646
47-func runDmenu(cmd *cobra.Command, args []string) error {
47+func runDmenu(_ *cobra.Command, _ []string) error {
4848 app := GetApp()
4949 if app == nil {
5050 return fmt.Errorf("app not initialized")
M internal/cli/cmd/doctor.go +3 -9
@@ -39,7 +39,7 @@ func init() {
3939 doctorCmd.Flags().BoolVar(&doctorOnlyRuntime, "runtime", false, "Only run runtime checks (GTK4/WebKitGTK)")
4040 doctorCmd.Flags().BoolVar(&doctorOnlyMedia, "media", false, "Only run media checks (GStreamer/VA-API)")
4141 }
42-func runDoctor(cmd *cobra.Command, args []string) error {
42+func runDoctor(_ *cobra.Command, _ []string) error {
4343 app := GetApp()
4444 if app == nil {
4545 return fmt.Errorf("app not initialized")
@@ -49,14 +49,8 @@ func runDoctor(cmd *cobra.Command, args []string) error {
4949 return fmt.Errorf("--runtime and --media are mutually exclusive")
5050 }
5151
52- runRuntime := true
53- runMedia := true
54- if doctorOnlyRuntime {
55- runMedia = false
56- }
57- if doctorOnlyMedia {
58- runRuntime = false
59- }
52+ runRuntime := !doctorOnlyMedia
53+ runMedia := !doctorOnlyRuntime
6054
6155 report := styles.DoctorReport{OverallOK: true}
6256
M internal/cli/cmd/history.go +6 -4
@@ -16,6 +16,8 @@ var (
1616 historyMax int
1717 )
1818
19+const defaultHistoryMax = 50
20+
1921 var historyCmd = &cobra.Command{
2022 Use: "history",
2123 Short: "Browse and manage history",
@@ -27,10 +29,10 @@ func init() {
2729 rootCmd.AddCommand(historyCmd)
2830
2931 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)")
3133 }
3234
33-func runHistory(cmd *cobra.Command, args []string) error {
35+func runHistory(_ *cobra.Command, _ []string) error {
3436 app := GetApp()
3537 if app == nil {
3638 return fmt.Errorf("app not initialized")
@@ -98,7 +100,7 @@ func init() {
98100 historyCmd.AddCommand(statsCmd)
99101 }
100102
101-func runStats(cmd *cobra.Command, args []string) error {
103+func runStats(_ *cobra.Command, _ []string) error {
102104 app := GetApp()
103105 if app == nil {
104106 return fmt.Errorf("app not initialized")
@@ -124,7 +126,7 @@ func init() {
124126 historyCmd.AddCommand(clearCmd)
125127 }
126128
127-func runClear(cmd *cobra.Command, args []string) error {
129+func runClear(_ *cobra.Command, _ []string) error {
128130 app := GetApp()
129131 if app == nil {
130132 return fmt.Errorf("app not initialized")
M internal/cli/cmd/logs.go +15 -9
@@ -24,6 +24,8 @@ var (
2424 logsClearAll bool
2525 )
2626
27+const defaultLogsLines = 50
28+
2729 var logsCmd = &cobra.Command{
2830 Use: "logs [session]",
2931 Short: "View application logs",
@@ -44,7 +46,7 @@ func init() {
4446 rootCmd.AddCommand(logsCmd)
4547
4648 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")
4850 }
4951
5052 // SessionInfo holds metadata about a log session.
@@ -57,7 +59,7 @@ type SessionInfo struct {
5759 ModTime time.Time
5860 }
5961
60-func runLogs(cmd *cobra.Command, args []string) error {
62+func runLogs(_ *cobra.Command, args []string) error {
6163 app := GetApp()
6264 if app == nil {
6365 return fmt.Errorf("app not initialized")
@@ -188,11 +190,11 @@ func findSession(logDir, query string) (*SessionInfo, error) {
188190 return nil, fmt.Errorf("no sessions found")
189191 }
190192
191- query = strings.ToLower(query)
193+ queryLower := strings.ToLower(query)
192194
193195 // Try exact short ID match first
194196 for _, s := range sessions {
195- if strings.ToLower(s.ShortID) == query {
197+ if strings.EqualFold(s.ShortID, queryLower) {
196198 return &s, nil
197199 }
198200 }
@@ -200,7 +202,7 @@ func findSession(logDir, query string) (*SessionInfo, error) {
200202 // Try partial match on full session ID
201203 var matches []SessionInfo
202204 for _, s := range sessions {
203- if strings.Contains(strings.ToLower(s.SessionID), query) {
205+ if strings.Contains(strings.ToLower(s.SessionID), queryLower) {
204206 matches = append(matches, s)
205207 }
206208 }
@@ -221,12 +223,16 @@ func findSession(logDir, query string) (*SessionInfo, error) {
221223 }
222224
223225 // 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) {
225227 file, err := os.Open(logPath)
226228 if err != nil {
227229 return fmt.Errorf("open log file: %w", err)
228230 }
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+ }()
230236
231237 // Read all lines
232238 var allLines []string
@@ -258,7 +264,7 @@ func tailSession(logPath string, theme *styles.Theme) error {
258264 if err != nil {
259265 return fmt.Errorf("open log file: %w", err)
260266 }
261- defer file.Close()
267+ defer func() { _ = file.Close() }()
262268
263269 // Seek to end
264270 _, _ = file.Seek(0, io.SeekEnd)
@@ -366,7 +372,7 @@ func init() {
366372 logsClearCmd.Flags().BoolVar(&logsClearAll, "all", false, "remove all session logs")
367373 }
368374
369-func runLogsClear(cmd *cobra.Command, args []string) error {
375+func runLogsClear(_ *cobra.Command, _ []string) error {
370376 app := GetApp()
371377 if app == nil {
372378 return fmt.Errorf("app not initialized")
M internal/cli/cmd/purge.go +123 -96
@@ -21,6 +21,27 @@ var (
2121 purgeFilters bool
2222 )
2323
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+
2445 var purgeCmd = &cobra.Command{
2546 Use: "purge",
2647 Short: "Remove all dumber data and configuration",
@@ -46,7 +67,7 @@ func init() {
4667 purgeCmd.Flags().BoolVar(&purgeFilters, "filters", false, "only remove content filter cache")
4768 }
4869
49-func runPurge(cmd *cobra.Command, args []string) error {
70+func runPurge(_ *cobra.Command, _ []string) error {
5071 home, err := os.UserHomeDir()
5172 if err != nil {
5273 return fmt.Errorf("get home dir: %w", err)
@@ -57,70 +78,90 @@ func runPurge(cmd *cobra.Command, args []string) error {
5778 return runPurgeFilters(home)
5879 }
5980
60- // Build list of directories to remove
61- dirs := []struct {
62- path string
63- desc string
64- }{
65- {getXDGPath("XDG_CONFIG_HOME", home, ".config", "dumber"), "config"},
66- {getXDGPath("XDG_DATA_HOME", home, ".local/share", "dumber"), "data"},
67- {getXDGPath("XDG_STATE_HOME", home, ".local/state", "dumber"), "state"},
68- {getXDGPath("XDG_CACHE_HOME", home, ".cache", "dumber"), "cache"},
81+ dirs := purgeDirList(home)
82+ existing := collectExistingDirs(dirs)
83+
84+ var desktopStatus *purgeDesktopStatus
85+ // Check desktop integration status if --desktop flag is set
86+ if purgeDesktop {
87+ desktopStatus = getDesktopStatus()
6988 }
7089
71- // Check which directories exist
72- var existing []struct {
73- path string
74- desc string
75- size int64
90+ // Check if there's anything to remove
91+ if len(existing) == 0 && desktopStatus == nil {
92+ fmt.Println("No dumber directories found.")
93+ return nil
7694 }
7795
78- for _, d := range dirs {
79- if info, err := os.Stat(d.path); err == nil && info.IsDir() {
80- size := getDirSize(d.path)
81- existing = append(existing, struct {
82- path string
83- desc string
84- size int64
85- }{d.path, d.desc, size})
96+ describePurgeTargets(existing, desktopStatus)
97+
98+ // Confirm unless --force
99+ if !confirmPurge() {
100+ fmt.Println("Aborted.")
101+ return nil
102+ }
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)
86117 }
118+ return fmt.Errorf("failed to remove some items")
87119 }
88120
89- // Check desktop integration status if --desktop flag is set
90- var desktopStatus *struct {
91- desktopInstalled bool
92- iconInstalled bool
93- isDefault bool
94- desktopPath string
95- iconPath string
121+ fmt.Println()
122+ fmt.Println("Purge complete.")
123+ return nil
124+}
125+
126+func purgeDirList(home string) []purgeDir {
127+ return []purgeDir{
128+ {getXDGPath("XDG_CONFIG_HOME", home, ".config"), "config"},
129+ {getXDGPath("XDG_DATA_HOME", home, ".local/share"), "data"},
130+ {getXDGPath("XDG_STATE_HOME", home, ".local/state"), "state"},
131+ {getXDGPath("XDG_CACHE_HOME", home, ".cache"), "cache"},
96132 }
97- if purgeDesktop {
98- adapter := desktop.New()
99- status, err := adapter.GetStatus(context.Background())
100- if err == nil && (status.DesktopFileInstalled || status.IconInstalled) {
101- desktopStatus = &struct {
102- desktopInstalled bool
103- iconInstalled bool
104- isDefault bool
105- desktopPath string
106- iconPath string
107- }{
108- desktopInstalled: status.DesktopFileInstalled,
109- iconInstalled: status.IconInstalled,
110- isDefault: status.IsDefaultBrowser,
111- desktopPath: status.DesktopFilePath,
112- iconPath: status.IconFilePath,
113- }
133+}
134+
135+func collectExistingDirs(dirs []purgeDir) []purgeDirWithSize {
136+ var existing []purgeDirWithSize
137+ for _, d := range dirs {
138+ if info, err := os.Stat(d.path); err == nil && info.IsDir() {
139+ existing = append(existing, purgeDirWithSize{
140+ path: d.path,
141+ desc: d.desc,
142+ size: getDirSize(d.path),
143+ })
114144 }
115145 }
146+ return existing
147+}
116148
117- // Check if there's anything to remove
118- if len(existing) == 0 && desktopStatus == nil {
119- fmt.Println("No dumber directories found.")
149+func getDesktopStatus() *purgeDesktopStatus {
150+ adapter := desktop.New()
151+ status, err := adapter.GetStatus(context.Background())
152+ if err != nil || (!status.DesktopFileInstalled && !status.IconInstalled) {
120153 return nil
121154 }
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+}
122163
123- // Show what will be deleted
164+func describePurgeTargets(existing []purgeDirWithSize, desktopStatus *purgeDesktopStatus) {
124165 fmt.Println("The following will be removed:")
125166 fmt.Println()
126167 for _, d := range existing {
@@ -138,21 +179,20 @@ func runPurge(cmd *cobra.Command, args []string) error {
138179 }
139180 }
140181 fmt.Println()
182+}
141183
142- // Confirm unless --force
143- if !purgeForce {
144- fmt.Print("Are you sure? [y/N]: ")
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- }
184+func confirmPurge() bool {
185+ if purgeForce {
186+ return true
153187 }
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+}
154194
155- // Remove directories
195+func removeDirs(existing []purgeDirWithSize) []string {
156196 var errors []string
157197 for _, d := range existing {
158198 if err := os.RemoveAll(d.path); err != nil {
@@ -161,48 +201,35 @@ func runPurge(cmd *cobra.Command, args []string) error {
161201 fmt.Printf("\u2713 Removed %s\n", d.path)
162202 }
163203 }
204+ return errors
205+}
164206
165- // Remove desktop integration
166- if desktopStatus != nil {
167- adapter := desktop.New()
168- uc := usecase.NewRemoveDesktopUseCase(adapter)
169- result, err := uc.Execute(context.Background())
170- if err != nil {
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- }
207+func removeDesktopIntegration(errors []string) []string {
208+ adapter := desktop.New()
209+ uc := usecase.NewRemoveDesktopUseCase(adapter)
210+ result, err := uc.Execute(context.Background())
211+ if err != nil {
212+ return append(errors, fmt.Sprintf("desktop integration: %v", err))
183213 }
184-
185- if len(errors) > 0 {
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")
214+ if result.WasDesktopInstalled {
215+ fmt.Printf("\u2713 Removed %s\n", result.RemovedDesktopPath)
192216 }
193-
194- fmt.Println()
195- fmt.Println("Purge complete.")
196- return nil
217+ if result.WasIconInstalled {
218+ fmt.Printf("\u2713 Removed %s\n", result.RemovedIconPath)
219+ }
220+ if result.WasDefault {
221+ fmt.Println(" \u26a0 Was default browser - please set a new default")
222+ }
223+ return errors
197224 }
198225
199226 // getXDGPath returns the XDG path for dumber.
200-func getXDGPath(envVar, home, defaultSuffix, appName string) string {
227+func getXDGPath(envVar, home, defaultSuffix string) string {
201228 base := os.Getenv(envVar)
202229 if base == "" {
203230 base = filepath.Join(home, defaultSuffix)
204231 }
205- return filepath.Join(base, appName)
232+ return filepath.Join(base, dumberAppName)
206233 }
207234
208235 // getDirSize calculates the total size of a directory.
@@ -236,7 +263,7 @@ func formatSize(bytes int64) string {
236263
237264 // runPurgeFilters removes only the content filter cache.
238265 func runPurgeFilters(home string) error {
239- dataDir := getXDGPath("XDG_DATA_HOME", home, ".local/share", "dumber")
266+ dataDir := getXDGPath("XDG_DATA_HOME", home, ".local/share")
240267 filterDirs := []struct {
241268 path string
242269 desc string
M internal/cli/cmd/root.go +3 -3
@@ -18,7 +18,7 @@ var (
1818 Use: "dumber",
1919 Short: "A minimal web browser",
2020 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 {
2222 // Skip initialization for help commands
2323 if cmd.Name() == "help" || cmd.Name() == "completion" {
2424 return nil
@@ -33,7 +33,7 @@ var (
3333 app.BuildInfo = buildInfo
3434 return nil
3535 },
36- PersistentPostRun: func(cmd *cobra.Command, args []string) {
36+ PersistentPostRun: func(_ *cobra.Command, _ []string) {
3737 if app != nil {
3838 _ = app.Close()
3939 }
@@ -65,7 +65,7 @@ If a URL is provided, navigate to it. Otherwise, open the homepage.
6565 Examples:
6666 dumber browse # Open browser to homepage
6767 dumber browse example.com # Open browser to URL`,
68- Run: func(cmd *cobra.Command, args []string) {
68+ Run: func(_ *cobra.Command, _ []string) {
6969 // This is handled by main.go before cobra runs
7070 },
7171 }
M internal/cli/cmd/setup.go +2 -2
@@ -55,7 +55,7 @@ func init() {
5555 setupCmd.AddCommand(setupDefaultCmd)
5656 }
5757
58-func runSetupInstall(cmd *cobra.Command, args []string) error {
58+func runSetupInstall(_ *cobra.Command, _ []string) error {
5959 app := GetApp()
6060 if app == nil {
6161 return fmt.Errorf("app not initialized")
@@ -105,7 +105,7 @@ func runSetupInstall(cmd *cobra.Command, args []string) error {
105105 return nil
106106 }
107107
108-func runSetupDefault(cmd *cobra.Command, args []string) error {
108+func runSetupDefault(_ *cobra.Command, _ []string) error {
109109 app := GetApp()
110110 if app == nil {
111111 return fmt.Errorf("app not initialized")
M internal/cli/model/cleanup.go +4 -1
@@ -37,6 +37,9 @@ type cleanupCompleteMsg struct {
3737
3838 // Init implements tea.Model.
3939 func (m CleanupModel) Init() tea.Cmd {
40+ if m.theme == nil {
41+ return nil
42+ }
4043 return nil
4144 }
4245
@@ -69,7 +72,7 @@ func (m CleanupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6972 m.cleanup = cleanup
7073
7174 if m.cleanup.Done() {
72- if m.cleanup.Cancelled {
75+ if m.cleanup.Canceled {
7376 return m, tea.Quit
7477 }
7578 if m.cleanup.Confirmed {
M internal/cli/model/dmenu.go +3 -1
@@ -64,6 +64,8 @@ type dmenuLoadedMsg struct {
6464 err error
6565 }
6666
67+const defaultDmenuHistoryLimit = 500
68+
6769 // Init implements tea.Model.
6870 func (m DmenuModel) Init() tea.Cmd {
6971 return tea.Batch(
@@ -76,7 +78,7 @@ func (m DmenuModel) Init() tea.Cmd {
7678 func (m DmenuModel) loadEntries() tea.Msg {
7779 ctx := context.Background()
7880
79- entries, err := m.historyUC.GetRecent(ctx, 500, 0)
81+ entries, err := m.historyUC.GetRecent(ctx, defaultDmenuHistoryLimit, 0)
8082 if err != nil {
8183 return dmenuLoadedMsg{err: err}
8284 }
M internal/cli/model/history.go +195 -151
@@ -166,178 +166,216 @@ func (m HistoryModel) loadDomainStats() tea.Msg {
166166
167167 // Update implements tea.Model.
168168 func (m HistoryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
169- var cmds []tea.Cmd
170-
171- // Handle cleanup modal
172169 if m.cleanup != nil {
173- cleanup, cmd := m.cleanup.Update(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
170+ return m.handleCleanupModal(msg)
183171 }
184-
185- // Handle confirm dialog
186172 if m.confirm != nil {
187- confirm, cmd := m.confirm.Update(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
173+ return m.handleConfirmModal(msg)
197174 }
198175
199176 switch msg := msg.(type) {
200177 case tea.WindowSizeMsg:
201- m.width = msg.Width
202- m.height = msg.Height
203- m.help.Width = msg.Width
204- m.updateList()
205-
178+ return m.handleWindowSize(msg)
206179 case tea.KeyMsg:
207- // Search mode handling
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- }
180+ return m.handleKeyMsg(msg)
225181
226- // Normal mode handling
227- switch {
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- }
182+ case historyLoadedMsg:
183+ return m.handleHistoryLoaded(msg)
274184
275- case key.Matches(msg, m.keys.Delete):
276- // Show delete confirmation
277- if item := m.list.SelectedItem(); item != nil {
278- confirm := styles.NewConfirm(m.theme, "Delete this entry?")
279- m.confirm = &confirm
280- }
185+ case domainStatsMsg:
186+ return m.handleDomainStats(msg)
281187
282- case key.Matches(msg, m.keys.DeleteDomain):
283- // Delete all from domain
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- }
188+ case historyDeletedMsg:
189+ return m.handleHistoryDeleted(msg)
290190
291- case key.Matches(msg, m.keys.Cleanup):
292- // Show cleanup modal
293- cleanup := styles.NewCleanup(m.theme)
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- }
191+ case historyCleanedMsg:
192+ return m.handleHistoryCleaned(msg)
193+ }
307194
308- case historyLoadedMsg:
309- if msg.err != nil {
310- m.err = msg.err
311- } else {
312- m.entries = msg.entries
313- m.updateList()
314- }
195+ return m, nil
196+}
315197
316- case domainStatsMsg:
317- if msg.err != nil {
318- m.err = msg.err
319- } else {
320- m.topDomains = msg.domains
198+func (m HistoryModel) handleCleanupModal(msg tea.Msg) (tea.Model, tea.Cmd) {
199+ cleanup, cmd := m.cleanup.Update(msg)
200+ m.cleanup = &cleanup
201+ if m.cleanup.Done() {
202+ if m.cleanup.Confirmed {
203+ cmd = m.performCleanup(m.cleanup.SelectedRange())
321204 }
205+ m.cleanup = nil
206+ }
207+ return m, cmd
208+}
322209
323- case historyDeletedMsg:
324- if msg.err != nil {
325- m.err = msg.err
326- } else {
327- // Reload history
328- cmds = append(cmds, m.loadHistory)
210+func (m HistoryModel) handleConfirmModal(msg tea.Msg) (tea.Model, tea.Cmd) {
211+ confirm, cmd := m.confirm.Update(msg)
212+ m.confirm = &confirm
213+ if m.confirm.Done() {
214+ if m.confirm.Result() {
215+ cmd = m.deleteCurrentEntry()
329216 }
217+ m.confirm = nil
218+ }
219+ return m, cmd
220+}
330221
331- case historyCleanedMsg:
332- if msg.err != nil {
333- m.err = msg.err
334- } else {
335- // Reload history
336- cmds = append(cmds, m.loadHistory)
222+func (m HistoryModel) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
223+ m.width = msg.Width
224+ m.height = msg.Height
225+ m.help.Width = msg.Width
226+ m.updateList()
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
337323 }
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
338343 }
344+}
339345
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
341379 }
342380
343381 // updateList updates the list with entries for the current tab.
@@ -482,6 +520,9 @@ func (m HistoryModel) performCleanup(cleanupRange styles.CleanupRange) tea.Cmd {
482520 // openURL opens a URL in the default browser.
483521 func (m HistoryModel) openURL(urlStr string) tea.Cmd {
484522 return func() tea.Msg {
523+ log := logging.FromContext(m.ctx)
524+ log.Debug().Str("url", urlStr).Msg("opening URL in browser")
525+
485526 // Use xdg-open on Linux
486527 _ = exec.Command("xdg-open", urlStr).Start()
487528 return nil
@@ -614,6 +655,9 @@ func (m HistoryListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
614655
615656 // View implements tea.Model.
616657 func (m HistoryListModel) View() string {
658+ if m.err != nil {
659+ return ""
660+ }
617661 return "" // Output handled externally
618662 }
619663
M internal/cli/model/stats.go +17 -9
@@ -209,19 +209,23 @@ func (m StatsModel) renderDailyActivity() string {
209209
210210 // Build sparkline using block characters
211211 blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
212+ const (
213+ sparklineMaxDays = 30
214+ sparklineMaxIndex = 7
215+ )
212216 var result string
213217
214218 // Show last 30 days or available data
215219 days := m.analytics.DailyVisits
216- if len(days) > 30 {
217- days = days[len(days)-30:]
220+ if len(days) > sparklineMaxDays {
221+ days = days[len(days)-sparklineMaxDays:]
218222 }
219223
220224 for _, d := range days {
221225 // Scale to block index (0-7)
222- idx := int(float64(d.Visits) / float64(maxVisits) * 7)
223- if idx > 7 {
224- idx = 7
226+ idx := int(float64(d.Visits) / float64(maxVisits) * sparklineMaxIndex)
227+ if idx > sparklineMaxIndex {
228+ idx = sparklineMaxIndex
225229 }
226230 result += string(blocks[idx])
227231 }
@@ -231,11 +235,15 @@ func (m StatsModel) renderDailyActivity() string {
231235
232236 // formatNumber formats a number for display.
233237 func formatNumber(n int64) string {
238+ const (
239+ formatMillion = 1_000_000
240+ formatThousand = 1_000
241+ )
234242 switch {
235- case n >= 1000000:
236- return fmt.Sprintf("%.1fM", float64(n)/1000000)
237- case n >= 1000:
238- return fmt.Sprintf("%.1fK", float64(n)/1000)
243+ case n >= formatMillion:
244+ return fmt.Sprintf("%.1fM", float64(n)/formatMillion)
245+ case n >= formatThousand:
246+ return fmt.Sprintf("%.1fK", float64(n)/formatThousand)
239247 default:
240248 return fmt.Sprintf("%d", n)
241249 }
M internal/cli/styles/about.go +6 -1
@@ -55,7 +55,12 @@ func (r *AboutRenderer) renderInfoLines(info build.Info) string {
5555 fmt.Sprintf("%s %s %s", iconStyle.Render(IconGo), keyStyle.Render("Go"), valStyle.Render(info.GoVersion)),
5656 "",
5757 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+ ),
5964 }
6065
6166 return strings.Join(lines, "\n")
M internal/cli/styles/badge.go +18 -8
@@ -41,7 +41,11 @@ func (t *Theme) MutedBadge(text string) string {
4141
4242 // StatusBadge renders a status badge with custom colors.
4343 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.
4549 Foreground(fg).
4650 Background(bg).
4751 Padding(0, 1)
@@ -52,6 +56,12 @@ func (t *Theme) StatusBadge(text string, fg, bg lipgloss.Color) string {
5256 func RelativeTime(tm time.Time) string {
5357 now := time.Now()
5458 diff := now.Sub(tm)
59+ const (
60+ hoursPerDay = 24
61+ daysPerWeek = 7
62+ daysPerMonth = 30
63+ daysPerYear = 365
64+ )
5565
5666 switch {
5767 case diff < time.Minute:
@@ -62,26 +72,26 @@ func RelativeTime(tm time.Time) string {
6272 return "1m ago"
6373 }
6474 return fmt.Sprintf("%dm ago", mins)
65- case diff < 24*time.Hour:
75+ case diff < hoursPerDay*time.Hour:
6676 hours := int(diff.Hours())
6777 if hours == 1 {
6878 return "1h ago"
6979 }
7080 return fmt.Sprintf("%dh ago", hours)
71- case diff < 7*24*time.Hour:
72- days := int(diff.Hours() / 24)
81+ case diff < time.Duration(daysPerWeek*hoursPerDay)*time.Hour:
82+ days := int(diff.Hours() / hoursPerDay)
7383 if days == 1 {
7484 return "1d ago"
7585 }
7686 return fmt.Sprintf("%dd ago", days)
77- case diff < 30*24*time.Hour:
78- weeks := int(diff.Hours() / (24 * 7))
87+ case diff < time.Duration(daysPerMonth*hoursPerDay)*time.Hour:
88+ weeks := int(diff.Hours() / (hoursPerDay * daysPerWeek))
7989 if weeks == 1 {
8090 return "1w ago"
8191 }
8292 return fmt.Sprintf("%dw ago", weeks)
83- case diff < 365*24*time.Hour:
84- months := int(diff.Hours() / (24 * 30))
93+ case diff < time.Duration(daysPerYear*hoursPerDay)*time.Hour:
94+ months := int(diff.Hours() / (hoursPerDay * daysPerMonth))
8595 if months == 1 {
8696 return "1mo ago"
8797 }
M internal/cli/styles/cleanup.go +6 -3
@@ -67,7 +67,7 @@ type CleanupModel struct {
6767 Ranges []CleanupRangeInfo
6868 Selected int
6969 Confirmed bool
70- Cancelled bool
70+ Canceled bool
7171 theme *Theme
7272 }
7373
@@ -100,6 +100,9 @@ func NewCleanup(theme *Theme) CleanupModel {
100100
101101 // Init implements tea.Model.
102102 func (m CleanupModel) Init() tea.Cmd {
103+ if m.theme == nil {
104+ return nil
105+ }
103106 return nil
104107 }
105108
@@ -121,7 +124,7 @@ func (m CleanupModel) Update(msg tea.Msg) (CleanupModel, tea.Cmd) {
121124 case key.Matches(msg, keys.Confirm):
122125 m.Confirmed = true
123126 case key.Matches(msg, keys.Cancel):
124- m.Cancelled = true
127+ m.Canceled = true
125128 }
126129 }
127130
@@ -198,7 +201,7 @@ func (m CleanupModel) View() string {
198201
199202 // Done returns true if the modal is complete.
200203 func (m CleanupModel) Done() bool {
201- return m.Confirmed || m.Cancelled
204+ return m.Confirmed || m.Canceled
202205 }
203206
204207 // SelectedRange returns the selected cleanup range.
M internal/cli/styles/confirm.go +6 -3
@@ -11,7 +11,7 @@ type ConfirmModel struct {
1111 Message string
1212 Yes bool // Current selection
1313 Confirmed bool // User pressed enter
14- Cancelled bool // User pressed escape
14+ Canceled bool // User pressed escape
1515 theme *Theme
1616 }
1717
@@ -48,6 +48,9 @@ func NewConfirm(theme *Theme, message string) ConfirmModel {
4848
4949 // Init implements tea.Model.
5050 func (m ConfirmModel) Init() tea.Cmd {
51+ if m.theme == nil {
52+ return nil
53+ }
5154 return nil
5255 }
5356
@@ -69,7 +72,7 @@ func (m ConfirmModel) Update(msg tea.Msg) (ConfirmModel, tea.Cmd) {
6972 case key.Matches(msg, keys.Confirm):
7073 m.Confirmed = true
7174 case key.Matches(msg, keys.Cancel):
72- m.Cancelled = true
75+ m.Canceled = true
7376 }
7477 }
7578
@@ -110,7 +113,7 @@ func (m ConfirmModel) View() string {
110113
111114 // Done returns true if the dialog is complete.
112115 func (m ConfirmModel) Done() bool {
113- return m.Confirmed || m.Cancelled
116+ return m.Confirmed || m.Canceled
114117 }
115118
116119 // Result returns true if user confirmed "Yes".
M internal/cli/styles/doctor.go +24 -8
@@ -7,6 +7,11 @@ import (
77 "github.com/charmbracelet/lipgloss"
88 )
99
10+const (
11+ statusYes = "Yes"
12+ statusNo = "No"
13+)
14+
1015 type DoctorRenderer struct {
1116 theme *Theme
1217 }
@@ -90,7 +95,12 @@ func (r *DoctorRenderer) renderRuntime(rt DoctorRuntimeReport) string {
9095 lines := make([]string, 0, len(rt.Checks)+2)
9196
9297 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+ ))
94104 }
95105
96106 for _, c := range rt.Checks {
@@ -133,33 +143,39 @@ func (r *DoctorRenderer) renderMedia(m DoctorMediaReport) string {
133143
134144 gstIcon := IconCheck
135145 gstStyle := r.theme.SuccessStyle
136- gstText := "Yes"
146+ gstText := statusYes
137147 if !m.GStreamerAvailable {
138148 gstIcon = IconX
139149 gstStyle = r.theme.ErrorStyle
140- gstText = "No"
150+ gstText = statusNo
141151 }
142152 lines = append(lines, fmt.Sprintf("%s %s %s", gstStyle.Render(gstIcon), r.theme.Subtle.Render("GStreamer"), gstStyle.Render(gstText)))
143153
144154 hwIcon := IconWarning
145155 hwStyle := r.theme.WarningStyle
146- hwText := "No"
156+ hwText := statusNo
147157 if m.HWAccelAvailable {
148158 hwIcon = IconCheck
149159 hwStyle = r.theme.SuccessStyle
150- hwText = "Yes"
160+ hwText = statusYes
151161 }
152162 lines = append(lines, fmt.Sprintf("%s %s %s", hwStyle.Render(hwIcon), r.theme.Subtle.Render("HW decode"), hwStyle.Render(hwText)))
153163
154164 av1Icon := IconWarning
155165 av1Style := r.theme.WarningStyle
156- av1Text := "No"
166+ av1Text := statusNo
157167 if m.AV1HWAvailable {
158168 av1Icon = IconCheck
159169 av1Style = r.theme.SuccessStyle
160- av1Text = "Yes"
170+ av1Text = statusYes
161171 }
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+ ))
163179
164180 plugins := []string{
165181 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 {
5555
5656 // Height returns the height of each item.
5757 func (d HistoryDelegate) Height() int {
58+ if d.Theme == nil {
59+ return 2
60+ }
5861 return 2
5962 }
6063
6164 // Spacing returns the spacing between items.
6265 func (d HistoryDelegate) Spacing() int {
66+ if d.Theme == nil {
67+ return 0
68+ }
6369 return 0
6470 }
6571
6672 // Update handles item-level events.
6773 func (d HistoryDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd {
74+ if d.Theme == nil {
75+ return nil
76+ }
6877 return nil
6978 }
7079
@@ -77,17 +86,22 @@ func (d HistoryDelegate) Render(w io.Writer, m list.Model, index int, item list.
7786
7887 t := d.Theme
7988 isSelected := index == m.Index()
89+ const (
90+ maxTitleLength = 60
91+ maxURLLength = 50
92+ ellipsisLength = 3
93+ )
8094
8195 // Title line
8296 title := hi.TitleValue()
83- if len(title) > 60 {
84- title = title[:57] + "..."
97+ if len(title) > maxTitleLength {
98+ title = title[:maxTitleLength-ellipsisLength] + "..."
8599 }
86100
87101 // URL/description line
88102 url := hi.URL
89- if len(url) > 50 {
90- url = url[:47] + "..."
103+ if len(url) > maxURLLength {
104+ url = url[:maxURLLength-ellipsisLength] + "..."
91105 }
92106
93107 // Metadata badges
@@ -179,16 +193,25 @@ type SimpleDelegate struct {
179193
180194 // Height returns the height of each item.
181195 func (d SimpleDelegate) Height() int {
196+ if d.Theme == nil {
197+ return 1
198+ }
182199 return 1
183200 }
184201
185202 // Spacing returns the spacing between items.
186203 func (d SimpleDelegate) Spacing() int {
204+ if d.Theme == nil {
205+ return 0
206+ }
187207 return 0
188208 }
189209
190210 // Update handles item-level events.
191211 func (d SimpleDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd {
212+ if d.Theme == nil {
213+ return nil
214+ }
192215 return nil
193216 }
194217
M internal/cli/styles/table.go +8 -4
@@ -69,11 +69,15 @@ func (d DomainStatsRow) ToRow() table.Row {
6969
7070 // formatInt formats an integer for display.
7171 func formatInt(n int) string {
72+ const (
73+ formatMillion = 1_000_000
74+ formatThousand = 1_000
75+ )
7276 switch {
73- case n >= 1000000:
74- return formatFloat(float64(n)/1000000) + "M"
75- case n >= 1000:
76- return formatFloat(float64(n)/1000) + "K"
77+ case n >= formatMillion:
78+ return formatFloat(float64(n)/formatMillion) + "M"
79+ case n >= formatThousand:
80+ return formatFloat(float64(n)/formatThousand) + "K"
7781 default:
7882 return intToString(n)
7983 }
M internal/cli/styles/tabs.go +2 -1
@@ -64,7 +64,8 @@ func (m TabsModel) View() string {
6464 row += tab
6565 }
6666
67- return m.theme.TabBar.Width(80).Render(row)
67+ const tabBarWidth = 80
68+ return m.theme.TabBar.Width(tabBarWidth).Render(row)
6869 }
6970
7071 // ViewCompact renders a compact tab bar without separators.
M internal/cli/styles/theme.go +3 -0
@@ -218,5 +218,8 @@ func (t *Theme) buildStyles() {
218218
219219 // Renderer returns a lipgloss renderer for the current output.
220220 func (t *Theme) Renderer() *lipgloss.Renderer {
221+ if t == nil {
222+ return lipgloss.DefaultRenderer()
223+ }
221224 return lipgloss.DefaultRenderer()
222225 }
M internal/domain/validation/fonts.go +3 -2
@@ -2,7 +2,8 @@ package validation
22
33 import "strings"
44
5-func ValidateFontFamily(field string, value string) []string {
5+func ValidateFontFamily(field, value string) []string {
6+ const maxFontFamilyLength = 200
67 value = strings.TrimSpace(value)
78 var errs []string
89
@@ -15,7 +16,7 @@ func ValidateFontFamily(field string, value string) []string {
1516 errs = append(errs, field+" must not contain newlines")
1617 }
1718
18- if len(value) > 200 {
19+ if len(value) > maxFontFamilyLength {
1920 errs = append(errs, field+" is too long")
2021 }
2122
M internal/domain/validation/shortcuts.go +2 -1
@@ -43,12 +43,13 @@ func ValidateShortcutURL(value string) []string {
4343 }
4444
4545 func ValidateShortcutDescription(value string) []string {
46+ const maxShortcutDescriptionLength = 200
4647 var errs []string
4748 value = strings.TrimSpace(value)
4849 if strings.ContainsAny(value, "\r\n") {
4950 errs = append(errs, "shortcut description must not contain newlines")
5051 }
51- if len(value) > 200 {
52+ if len(value) > maxShortcutDescriptionLength {
5253 errs = append(errs, "shortcut description is too long")
5354 }
5455 return errs
M internal/infrastructure/config/loader.go +92 -46
@@ -75,26 +75,50 @@ func (m *Manager) Load() error {
7575 m.mu.Lock()
7676 defer m.mu.Unlock()
7777
78- // Ensure directories exist
7978 if err := EnsureDirectories(); err != nil {
8079 return fmt.Errorf("failed to ensure directories: %w", err)
8180 }
8281
83- // Set defaults
8482 m.setDefaults()
8583
86- // Read config file if it exists
84+ if err := m.readConfigFile(); err != nil {
85+ return err
86+ }
87+
88+ config, err := m.unmarshalConfig()
89+ if err != nil {
90+ return err
91+ }
92+ if err := ensureDatabasePath(config); err != nil {
93+ return err
94+ }
95+ normalizeConfig(config)
96+
97+ if err := validateConfig(config); err != nil {
98+ return fmt.Errorf("configuration validation failed: %w", err)
99+ }
100+
101+ m.config = config
102+ return nil
103+}
104+
105+func (m *Manager) readConfigFile() error {
87106 if err := m.viper.ReadInConfig(); err != nil {
88107 var configFileNotFoundError viper.ConfigFileNotFoundError
89108 if errors.As(err, &configFileNotFoundError) {
90- // Config file not found, create default one
91- if err := m.createDefaultConfig(); err != nil {
109+ if createErr := m.createDefaultConfig(); createErr != nil {
92110 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+ )
94116 }
95- // Re-read the newly created config file
96- if err := m.viper.ReadInConfig(); err != nil {
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)
117+ if rereadErr := m.viper.ReadInConfig(); rereadErr != nil {
118+ return fmt.Errorf(
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+ )
98122 }
99123 } else {
100124 configFile := m.viper.ConfigFileUsed()
@@ -105,24 +129,35 @@ func (m *Manager) Load() error {
105129 return fmt.Errorf("failed to read config file at %s: %w\nCheck the file format (must be valid TOML) and permissions", configFile, err)
106130 }
107131 }
132+ return nil
133+}
108134
109- // Unmarshal into config struct
135+func (m *Manager) unmarshalConfig() (*Config, error) {
110136 config := &Config{}
111137 if err := m.viper.Unmarshal(config); err != nil {
112138 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+ )
114144 }
145+ return config, nil
146+}
115147
116- // Set database path if not specified
117- if config.Database.Path == "" {
118- dbPath, err := GetDatabaseFile()
119- if err != nil {
120- return fmt.Errorf("failed to get database path: %w", err)
121- }
122- config.Database.Path = dbPath
148+func ensureDatabasePath(config *Config) error {
149+ if config.Database.Path != "" {
150+ return nil
151+ }
152+ dbPath, err := GetDatabaseFile()
153+ if err != nil {
154+ return fmt.Errorf("failed to get database path: %w", err)
123155 }
156+ config.Database.Path = dbPath
157+ return nil
158+}
124159
125- // Normalize/validate rendering mode using switch statement
160+func normalizeConfig(config *Config) {
126161 switch strings.ToLower(string(config.RenderingMode)) {
127162 case "", string(RenderingModeAuto):
128163 config.RenderingMode = RenderingModeAuto
@@ -134,19 +169,14 @@ func (m *Manager) Load() error {
134169 config.RenderingMode = RenderingModeAuto
135170 }
136171
137- // Validate ColorScheme setting using switch statement
138172 switch config.Appearance.ColorScheme {
139- case "prefer-dark", "prefer-light", ThemeDefault:
140- // Valid values - no changes needed
173+ case ThemePreferDark, ThemePreferLight, ThemeDefault:
141174 case "":
142- // Empty string is treated the same as "default"
143175 config.Appearance.ColorScheme = ThemeDefault
144176 default:
145- // Invalid value - reset to default
146177 config.Appearance.ColorScheme = ThemeDefault
147178 }
148179
149- // Validate Media.HardwareDecodingMode using switch statement
150180 switch strings.ToLower(string(config.Media.HardwareDecodingMode)) {
151181 case "", string(HardwareDecodingAuto):
152182 config.Media.HardwareDecodingMode = HardwareDecodingAuto
@@ -158,16 +188,7 @@ func (m *Manager) Load() error {
158188 config.Media.HardwareDecodingMode = HardwareDecodingAuto
159189 }
160190
161- // Normalize runtime prefix
162191 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
171192 }
172193
173194 // Get returns the current configuration (thread-safe).
@@ -258,16 +279,32 @@ func (m *Manager) setDefaults() {
258279
259280 // Note: Database.Path is set dynamically in Load(), no defaults needed
260281
261- // History defaults
282+ m.setHistoryDefaults(defaults)
283+ m.setSearchDefaults(defaults)
284+ m.setDmenuDefaults(defaults)
285+ m.setLoggingDefaults(defaults)
286+ m.setDebugDefaults(defaults)
287+ m.setAppearanceDefaults(defaults)
288+ m.setRenderingDefaults(defaults)
289+ m.setWorkspaceDefaults(defaults)
290+ m.setContentFilteringDefaults(defaults)
291+ m.setOmniboxDefaults(defaults)
292+ m.setMediaDefaults(defaults)
293+ m.setRuntimeDefaults(defaults)
294+}
295+
296+func (m *Manager) setHistoryDefaults(defaults *Config) {
262297 m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
263298 m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
264299 m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
300+}
265301
266- // Search shortcuts defaults
302+func (m *Manager) setSearchDefaults(defaults *Config) {
267303 m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
268304 m.viper.SetDefault("default_search_engine", defaults.DefaultSearchEngine)
305+}
269306
270- // Dmenu defaults
307+func (m *Manager) setDmenuDefaults(defaults *Config) {
271308 m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
272309 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
273310 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
@@ -276,19 +313,22 @@ func (m *Manager) setDefaults() {
276313 m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
277314 m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
278315 m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
316+}
279317
280- // Logging defaults
318+func (m *Manager) setLoggingDefaults(defaults *Config) {
281319 m.viper.SetDefault("logging.level", defaults.Logging.Level)
282320 m.viper.SetDefault("logging.format", defaults.Logging.Format)
283321 m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
284322 m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
285323 m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
286324 m.viper.SetDefault("logging.capture_console", defaults.Logging.CaptureConsole)
325+}
287326
288- // Debug defaults
327+func (m *Manager) setDebugDefaults(defaults *Config) {
289328 m.viper.SetDefault("debug.enable_devtools", defaults.Debug.EnableDevTools)
329+}
290330
291- // Appearance defaults
331+func (m *Manager) setAppearanceDefaults(defaults *Config) {
292332 m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
293333 m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
294334 m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
@@ -296,13 +336,15 @@ func (m *Manager) setDefaults() {
296336 m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)
297337 m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)
298338 m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme)
339+}
299340
300- // Rendering defaults
341+func (m *Manager) setRenderingDefaults(defaults *Config) {
301342 m.viper.SetDefault("rendering_mode", string(RenderingModeGPU))
302343 m.viper.SetDefault("default_webpage_zoom", defaults.DefaultWebpageZoom)
303344 m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)
345+}
304346
305- // Workspace defaults
347+func (m *Manager) setWorkspaceDefaults(defaults *Config) {
306348 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
307349 m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
308350 m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
@@ -328,20 +370,24 @@ func (m *Manager) setDefaults() {
328370 m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)
329371 m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)
330372 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
373+}
331374
332- // Content filtering
375+func (m *Manager) setContentFilteringDefaults(defaults *Config) {
333376 m.viper.SetDefault("content_filtering.enabled", defaults.ContentFiltering.Enabled)
334377 m.viper.SetDefault("content_filtering.auto_update", defaults.ContentFiltering.AutoUpdate)
378+}
335379
336- // Omnibox defaults
380+func (m *Manager) setOmniboxDefaults(defaults *Config) {
337381 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
382+}
338383
339- // Media defaults
384+func (m *Manager) setMediaDefaults(defaults *Config) {
340385 m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode))
341386 m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1)
342387 m.viper.SetDefault("media.show_diagnostics", defaults.Media.ShowDiagnosticsOnStartup)
388+}
343389
344- // Runtime defaults
390+func (m *Manager) setRuntimeDefaults(defaults *Config) {
345391 m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix)
346392 }
347393
M internal/infrastructure/config/schema.go +24 -17
@@ -17,7 +17,7 @@ type Config struct {
1717 DefaultWebpageZoom float64 `mapstructure:"default_webpage_zoom" yaml:"default_webpage_zoom" toml:"default_webpage_zoom"`
1818 // DefaultUIScale sets the default UI scale for GTK widgets (1.0 = 100%, 2.0 = 200%)
1919 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.
2121 Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"`
2222 // ContentFiltering controls ad blocking and content filtering
2323 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`
@@ -53,6 +53,13 @@ const (
5353 // ThemeDefault is the default theme setting (follows system).
5454 const ThemeDefault = "default"
5555
56+const (
57+ // ThemePreferDark explicitly selects dark theme.
58+ ThemePreferDark = "prefer-dark"
59+ // ThemePreferLight explicitly selects light theme.
60+ ThemePreferLight = "prefer-light"
61+)
62+
5663 // MediaConfig holds video playback and hardware acceleration preferences.
5764 type MediaConfig struct {
5865 // HardwareDecodingMode controls hardware vs software video decoding
@@ -173,27 +180,27 @@ type DebugConfig struct {
173180 EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"`
174181 }
175182
176-// WorkspaceConfig captures layout, pane, and tab behaviour preferences.
183+// WorkspaceConfig captures layout, pane, and tab behavior preferences.
177184 type WorkspaceConfig struct {
178- // PaneMode defines modal pane behaviour and bindings.
185+ // PaneMode defines modal pane behavior and bindings.
179186 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).
181188 TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
182189 // Shortcuts holds global (non-modal) keyboard shortcuts.
183190 Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
184191 // TabBarPosition determines tab bar placement: "top" or "bottom".
185192 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
186193 // 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
188195 // Popups configures default popup placement rules.
189196 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
190197 // Styling configures workspace visual appearance.
191198 Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"`
192199 }
193200
194-// PaneModeConfig defines modal behaviour for pane management.
201+// PaneModeConfig defines modal behavior for pane management.
195202 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
197204 TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
198205 Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
199206 }
@@ -210,9 +217,9 @@ func (p *PaneModeConfig) GetKeyBindings() map[string]string {
210217 return keyToAction
211218 }
212219
213-// TabModeConfig defines modal behaviour for tab management (Zellij-style).
220+// TabModeConfig defines modal behavior for tab management (Zellij-style).
214221 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
216223 TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
217224 Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
218225 }
@@ -263,15 +270,15 @@ type PopupBehaviorConfig struct {
263270 OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" toml:"open_in_new_pane" json:"open_in_new_pane"`
264271
265272 // 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
267274
268275 // BlankTargetBehavior determines how target="_blank" links are opened
269276 // Accepted values: "split", "stacked" (default), "tabbed"
270277 // 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
272279
273280 // 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
275282
276283 // OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects
277284 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 {
285292 BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
286293
287294 // 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
289296 // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)
290297 // 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
292299
293300 // 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
295302 // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable)
296303 // 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
298305
299306 // 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
301308 }
M internal/infrastructure/config/validation.go +119 -59
@@ -11,7 +11,30 @@ import (
1111 func validateConfig(config *Config) error {
1212 var validationErrors []string
1313
14- // Validate numeric ranges
14+ validationErrors = append(validationErrors, validateHistory(config)...)
15+ validationErrors = append(validationErrors, validateDmenu(config)...)
16+ validationErrors = append(validationErrors, validateAppearance(config)...)
17+ validationErrors = append(validationErrors, validateSearchEngine(config)...)
18+ validationErrors = append(validationErrors, validatePopups(config)...)
19+ validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)
20+ validationErrors = append(validationErrors, validatePaneMode(config)...)
21+ validationErrors = append(validationErrors, validateTabBar(config)...)
22+ validationErrors = append(validationErrors, validateTabMode(config)...)
23+ validationErrors = append(validationErrors, validateLogging(config)...)
24+ validationErrors = append(validationErrors, validateOmnibox(config)...)
25+ validationErrors = append(validationErrors, validateRendering(config)...)
26+ validationErrors = append(validationErrors, validateColorScheme(config)...)
27+
28+ // If there are validation errors, return them
29+ if len(validationErrors) > 0 {
30+ return fmt.Errorf("config validation failed:\n - %s", strings.Join(validationErrors, "\n - "))
31+ }
32+
33+ return nil
34+}
35+
36+func validateHistory(config *Config) []string {
37+ var validationErrors []string
1538 if config.History.MaxEntries < 0 {
1639 validationErrors = append(validationErrors, "history.max_entries must be non-negative")
1740 }
@@ -21,19 +44,27 @@ func validateConfig(config *Config) error {
2144 if config.History.CleanupIntervalDays < 0 {
2245 validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative")
2346 }
47+ return validationErrors
48+}
2449
50+func validateDmenu(config *Config) []string {
2551 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"}
2753 }
54+ return nil
55+}
2856
57+func validateAppearance(config *Config) []string {
58+ var validationErrors []string
2959 if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 {
3060 validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
3161 }
32-
3362 validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...)
3463 validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...)
35- validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...)
36-
64+ validationErrors = append(
65+ validationErrors,
66+ domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...,
67+ )
3768 validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
3869 "appearance.light_palette",
3970 config.Appearance.LightPalette.Background,
@@ -54,48 +85,60 @@ func validateConfig(config *Config) error {
5485 config.Appearance.DarkPalette.Accent,
5586 config.Appearance.DarkPalette.Border,
5687 )...)
57-
5888 if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 {
5989 validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0")
6090 }
6191 if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {
6292 validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")
6393 }
94+ return validationErrors
95+}
6496
65- // Validate default search engine
97+func validateSearchEngine(config *Config) []string {
6698 if config.DefaultSearchEngine == "" {
67- validationErrors = append(validationErrors, "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")
99+ return []string{"default_search_engine cannot be empty"}
70100 }
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+}
71106
72- // Validate popup behavior using switch statement (per CLAUDE.md)
107+func validatePopups(config *Config) []string {
108+ var validationErrors []string
73109 switch config.Workspace.Popups.Behavior {
74110 case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
75- // Valid
76111 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+ ))
78116 }
79117
80- // Validate popup placement for split behavior using switch statement
81118 if config.Workspace.Popups.Behavior == PopupBehaviorSplit {
82119 switch config.Workspace.Popups.Placement {
83120 case "right", "left", "top", "bottom":
84- // Valid
85121 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+ ))
87126 }
88127 }
89128
90- // Validate blank target behavior using switch statement
91129 switch config.Workspace.Popups.BlankTargetBehavior {
92130 case "split", "stacked", "tabbed":
93- // Valid
94131 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+ ))
96136 }
137+ return validationErrors
138+}
97139
98- // Validate workspace styling values
140+func validateWorkspaceStyling(config *Config) []string {
141+ var validationErrors []string
99142 if config.Workspace.Styling.BorderWidth < 0 {
100143 validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
101144 }
@@ -108,18 +151,18 @@ func validateConfig(config *Config) error {
108151 if config.Workspace.Styling.TransitionDuration < 0 {
109152 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
110153 }
154+ return validationErrors
155+}
111156
112- // Validate pane mode timeout
157+func validatePaneMode(config *Config) []string {
158+ var validationErrors []string
113159 if config.Workspace.PaneMode.TimeoutMilliseconds < 0 {
114160 validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative")
115161 }
116-
117- // Validate pane mode actions
118162 if len(config.Workspace.PaneMode.Actions) == 0 {
119163 validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty")
120164 }
121165
122- // Check for duplicate keys and empty action key lists in pane mode
123166 seenKeys := make(map[string]string)
124167 for action, keys := range config.Workspace.PaneMode.Actions {
125168 if len(keys) == 0 {
@@ -127,31 +170,37 @@ func validateConfig(config *Config) error {
127170 }
128171 for _, key := range keys {
129172 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+ ))
131179 }
132180 seenKeys[key] = action
133181 }
134182 }
183+ return validationErrors
184+}
135185
136- // Validate tab bar position using switch statement
186+func validateTabBar(config *Config) []string {
137187 switch config.Workspace.TabBarPosition {
138188 case "top", "bottom":
139- // Valid
189+ return nil
140190 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)}
142192 }
193+}
143194
144- // Validate tab mode timeout
195+func validateTabMode(config *Config) []string {
196+ var validationErrors []string
145197 if config.Workspace.TabMode.TimeoutMilliseconds < 0 {
146198 validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative")
147199 }
148-
149- // Validate tab mode actions
150200 if len(config.Workspace.TabMode.Actions) == 0 {
151201 validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty")
152202 }
153203
154- // Check for duplicate keys and empty action key lists in tab mode
155204 tabSeenKeys := make(map[string]string)
156205 for action, keys := range config.Workspace.TabMode.Actions {
157206 if len(keys) == 0 {
@@ -159,61 +208,72 @@ func validateConfig(config *Config) error {
159208 }
160209 for _, key := range keys {
161210 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+ ))
163217 }
164218 tabSeenKeys[key] = action
165219 }
166220 }
221+ return validationErrors
222+}
167223
168- // Validate logging values
224+func validateLogging(config *Config) []string {
225+ var validationErrors []string
169226 if config.Logging.MaxAge < 0 {
170227 validationErrors = append(validationErrors, "logging.max_age must be non-negative")
171228 }
172-
173- // Validate logging level using switch statement
174229 switch config.Logging.Level {
175230 case "trace", "debug", "info", "warn", "error", "fatal", "":
176- // Valid (empty uses default)
177231 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+ ))
179236 }
180-
181- // Validate logging format using switch statement
182237 switch config.Logging.Format {
183238 case "text", "json", "console", "":
184- // Valid (empty uses default)
185239 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+ ))
187244 }
245+ return validationErrors
246+}
188247
189- // Validate omnibox initial behavior using switch statement
248+func validateOmnibox(config *Config) []string {
190249 switch config.Omnibox.InitialBehavior {
191250 case "recent", "most_visited", "none":
192- // Valid
251+ return nil
193252 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+ )}
195257 }
258+}
196259
197- // Validate rendering mode using switch statement
260+func validateRendering(config *Config) []string {
198261 switch config.RenderingMode {
199262 case RenderingModeAuto, RenderingModeGPU, RenderingModeCPU, "":
200- // Valid (empty uses default)
263+ return nil
201264 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)}
203266 }
267+}
204268
205- // Validate color scheme using switch statement
269+func validateColorScheme(config *Config) []string {
206270 switch config.Appearance.ColorScheme {
207- case "prefer-dark", "prefer-light", ThemeDefault, "":
208- // Valid (empty uses default)
271+ case ThemePreferDark, ThemePreferLight, ThemeDefault, "":
272+ return nil
209273 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+ )}
211278 }
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
219279 }
M internal/infrastructure/config/watcher.go +2 -2
@@ -18,7 +18,7 @@ func (m *Manager) Watch() error {
1818 }
1919
2020 m.viper.WatchConfig()
21- m.viper.OnConfigChange(func(e fsnotify.Event) {
21+ m.viper.OnConfigChange(func(_ fsnotify.Event) {
2222 // Reload config
2323 if err := m.reload(); err != nil {
2424 fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err)
@@ -83,7 +83,7 @@ func (m *Manager) reload() error {
8383
8484 // Validate ColorScheme setting using switch statement
8585 switch config.Appearance.ColorScheme {
86- case "prefer-dark", "prefer-light", ThemeDefault:
86+ case ThemePreferDark, ThemePreferLight, ThemeDefault:
8787 // Valid values - no changes needed
8888 case "":
8989 // Empty string is treated the same as "default"
M internal/infrastructure/deps/env.go +1 -1
@@ -15,7 +15,7 @@ func ApplyPrefixEnv(prefix string) {
1515 }
1616
1717 for key, values := range prefixEnv(prefix) {
18- os.Setenv(key, prependPathList(os.Getenv(key), values...))
18+ _ = os.Setenv(key, prependPathList(os.Getenv(key), values...))
1919 }
2020 }
2121
M internal/infrastructure/deps/pkgconfig.go +8 -1
@@ -15,7 +15,14 @@ func NewPkgConfigProbe() *PkgConfigProbe {
1515 return &PkgConfigProbe{}
1616 }
1717
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+ }
1926 pc, err := exec.LookPath("pkg-config")
2027 if err != nil {
2128 return "", &port.PkgConfigError{
M internal/infrastructure/desktop/adapter.go +10 -4
@@ -103,8 +103,8 @@ func getExecutablePath() (string, error) {
103103 execPath, err := os.Executable()
104104 if err == nil {
105105 // Resolve symlinks
106- resolved, err := filepath.EvalSymlinks(execPath)
107- if err == nil {
106+ resolved, symlinkErr := filepath.EvalSymlinks(execPath)
107+ if symlinkErr == nil {
108108 execPath = resolved
109109 }
110110 return execPath, nil
@@ -130,7 +130,7 @@ func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus
130130 }
131131 status.DesktopFilePath = desktopPath
132132
133- if _, err := os.Stat(desktopPath); err == nil {
133+ if _, statErr := os.Stat(desktopPath); statErr == nil {
134134 status.DesktopFileInstalled = true
135135 }
136136
@@ -141,7 +141,7 @@ func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus
141141 }
142142 status.IconFilePath = iconPath
143143
144- if _, err := os.Stat(iconPath); err == nil {
144+ if _, iconStatErr := os.Stat(iconPath); iconStatErr == nil {
145145 status.IconInstalled = true
146146 }
147147
@@ -244,6 +244,9 @@ func (a *Adapter) RemoveDesktopFile(ctx context.Context) error {
244244
245245 // InstallIcon writes the icon file to XDG icons directory.
246246 func (a *Adapter) InstallIcon(ctx context.Context, svgData []byte) (string, error) {
247+ if a == nil {
248+ return "", fmt.Errorf("desktop adapter is nil")
249+ }
247250 log := logging.FromContext(ctx)
248251
249252 iconPath, err := getIconFilePath()
@@ -269,6 +272,9 @@ func (a *Adapter) InstallIcon(ctx context.Context, svgData []byte) (string, erro
269272
270273 // RemoveIcon removes the icon file from XDG icons directory.
271274 func (a *Adapter) RemoveIcon(ctx context.Context) error {
275+ if a == nil {
276+ return fmt.Errorf("desktop adapter is nil")
277+ }
272278 log := logging.FromContext(ctx)
273279
274280 iconPath, err := getIconFilePath()
M internal/infrastructure/favicon/cache.go +0 -1
@@ -192,7 +192,6 @@ func (c *Cache) loadFromDisk(domain string) []byte {
192192 return nil
193193 }
194194
195- //nolint:gosec // path is constructed from sanitized domain
196195 data, err := os.ReadFile(path)
197196 if err != nil {
198197 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) {
5656 log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo")
5757 return nil, err
5858 }
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+ }()
6064
6165 if resp.StatusCode != http.StatusOK {
6266 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 (
1313 "github.com/bnema/dumber/internal/logging"
1414 )
1515
16+const (
17+ cacheDirPerm = 0o755
18+ manifestFilePerm = 0o644
19+)
20+
1621 // Downloader handles downloading filter files from GitHub releases.
1722 type Downloader struct {
1823 baseURL string
@@ -60,7 +65,7 @@ func (d *Downloader) FetchManifest(ctx context.Context) (*Manifest, error) {
6065 url := fmt.Sprintf("%s/%s", d.baseURL, FilterFiles.Manifest)
6166 log.Debug().Str("url", url).Msg("fetching manifest")
6267
63- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
68+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
6469 if err != nil {
6570 return nil, fmt.Errorf("failed to create request: %w", err)
6671 }
@@ -69,7 +74,11 @@ func (d *Downloader) FetchManifest(ctx context.Context) (*Manifest, error) {
6974 if err != nil {
7075 return nil, fmt.Errorf("failed to fetch manifest: %w", err)
7176 }
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+ }()
7382
7483 if resp.StatusCode != http.StatusOK {
7584 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
105114 Logger()
106115
107116 // Ensure cache directory exists
108- if err := os.MkdirAll(d.cacheDir, 0o755); err != nil {
117+ if err := os.MkdirAll(d.cacheDir, cacheDirPerm); err != nil {
109118 return nil, fmt.Errorf("failed to create cache dir: %w", err)
110119 }
111120
@@ -155,7 +164,7 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string,
155164 url := fmt.Sprintf("%s/%s", d.baseURL, filename)
156165 localPath := filepath.Join(d.cacheDir, filename)
157166
158- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
167+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
159168 if err != nil {
160169 return "", 0, fmt.Errorf("failed to create request: %w", err)
161170 }
@@ -164,7 +173,11 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string,
164173 if err != nil {
165174 return "", 0, fmt.Errorf("failed to download %s: %w", filename, err)
166175 }
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+ }()
168181
169182 if resp.StatusCode != http.StatusOK {
170183 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,
179192
180193 bytesWritten, err := io.Copy(file, resp.Body)
181194 if err != nil {
182- file.Close()
183- os.Remove(tmpPath)
195+ _ = file.Close()
196+ _ = os.Remove(tmpPath)
184197 return "", 0, fmt.Errorf("failed to write %s: %w", filename, err)
185198 }
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+ }
187203
188204 // Atomic rename
189205 if err := os.Rename(tmpPath, localPath); err != nil {
190- os.Remove(tmpPath)
206+ _ = os.Remove(tmpPath)
191207 return "", 0, fmt.Errorf("failed to rename temp file: %w", err)
192208 }
193209
@@ -207,7 +223,7 @@ func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error {
207223 }
208224
209225 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 {
211227 return fmt.Errorf("failed to write manifest: %w", err)
212228 }
213229
M internal/infrastructure/filtering/manager.go +12 -5
@@ -13,6 +13,12 @@ import (
1313 "github.com/bnema/puregotk-webkit/webkit"
1414 )
1515
16+const (
17+ storeDirPerm = 0o755
18+ jsonDirPerm = 0o755
19+ mergedFilePerm = 0o644
20+)
21+
1622 // Manager orchestrates the content filter lifecycle.
1723 // It handles downloading, compiling, loading, and applying filters to WebViews.
1824 type Manager struct {
@@ -47,20 +53,21 @@ type ManagerConfig struct {
4753 // NewManager creates a new filter Manager.
4854 func NewManager(cfg ManagerConfig) (*Manager, error) {
4955 // Ensure directories exist
50- if err := os.MkdirAll(cfg.StoreDir, 0o755); err != nil {
56+ if err := os.MkdirAll(cfg.StoreDir, storeDirPerm); err != nil {
5157 return nil, fmt.Errorf("failed to create store dir: %w", err)
5258 }
53- if err := os.MkdirAll(cfg.JSONDir, 0o755); err != nil {
59+ if err := os.MkdirAll(cfg.JSONDir, jsonDirPerm); err != nil {
5460 return nil, fmt.Errorf("failed to create json dir: %w", err)
5561 }
5662
5763 // Use provided implementations or create defaults
5864 store := cfg.Store
5965 if store == nil {
60- store = NewStore(cfg.StoreDir)
61- if store == nil {
66+ newStore := NewStore(cfg.StoreDir)
67+ if newStore == nil {
6268 return nil, fmt.Errorf("failed to create filter store at %s", cfg.StoreDir)
6369 }
70+ store = newStore
6471 }
6572
6673 downloader := cfg.Downloader
@@ -243,7 +250,7 @@ func (m *Manager) mergeJSONFiles(ctx context.Context, paths []string) (string, e
243250
244251 // Write merged file
245252 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 {
247254 return "", fmt.Errorf("failed to write merged file: %w", err)
248255 }
249256
M internal/infrastructure/filtering/manager_test.go +5 -5
@@ -74,9 +74,9 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) {
7474 // Track status changes
7575 var statuses []filtering.FilterStatus
7676 var mu sync.Mutex
77- mgr.SetStatusCallback(func(s filtering.FilterStatus) {
77+ mgr.SetStatusCallback(func(status filtering.FilterStatus) {
7878 mu.Lock()
79- statuses = append(statuses, s)
79+ statuses = append(statuses, status)
8080 mu.Unlock()
8181 })
8282
@@ -165,9 +165,9 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) {
165165 // Track status changes
166166 var statuses []filtering.FilterStatus
167167 var mu sync.Mutex
168- mgr.SetStatusCallback(func(s filtering.FilterStatus) {
168+ mgr.SetStatusCallback(func(status filtering.FilterStatus) {
169169 mu.Lock()
170- statuses = append(statuses, s)
170+ statuses = append(statuses, status)
171171 mu.Unlock()
172172 })
173173
@@ -470,7 +470,7 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) {
470470 // Track callback invocations
471471 callCount := 0
472472 var mu sync.Mutex
473- mgr.SetStatusCallback(func(s filtering.FilterStatus) {
473+ mgr.SetStatusCallback(func(_ filtering.FilterStatus) {
474474 mu.Lock()
475475 callCount++
476476 mu.Unlock()
M internal/infrastructure/filtering/store.go +5 -5
@@ -44,7 +44,7 @@ type CompileResult struct {
4444 // Compile compiles a JSON filter file and stores it with the given identifier.
4545 // This is an async operation that may take several seconds for large filter sets.
4646 // 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) {
4848 log := logging.FromContext(ctx).With().
4949 Str("component", "filter-store").
5050 Str("identifier", identifier).
@@ -90,7 +90,7 @@ func (s *Store) Compile(ctx context.Context, identifier string, jsonPath string)
9090 log.Info().Msg("filter compilation completed")
9191 return result.Filter, nil
9292 case <-ctx.Done():
93- log.Warn().Msg("filter compilation cancelled")
93+ log.Warn().Msg("filter compilation canceled")
9494 return nil, ctx.Err()
9595 }
9696 }
@@ -142,7 +142,7 @@ func (s *Store) Load(ctx context.Context, identifier string) (*webkit.UserConten
142142 log.Debug().Msg("filter loaded successfully")
143143 return result.Filter, nil
144144 case <-ctx.Done():
145- log.Warn().Msg("filter load cancelled")
145+ log.Warn().Msg("filter load canceled")
146146 return nil, ctx.Err()
147147 }
148148 }
@@ -192,7 +192,7 @@ func (s *Store) FetchIdentifiers(ctx context.Context) ([]string, error) {
192192 log.Debug().Int("count", len(result.Identifiers)).Msg("fetched filter identifiers")
193193 return result.Identifiers, nil
194194 case <-ctx.Done():
195- log.Warn().Msg("fetch identifiers cancelled")
195+ log.Warn().Msg("fetch identifiers canceled")
196196 return nil, ctx.Err()
197197 }
198198 }
@@ -237,7 +237,7 @@ func (s *Store) Remove(ctx context.Context, identifier string) error {
237237 log.Info().Msg("filter removed")
238238 return nil
239239 case <-ctx.Done():
240- log.Warn().Msg("remove filter cancelled")
240+ log.Warn().Msg("remove filter canceled")
241241 return ctx.Err()
242242 }
243243 }
M internal/infrastructure/media/diagnostics.go +22 -9
@@ -90,6 +90,9 @@ func (a *Adapter) checkGStreamerPlugins(ctx context.Context, r *port.MediaDiagno
9090
9191 // parseVADecoders extracts decoder names from gst-inspect-1.0 va output.
9292 func (a *Adapter) parseVADecoders(output string, r *port.MediaDiagnosticsResult) {
93+ if a == nil {
94+ return
95+ }
9396 lines := strings.Split(output, "\n")
9497 for _, line := range lines {
9598 line = strings.TrimSpace(line)
@@ -111,6 +114,9 @@ func (a *Adapter) parseVADecoders(output string, r *port.MediaDiagnosticsResult)
111114
112115 // parseVAAPIDecoders extracts decoder names from gst-inspect-1.0 vaapi output.
113116 func (a *Adapter) parseVAAPIDecoders(output string, r *port.MediaDiagnosticsResult) {
117+ if a == nil {
118+ return
119+ }
114120 lines := strings.Split(output, "\n")
115121 for _, line := range lines {
116122 line = strings.TrimSpace(line)
@@ -132,6 +138,9 @@ func (a *Adapter) parseVAAPIDecoders(output string, r *port.MediaDiagnosticsResu
132138
133139 // parseNVCodecDecoders extracts decoder names from gst-inspect-1.0 nvcodec output.
134140 func (a *Adapter) parseNVCodecDecoders(output string, r *port.MediaDiagnosticsResult) {
141+ if a == nil {
142+ return
143+ }
135144 lines := strings.Split(output, "\n")
136145 for _, line := range lines {
137146 line = strings.TrimSpace(line)
@@ -154,6 +163,9 @@ func (a *Adapter) parseNVCodecDecoders(output string, r *port.MediaDiagnosticsRe
154163 // checkVAAPI detects VA-API driver and version.
155164 func (a *Adapter) checkVAAPI(ctx context.Context, r *port.MediaDiagnosticsResult) {
156165 log := logging.FromContext(ctx)
166+ if a == nil {
167+ return
168+ }
157169
158170 // Check LIBVA_DRIVER_NAME environment first
159171 if driver := os.Getenv("LIBVA_DRIVER_NAME"); driver != "" {
@@ -211,23 +223,23 @@ func (a *Adapter) checkVAAPI(ctx context.Context, r *port.MediaDiagnosticsResult
211223 }
212224
213225 // generateWarnings creates user-friendly warning messages.
226+//nolint:revive // receiver required for interface consistency
214227 func (a *Adapter) generateWarnings(r *port.MediaDiagnosticsResult) {
215228 // Critical: GStreamer not installed
216229 if !r.GStreamerAvailable {
217230 r.Warnings = append(r.Warnings,
218- "CRITICAL: GStreamer not installed. Video playback will not work!")
219- r.Warnings = append(r.Warnings,
220- "Install: gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav")
231+ "CRITICAL: GStreamer not installed. Video playback will not work!",
232+ "Install: gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav",
233+ )
221234 return // No point checking other things
222235 }
223236
224237 if !r.HWAccelAvailable {
225238 r.Warnings = append(r.Warnings,
226- "No hardware video acceleration detected. Video will use software decoding (higher CPU).")
227- r.Warnings = append(r.Warnings,
228- "Install VA plugin: gst-plugin-va (Arch) or gstreamer1.0-plugins-bad (Debian/Ubuntu)")
229- r.Warnings = append(r.Warnings,
230- "Install VA-API driver: libva-mesa-driver (AMD) | intel-media-driver (Intel) | nvidia-vaapi-driver (NVIDIA)")
239+ "No hardware video acceleration detected. Video will use software decoding (higher CPU).",
240+ "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)",
242+ )
231243 }
232244
233245 // AV1 is the preferred codec - warn if not available
@@ -245,6 +257,7 @@ func (a *Adapter) generateWarnings(r *port.MediaDiagnosticsResult) {
245257 // Prefer modern VA plugin over legacy VAAPI
246258 if r.HasVAAPIPlugin && !r.HasVAPlugin {
247259 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.")
249262 }
250263 }
M internal/infrastructure/persistence/sqlite/connection.go +2 -1
@@ -15,6 +15,7 @@ import (
1515 // NewConnection creates a new SQLite database connection with optimized settings.
1616 // It creates the database directory if it doesn't exist and applies performance pragmas.
1717 func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
18+ const dbDirPerm = 0o750
1819 log := logging.FromContext(ctx)
1920
2021 if dbPath == "" {
@@ -22,7 +23,7 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
2223 }
2324
2425 // 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 {
2627 return nil, fmt.Errorf("failed to create database directory: %w", err)
2728 }
2829
M internal/infrastructure/persistence/sqlite/favorite_repo.go +9 -8
@@ -3,6 +3,7 @@ package sqlite
33 import (
44 "context"
55 "database/sql"
6+ "errors"
67
78 "github.com/bnema/dumber/internal/domain/entity"
89 "github.com/bnema/dumber/internal/domain/repository"
@@ -46,7 +47,7 @@ func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
4647 func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
4748 row, err := r.queries.GetFavoriteByID(ctx, int64(id))
4849 if err != nil {
49- if err == sql.ErrNoRows {
50+ if errors.Is(err, sql.ErrNoRows) {
5051 return nil, nil
5152 }
5253 return nil, err
@@ -57,7 +58,7 @@ func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*ent
5758 func (r *favoriteRepo) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) {
5859 row, err := r.queries.GetFavoriteByURL(ctx, url)
5960 if err != nil {
60- if err == sql.ErrNoRows {
61+ if errors.Is(err, sql.ErrNoRows) {
6162 return nil, nil
6263 }
6364 return nil, err
@@ -91,7 +92,7 @@ func (r *favoriteRepo) GetByFolder(ctx context.Context, folderID *entity.FolderI
9192 func (r *favoriteRepo) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
9293 row, err := r.queries.GetFavoriteByShortcut(ctx, sql.NullInt64{Int64: int64(key), Valid: true})
9394 if err != nil {
94- if err == sql.ErrNoRows {
95+ if errors.Is(err, sql.ErrNoRows) {
9596 return nil, nil
9697 }
9798 return nil, err
@@ -155,8 +156,8 @@ func favoriteFromRow(row sqlc.Favorite) *entity.Favorite {
155156
156157 func favoritesFromRows(rows []sqlc.Favorite) []*entity.Favorite {
157158 favorites := make([]*entity.Favorite, len(rows))
158- for i, row := range rows {
159- favorites[i] = favoriteFromRow(row)
159+ for i := range rows {
160+ favorites[i] = favoriteFromRow(rows[i])
160161 }
161162 return favorites
162163 }
@@ -196,7 +197,7 @@ func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error {
196197 func (r *folderRepo) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) {
197198 row, err := r.queries.GetFolderByID(ctx, int64(id))
198199 if err != nil {
199- if err == sql.ErrNoRows {
200+ if errors.Is(err, sql.ErrNoRows) {
200201 return nil, nil
201202 }
202203 return nil, err
@@ -290,7 +291,7 @@ func (r *tagRepo) Save(ctx context.Context, tag *entity.Tag) error {
290291 func (r *tagRepo) FindByID(ctx context.Context, id entity.TagID) (*entity.Tag, error) {
291292 row, err := r.queries.GetTagByID(ctx, int64(id))
292293 if err != nil {
293- if err == sql.ErrNoRows {
294+ if errors.Is(err, sql.ErrNoRows) {
294295 return nil, nil
295296 }
296297 return nil, err
@@ -301,7 +302,7 @@ func (r *tagRepo) FindByID(ctx context.Context, id entity.TagID) (*entity.Tag, e
301302 func (r *tagRepo) FindByName(ctx context.Context, name string) (*entity.Tag, error) {
302303 row, err := r.queries.GetTagByName(ctx, name)
303304 if err != nil {
304- if err == sql.ErrNoRows {
305+ if errors.Is(err, sql.ErrNoRows) {
305306 return nil, nil
306307 }
307308 return nil, err
M internal/infrastructure/persistence/sqlite/history_repo.go +9 -6
@@ -3,6 +3,7 @@ package sqlite
33 import (
44 "context"
55 "database/sql"
6+ "errors"
67 "time"
78
89 "github.com/bnema/dumber/internal/domain/entity"
@@ -34,7 +35,7 @@ func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) erro
3435 func (r *historyRepo) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
3536 row, err := r.queries.GetHistoryByURL(ctx, url)
3637 if err != nil {
37- if err == sql.ErrNoRows {
38+ if errors.Is(err, sql.ErrNoRows) {
3839 return nil, nil
3940 }
4041 return nil, err
@@ -60,7 +61,8 @@ func (r *historyRepo) Search(ctx context.Context, query string, limit int) ([]en
6061 }
6162
6263 matches := make([]entity.HistoryMatch, len(rows))
63- for i, row := range rows {
64+ for i := range rows {
65+ row := rows[i]
6466 matches[i] = entity.HistoryMatch{
6567 Entry: historyFromRow(row),
6668 Score: 1.0, // FTS5 already ranked by bm25
@@ -79,8 +81,8 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti
7981 }
8082
8183 entries := make([]*entity.HistoryEntry, len(rows))
82- for i, row := range rows {
83- entries[i] = historyFromRow(row)
84+ for i := range rows {
85+ entries[i] = historyFromRow(rows[i])
8486 }
8587 return entries, nil
8688 }
@@ -118,9 +120,10 @@ func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error
118120
119121 // Handle the interface{} type for total_visits
120122 var totalVisits int64
121- if v, ok := row.TotalVisits.(int64); ok {
123+ switch v := row.TotalVisits.(type) {
124+ case int64:
122125 totalVisits = v
123- } else if v, ok := row.TotalVisits.(float64); ok {
126+ case float64:
124127 totalVisits = int64(v)
125128 }
126129
M internal/infrastructure/persistence/sqlite/migrations.go +2 -2
@@ -36,8 +36,8 @@ func RunMigrations(ctx context.Context, db *sql.DB) error {
3636 }
3737
3838 // Run migrations
39- if err := goose.Up(db, "migrations"); err != nil {
40- return fmt.Errorf("failed to run migrations: %w", err)
39+ if migrateErr := goose.Up(db, "migrations"); migrateErr != nil {
40+ return fmt.Errorf("failed to run migrations: %w", migrateErr)
4141 }
4242
4343 // Get new version after migration
M internal/infrastructure/persistence/sqlite/zoom_repo.go +2 -1
@@ -3,6 +3,7 @@ package sqlite
33 import (
44 "context"
55 "database/sql"
6+ "errors"
67
78 "github.com/bnema/dumber/internal/domain/entity"
89 "github.com/bnema/dumber/internal/domain/repository"
@@ -25,7 +26,7 @@ func (r *zoomRepo) Get(ctx context.Context, domain string) (*entity.ZoomLevel, e
2526
2627 row, err := r.queries.GetZoomLevel(ctx, domain)
2728 if err != nil {
28- if err == sql.ErrNoRows {
29+ if errors.Is(err, sql.ErrNoRows) {
2930 return nil, nil
3031 }
3132 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
7474 log := logging.FromContext(ctx)
7575
7676 // Look up parent WebView
77- parent := LookupWebView(WebViewID(parentID))
77+ parent := LookupWebView(parentID)
7878 if parent == nil {
7979 return nil, fmt.Errorf("parent webview %d not found", parentID)
8080 }
M internal/infrastructure/webkit/handlers/config.go +3 -0
@@ -22,6 +22,9 @@ func NewConfigHandler() *ConfigHandler {
2222
2323 // Handle processes the save_config message.
2424 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+ }
2528 log := logging.FromContext(ctx).With().Str("handler", "config").Logger()
2629
2730 var payloadCfg port.WebUIConfig
M internal/infrastructure/webkit/handlers/homepage/favorites.go +4 -4
@@ -22,7 +22,7 @@ func NewFavoritesHandlers(favoritesUC *usecase.ManageFavoritesUseCase) *Favorite
2222
2323 // HandleList handles favorite_list messages.
2424 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) {
2626 log := logging.FromContext(ctx)
2727
2828 requestID := ParseRequestID(payload)
@@ -49,7 +49,7 @@ type setShortcutRequest struct {
4949
5050 // HandleSetShortcut handles favorite_set_shortcut messages.
5151 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) {
5353 log := logging.FromContext(ctx)
5454
5555 var req setShortcutRequest
@@ -78,7 +78,7 @@ type getByShortcutRequest struct {
7878
7979 // HandleGetByShortcut handles favorite_get_by_shortcut messages.
8080 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) {
8282 log := logging.FromContext(ctx)
8383
8484 var req getByShortcutRequest
@@ -109,7 +109,7 @@ type setFolderRequest struct {
109109
110110 // HandleSetFolder handles favorite_set_folder messages.
111111 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) {
113113 log := logging.FromContext(ctx)
114114
115115 var req setFolderRequest
M internal/infrastructure/webkit/handlers/homepage/folders.go +4 -4
@@ -22,7 +22,7 @@ func NewFolderHandlers(favoritesUC *usecase.ManageFavoritesUseCase) *FolderHandl
2222
2323 // HandleList handles folder_list messages.
2424 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) {
2626 log := logging.FromContext(ctx)
2727
2828 requestID := ParseRequestID(payload)
@@ -49,7 +49,7 @@ type createFolderRequest struct {
4949
5050 // HandleCreate handles folder_create messages.
5151 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) {
5353 log := logging.FromContext(ctx)
5454
5555 var req createFolderRequest
@@ -84,7 +84,7 @@ type deleteFolderRequest struct {
8484
8585 // HandleDelete handles folder_delete messages.
8686 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) {
8888 log := logging.FromContext(ctx)
8989
9090 var req deleteFolderRequest
@@ -116,7 +116,7 @@ type updateFolderRequest struct {
116116 // HandleUpdate handles folder_update messages.
117117 // NOTE: This requires UpdateFolder() method to be added to ManageFavoritesUseCase.
118118 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) {
120120 log := logging.FromContext(ctx)
121121
122122 var req updateFolderRequest
M internal/infrastructure/webkit/handlers/homepage/history.go +8 -8
@@ -30,7 +30,7 @@ type timelineRequest struct {
3030
3131 // HandleTimeline handles history_timeline messages.
3232 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) {
3434 log := logging.FromContext(ctx)
3535
3636 var req timelineRequest
@@ -62,7 +62,7 @@ type searchRequest struct {
6262
6363 // HandleSearchFTS handles history_search_fts messages.
6464 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) {
6666 log := logging.FromContext(ctx)
6767
6868 var req searchRequest
@@ -101,7 +101,7 @@ type deleteEntryRequest struct {
101101
102102 // HandleDeleteEntry handles history_delete_entry messages.
103103 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) {
105105 log := logging.FromContext(ctx)
106106
107107 var req deleteEntryRequest
@@ -130,7 +130,7 @@ type deleteRangeRequest struct {
130130
131131 // HandleDeleteRange handles history_delete_range messages.
132132 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) {
134134 log := logging.FromContext(ctx)
135135
136136 var req deleteRangeRequest
@@ -161,7 +161,7 @@ func (h *HistoryHandlers) HandleDeleteRange() webkit.MessageHandler {
161161
162162 // HandleClearAll handles history_clear_all messages.
163163 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) {
165165 log := logging.FromContext(ctx)
166166
167167 requestID := ParseRequestID(payload)
@@ -200,7 +200,7 @@ func rangeToTime(r string) time.Time {
200200
201201 // HandleAnalytics handles history_analytics messages.
202202 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) {
204204 log := logging.FromContext(ctx)
205205
206206 requestID := ParseRequestID(payload)
@@ -226,7 +226,7 @@ type domainStatsRequest struct {
226226
227227 // HandleDomainStats handles history_domain_stats messages.
228228 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) {
230230 log := logging.FromContext(ctx)
231231
232232 var req domainStatsRequest
@@ -256,7 +256,7 @@ type deleteDomainRequest struct {
256256
257257 // HandleDeleteDomain handles history_delete_domain messages.
258258 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) {
260260 log := logging.FromContext(ctx)
261261
262262 var req deleteDomainRequest
M internal/infrastructure/webkit/handlers/homepage/tags.go +13 -28
@@ -22,7 +22,7 @@ func NewTagHandlers(favoritesUC *usecase.ManageFavoritesUseCase) *TagHandlers {
2222
2323 // HandleList handles tag_list messages.
2424 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) {
2626 log := logging.FromContext(ctx)
2727
2828 requestID := ParseRequestID(payload)
@@ -49,7 +49,7 @@ type createTagRequest struct {
4949
5050 // HandleCreate handles tag_create messages.
5151 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) {
5353 log := logging.FromContext(ctx)
5454
5555 var req createTagRequest
@@ -84,7 +84,7 @@ type deleteTagRequest struct {
8484
8585 // HandleDelete handles tag_delete messages.
8686 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) {
8888 log := logging.FromContext(ctx)
8989
9090 var req deleteTagRequest
@@ -116,7 +116,7 @@ type updateTagRequest struct {
116116 // HandleUpdate handles tag_update messages.
117117 // NOTE: This requires UpdateTag() method to be added to ManageFavoritesUseCase.
118118 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) {
120120 log := logging.FromContext(ctx)
121121
122122 var req updateTagRequest
@@ -155,34 +155,19 @@ type tagAssignRequest struct {
155155
156156 // HandleAssign handles tag_assign messages.
157157 func (h *TagHandlers) HandleAssign() webkit.MessageHandler {
158- return webkit.MessageHandlerFunc(func(ctx context.Context, webviewID webkit.WebViewID, payload json.RawMessage) (any, error) {
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- })
158+ return handleTagAssign("tag_assign", h.favoritesUC.TagFavorite)
178159 }
179160
180161 // HandleRemove handles tag_remove messages.
181162 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) {
183168 log := logging.FromContext(ctx)
184169
185- var req tagAssignRequest // same structure as assign
170+ var req tagAssignRequest
186171 if err := json.Unmarshal(payload, &req); err != nil {
187172 return NewErrorResponse("", err), nil
188173 }
@@ -191,9 +176,9 @@ func (h *TagHandlers) HandleRemove() webkit.MessageHandler {
191176 Str("request_id", req.RequestID).
192177 Int64("favorite_id", req.FavoriteID).
193178 Int64("tag_id", req.TagID).
194- Msg("handling tag_remove")
179+ Msg("handling " + action)
195180
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 {
197182 return NewErrorResponse(req.RequestID, err), nil
198183 }
199184
M internal/infrastructure/webkit/messaging.go +8 -2
@@ -106,7 +106,7 @@ func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCal
106106 // It registers the script message handler in the MAIN world (not isolated).
107107 // WebKit's messageHandlers is only available in main world.
108108 // 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) {
110110 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
111111
112112 log.Debug().Msg("SetupMessageHandler called")
@@ -255,7 +255,13 @@ func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewI
255255 return fmt.Errorf("marshal callback payload: %w", err)
256256 }
257257
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+ )
259265
260266 wv.RunJavaScript(ctx, script, world)
261267 return nil
M internal/infrastructure/webkit/pool.go +8 -2
@@ -54,7 +54,14 @@ type WebViewPool struct {
5454 }
5555
5656 // 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 {
5865 if cfg.MaxSize <= 0 {
5966 cfg.MaxSize = 8
6067 }
@@ -203,7 +210,6 @@ func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
203210 default:
204211 // Pool full, destroy
205212 wv.Destroy()
206- break
207213 }
208214
209215 // 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 {
6666 mu sync.RWMutex
6767 }
6868
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+
6986 // NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
7087 func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
7188 log := logging.FromContext(ctx)
@@ -93,7 +110,7 @@ func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
93110 // registerDefaults sets up default page handlers.
94111 func (h *DumbSchemeHandler) registerDefaults() {
95112 // Error page (static fallback)
96- h.RegisterPage("/error", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
113+h.RegisterPage("/error", PageHandlerFunc(func(_ *SchemeRequest) *SchemeResponse {
97114 return &SchemeResponse{
98115 Data: []byte(errorPageHTML),
99116 ContentType: "text/html",
@@ -107,46 +124,7 @@ func (h *DumbSchemeHandler) registerDefaults() {
107124 return nil
108125 }
109126
110- cfg := 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- }
127+ return buildConfigResponse(config.Get())
150128 }))
151129
152130 // API: Get default config (used by Reset Defaults in dumb://config)
@@ -155,47 +133,40 @@ func (h *DumbSchemeHandler) registerDefaults() {
155133 return nil
156134 }
157135
158- defaults := config.DefaultConfig()
159- resp := struct {
160- Appearance struct {
161- SansFont string `json:"sans_font"`
162- SerifFont string `json:"serif_font"`
163- MonospaceFont string `json:"monospace_font"`
164- DefaultFontSize int `json:"default_font_size"`
165- ColorScheme string `json:"color_scheme"`
166- LightPalette config.ColorPalette `json:"light_palette"`
167- DarkPalette config.ColorPalette `json:"dark_palette"`
168- } `json:"appearance"`
169- DefaultUIScale float64 `json:"default_ui_scale"`
170- DefaultSearchEngine string `json:"default_search_engine"`
171- SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
172- }{
173- DefaultUIScale: defaults.DefaultUIScale,
174- DefaultSearchEngine: defaults.DefaultSearchEngine,
175- SearchShortcuts: defaults.SearchShortcuts,
176- }
177- resp.Appearance.SansFont = defaults.Appearance.SansFont
178- resp.Appearance.SerifFont = defaults.Appearance.SerifFont
179- resp.Appearance.MonospaceFont = defaults.Appearance.MonospaceFont
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- }
136+ return buildConfigResponse(config.DefaultConfig())
137+ }))
138+}
139+
140+func buildConfigResponse(cfg *config.Config) *SchemeResponse {
141+ resp := configPayload{
142+ DefaultUIScale: cfg.DefaultUIScale,
143+ DefaultSearchEngine: cfg.DefaultSearchEngine,
144+ SearchShortcuts: cfg.SearchShortcuts,
145+ Appearance: configAppearancePayload{
146+ SansFont: cfg.Appearance.SansFont,
147+ SerifFont: cfg.Appearance.SerifFont,
148+ MonospaceFont: cfg.Appearance.MonospaceFont,
149+ DefaultFontSize: cfg.Appearance.DefaultFontSize,
150+ ColorScheme: cfg.Appearance.ColorScheme,
151+ LightPalette: cfg.Appearance.LightPalette,
152+ DarkPalette: cfg.Appearance.DarkPalette,
153+ },
154+ }
155+
156+ data, err := json.Marshal(resp)
157+ if err != nil {
193158 return &SchemeResponse{
194- Data: data,
159+ Data: []byte(fmt.Sprintf(`{"error": %q}`, err)),
195160 ContentType: "application/json",
196- StatusCode: http.StatusOK,
161+ StatusCode: http.StatusInternalServerError,
197162 }
198- }))
163+ }
164+
165+ return &SchemeResponse{
166+ Data: data,
167+ ContentType: "application/json",
168+ StatusCode: http.StatusOK,
169+ }
199170 }
200171
201172 // RegisterPage registers a handler for a specific path.
@@ -301,28 +272,28 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
301272
302273 var relPath string
303274 switch {
304- // dumb://home or dumb://home/ → index.html
275+ // Home root maps to index.html.
305276 case host == HomePath && (path == "" || path == "/"):
306277 relPath = IndexHTML
307- // dumb://home/<asset> → serve asset
278+ // Home asset paths map directly to assets.
308279 case host == HomePath && path != "":
309280 relPath = path
310- // dumb://config or dumb://config/ → config.html
281+ // Config root maps to config.html.
311282 case host == ConfigPath && (path == "" || path == "/"):
312283 relPath = "config.html"
313- // dumb://config/<asset> → serve asset
284+ // Config asset paths map directly to assets.
314285 case host == ConfigPath && path != "":
315286 relPath = path
316- // dumb://error or dumb://error/ → error.html
287+ // Error root maps to error.html.
317288 case host == ErrorPath && (path == "" || path == "/"):
318289 relPath = "error.html"
319- // dumb://error/<asset> → serve asset
290+ // Error asset paths map directly to assets.
320291 case host == ErrorPath && path != "":
321292 relPath = path
322- // dumb:home (opaque form) → index.html
293+ // Opaque home form maps to index.html.
323294 case u.Opaque == HomePath:
324295 relPath = IndexHTML
325- // dumb:error (opaque form) → error.html
296+ // Opaque error form maps to error.html.
326297 case u.Opaque == ErrorPath:
327298 relPath = "error.html"
328299 default:
@@ -354,6 +325,9 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
354325
355326 // getMimeType determines the MIME type for a given file path.
356327 func (h *DumbSchemeHandler) getMimeType(filename string) string {
328+ if h == nil {
329+ return "application/octet-stream"
330+ }
357331 ext := strings.ToLower(filepath.Ext(filename))
358332
359333 // Try standard mime type first
@@ -451,7 +425,7 @@ func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
451425 return
452426 }
453427
454- callback := webkit.URISchemeRequestCallback(func(reqPtr, userData uintptr) {
428+callback := webkit.URISchemeRequestCallback(func(reqPtr, _ uintptr) {
455429 h.HandleRequest(reqPtr)
456430 })
457431
M internal/infrastructure/webkit/settings.go +42 -26
@@ -7,6 +7,7 @@ import (
77 "github.com/bnema/dumber/internal/infrastructure/config"
88 "github.com/bnema/dumber/internal/logging"
99 "github.com/bnema/puregotk-webkit/webkit"
10+ "github.com/rs/zerolog"
1011 )
1112
1213 // SettingsManager creates and manages WebKit Settings instances from config.
@@ -44,12 +45,34 @@ func (sm *SettingsManager) CreateSettings(ctx context.Context) *webkit.Settings
4445
4546 // applySettings applies configuration to a webkit.Settings instance.
4647 func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.Settings, cfg *config.Config) {
48+ if sm == nil {
49+ return
50+ }
4751 log := logging.FromContext(ctx)
48- // JavaScript settings
52+ applyJavaScriptSettings(settings)
53+ applyFontSettings(settings, cfg)
54+ applyRenderingSettings(settings, cfg)
55+ applyDebugSettings(settings, cfg)
56+ applyBrowsingSettings(settings)
57+ applyMediaSettings(settings, cfg, log)
58+ applyStorageSettings(settings)
59+ applyUISettings(settings)
60+ applyCanvasSettings(settings)
61+ applyWebRTCSettings(settings)
62+
63+ log.Debug().
64+ Str("sans_font", cfg.Appearance.SansFont).
65+ Str("rendering_mode", string(cfg.RenderingMode)).
66+ Bool("developer_extras", cfg.Debug.EnableDevTools).
67+ Msg("settings applied")
68+}
69+
70+func applyJavaScriptSettings(settings *webkit.Settings) {
4971 settings.SetEnableJavascript(true)
5072 settings.SetEnableJavascriptMarkup(true)
73+}
5174
52- // Font settings from config
75+func applyFontSettings(settings *webkit.Settings, cfg *config.Config) {
5376 if cfg.Appearance.SansFont != "" {
5477 settings.SetDefaultFontFamily(cfg.Appearance.SansFont)
5578 settings.SetSansSerifFontFamily(cfg.Appearance.SansFont)
@@ -63,29 +86,31 @@ func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.S
6386 if cfg.Appearance.DefaultFontSize > 0 {
6487 settings.SetDefaultFontSize(uint32(cfg.Appearance.DefaultFontSize))
6588 }
89+}
6690
67- // Hardware acceleration based on rendering mode
91+func applyRenderingSettings(settings *webkit.Settings, cfg *config.Config) {
6892 switch cfg.RenderingMode {
6993 case config.RenderingModeGPU:
7094 settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlwaysValue)
7195 case config.RenderingModeCPU:
7296 settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyNeverValue)
7397 case config.RenderingModeAuto:
74- // Default to always for auto mode (WebKit handles capability detection)
7598 settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlwaysValue)
7699 }
100+}
77101
78- // Debug settings
102+func applyDebugSettings(settings *webkit.Settings, cfg *config.Config) {
79103 settings.SetEnableDeveloperExtras(cfg.Debug.EnableDevTools)
80104 settings.SetEnableWriteConsoleMessagesToStdout(cfg.Logging.CaptureConsole)
105+}
81106
82- // Browsing experience
107+func applyBrowsingSettings(settings *webkit.Settings) {
83108 settings.SetEnableSmoothScrolling(true)
84- // Note: DNS prefetching setting deprecated in WebKitGTK 6 - use NetworkSession.PrefetchDns() instead
85109 settings.SetEnablePageCache(true)
86110 settings.SetEnableSiteSpecificQuirks(true)
111+}
87112
88- // Media settings
113+func applyMediaSettings(settings *webkit.Settings, cfg *config.Config, log *zerolog.Logger) {
89114 settings.SetEnableWebaudio(true)
90115 settings.SetEnableWebgl(true)
91116 settings.SetEnableMedia(true)
@@ -96,46 +121,37 @@ func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.S
96121 settings.SetMediaPlaybackRequiresUserGesture(true)
97122 settings.SetMediaPlaybackAllowsInline(true)
98123
99- // Hardware decoding mode - THE FIX for Twitch Error #4000
100- // Default "auto" allows GStreamer to choose best decoder with software fallback
101124 switch cfg.Media.HardwareDecodingMode {
102125 case config.HardwareDecodingForce:
103- // Force hardware - will fail if unavailable (not recommended)
104- // Prioritize AV1 (most efficient codec) when user wants forced HW
105126 hwTypes := "video/av01;video/mp4;video/webm;video/x-h264;video/x-h265"
106127 settings.SetMediaContentTypesRequiringHardwareSupport(&hwTypes)
107128 log.Debug().Msg("hardware decoding: forced (may fail without hw support)")
108129 case config.HardwareDecodingDisable:
109- // Force software only
110130 settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyNeverValue)
111131 log.Debug().Msg("hardware decoding: disabled (software only)")
112- default: // HardwareDecodingAuto
113- // Empty string = let GStreamer choose best decoder with software fallback
114- // This fixes Twitch Error #4000 by allowing playback when HW unavailable
132+ default:
115133 emptyTypes := ""
116134 settings.SetMediaContentTypesRequiringHardwareSupport(&emptyTypes)
117135 log.Debug().Msg("hardware decoding: auto (hw preferred, software fallback)")
118136 }
137+}
119138
120- // HTML5 storage
139+func applyStorageSettings(settings *webkit.Settings) {
121140 settings.SetEnableHtml5LocalStorage(true)
122141 settings.SetEnableHtml5Database(true)
142+}
123143
124- // UI behavior - touchpad swipe gestures for back/forward navigation
144+func applyUISettings(settings *webkit.Settings) {
125145 settings.SetEnableBackForwardNavigationGestures(true)
126146 settings.SetEnableFullscreen(true)
147+}
127148
128- // Canvas acceleration
149+func applyCanvasSettings(settings *webkit.Settings) {
129150 settings.SetEnable2dCanvasAcceleration(true)
151+}
130152
131- // WebRTC
153+func applyWebRTCSettings(settings *webkit.Settings) {
132154 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")
139155 }
140156
141157 // 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
305305
306306 // connectSignals sets up signal handlers for the WebView.
307307 func (wv *WebView) connectSignals() {
308- // load-changed signal
308+ wv.connectLoadChangedSignal()
309+ wv.connectCloseSignal()
310+ wv.connectCreateSignal()
311+ wv.connectReadyToShowSignal()
312+ wv.connectTitleSignal()
313+ wv.connectURISignal()
314+ wv.connectFaviconSignal()
315+ wv.connectProgressSignal()
316+ wv.connectDecidePolicySignal()
317+}
318+
319+func (wv *WebView) connectLoadChangedSignal() {
309320 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
310321 uri := inner.GetUri()
311322 title := inner.GetTitle()
@@ -337,18 +348,20 @@ func (wv *WebView) connectSignals() {
337348 }
338349 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
339350 wv.signalIDs = append(wv.signalIDs, sigID)
351+}
340352
341- // close signal
342- closeCb := func(inner webkit.WebView) {
353+func (wv *WebView) connectCloseSignal() {
354+ closeCb := func(_ webkit.WebView) {
343355 if wv.OnClose != nil {
344356 wv.OnClose()
345357 }
346358 }
347- sigID = wv.inner.ConnectClose(&closeCb)
359+ sigID := wv.inner.ConnectClose(&closeCb)
348360 wv.signalIDs = append(wv.signalIDs, sigID)
361+}
349362
350- // create signal for popup window handling
351- createCb := func(inner webkit.WebView, navActionPtr uintptr) gtk.Widget {
363+func (wv *WebView) connectCreateSignal() {
364+ createCb := func(_ webkit.WebView, navActionPtr uintptr) gtk.Widget {
352365 if wv.OnCreate == nil {
353366 return gtk.Widget{} // Block popup
354367 }
@@ -378,20 +391,21 @@ func (wv *WebView) connectSignals() {
378391
379392 return newWV.inner.Widget
380393 }
381- sigID = wv.inner.ConnectCreate(&createCb)
394+ sigID := wv.inner.ConnectCreate(&createCb)
382395 wv.signalIDs = append(wv.signalIDs, sigID)
396+}
383397
384- // ready-to-show signal for popup display
385- readyToShowCb := func(inner webkit.WebView) {
398+func (wv *WebView) connectReadyToShowSignal() {
399+ readyToShowCb := func(_ webkit.WebView) {
386400 if wv.OnReadyToShow != nil {
387401 wv.OnReadyToShow()
388402 }
389403 }
390- sigID = wv.inner.ConnectReadyToShow(&readyToShowCb)
404+ sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)
391405 wv.signalIDs = append(wv.signalIDs, sigID)
406+}
392407
393- // notify::title signal for title changes
394- // This fires when the page title changes (e.g., after page load, SPA navigation)
408+func (wv *WebView) connectTitleSignal() {
395409 titleCb := func() {
396410 title := wv.inner.GetTitle()
397411 wv.mu.Lock()
@@ -402,11 +416,11 @@ func (wv *WebView) connectSignals() {
402416 wv.OnTitleChanged(title)
403417 }
404418 }
405- sigID = gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))
419+ sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))
406420 wv.signalIDs = append(wv.signalIDs, sigID)
421+}
407422
408- // notify::uri signal for URI changes
409- // This fires when the URI changes (e.g., redirects, SPA navigation via History API)
423+func (wv *WebView) connectURISignal() {
410424 uriCb := func() {
411425 uri := wv.inner.GetUri()
412426 wv.mu.Lock()
@@ -418,22 +432,22 @@ func (wv *WebView) connectSignals() {
418432 wv.OnURIChanged(uri)
419433 }
420434 }
421- sigID = gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb))
435+ sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb))
422436 wv.signalIDs = append(wv.signalIDs, sigID)
437+}
423438
424- // notify::favicon signal for favicon changes
425- // This fires when the page favicon changes (after page load or dynamic updates)
439+func (wv *WebView) connectFaviconSignal() {
426440 faviconCb := func() {
427441 favicon := wv.inner.GetFavicon()
428442 if wv.OnFaviconChanged != nil {
429443 wv.OnFaviconChanged(favicon)
430444 }
431445 }
432- sigID = gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb))
446+ sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb))
433447 wv.signalIDs = append(wv.signalIDs, sigID)
448+}
434449
435- // notify::estimated-load-progress signal for load progress updates
436- // This fires frequently during page load with values from 0.0 to 1.0
450+func (wv *WebView) connectProgressSignal() {
437451 progressCb := func() {
438452 progress := wv.inner.GetEstimatedLoadProgress()
439453 wv.mu.Lock()
@@ -444,17 +458,16 @@ func (wv *WebView) connectSignals() {
444458 wv.OnProgressChanged(progress)
445459 }
446460 }
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))
448462 wv.signalIDs = append(wv.signalIDs, sigID)
463+}
449464
450- // decide-policy signal for intercepting middle-click and Ctrl+click on links
451- decidePolicyCb := func(inner webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool {
452- // Only handle navigation actions (not new window or response policies)
465+func (wv *WebView) connectDecidePolicySignal() {
466+ decidePolicyCb := func(_ webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool {
453467 if decisionType != webkit.PolicyDecisionTypeNavigationActionValue {
454468 return false // Let WebKit handle
455469 }
456470
457- // Cast to NavigationPolicyDecision
458471 navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
459472 if navDecision == nil {
460473 return false
@@ -465,24 +478,19 @@ func (wv *WebView) connectSignals() {
465478 return false
466479 }
467480
468- // Only handle link clicks
469481 if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
470482 return false
471483 }
472484
473- // Get mouse button and modifiers
474485 mouseButton := navAction.GetMouseButton()
475486 modifiers := navAction.GetModifiers()
476-
477- // Check for middle-click (button 2) or Ctrl+left-click (button 1 + Ctrl)
478487 isMiddleClick := mouseButton == 2
479488 isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0
480489
481490 if !isMiddleClick && !isCtrlClick {
482- return false // Normal click, let WebKit handle
491+ return false
483492 }
484493
485- // Get the link URL
486494 request := navAction.GetRequest()
487495 if request == nil {
488496 return false
@@ -499,18 +507,16 @@ func (wv *WebView) connectSignals() {
499507 Bool("ctrl", isCtrlClick).
500508 Msg("middle-click/ctrl+click on link detected")
501509
502- // Call the handler
503510 if wv.OnLinkMiddleClick != nil {
504511 if wv.OnLinkMiddleClick(linkURI) {
505- // Handler processed it, block the navigation
506512 navDecision.Ignore()
507513 return true
508514 }
509515 }
510516
511- return false // Let WebKit handle if not handled
517+ return false
512518 }
513- sigID = wv.inner.ConnectDecidePolicy(&decidePolicyCb)
519+ sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
514520 wv.signalIDs = append(wv.signalIDs, sigID)
515521 }
516522
@@ -566,6 +572,7 @@ func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error
566572 baseURIPtr = &baseURI
567573 }
568574 wv.inner.LoadHtml(content, baseURIPtr)
575+ logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("loading HTML content")
569576 return nil
570577 }
571578
@@ -575,6 +582,7 @@ func (wv *WebView) Reload(ctx context.Context) error {
575582 return fmt.Errorf("webview %d is destroyed", wv.id)
576583 }
577584 wv.inner.Reload()
585+ logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("reloading webview")
578586 return nil
579587 }
580588
@@ -584,6 +592,7 @@ func (wv *WebView) ReloadBypassCache(ctx context.Context) error {
584592 return fmt.Errorf("webview %d is destroyed", wv.id)
585593 }
586594 wv.inner.ReloadBypassCache()
595+ logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("reloading webview bypassing cache")
587596 return nil
588597 }
589598
@@ -593,6 +602,7 @@ func (wv *WebView) Stop(ctx context.Context) error {
593602 return fmt.Errorf("webview %d is destroyed", wv.id)
594603 }
595604 wv.inner.StopLoading()
605+ logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("stopping webview load")
596606 return nil
597607 }
598608
@@ -605,6 +615,7 @@ func (wv *WebView) GoBack(ctx context.Context) error {
605615 return fmt.Errorf("cannot go back")
606616 }
607617 wv.inner.GoBack()
618+ logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go back")
608619 return nil
609620 }
610621
@@ -617,6 +628,7 @@ func (wv *WebView) GoForward(ctx context.Context) error {
617628 return fmt.Errorf("cannot go forward")
618629 }
619630 wv.inner.GoForward()
631+ logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("webview go forward")
620632 return nil
621633 }
622634
@@ -677,6 +689,7 @@ func (wv *WebView) SetZoomLevel(ctx context.Context, level float64) error {
677689 return fmt.Errorf("webview %d is destroyed", wv.id)
678690 }
679691 wv.inner.SetZoomLevel(level)
692+ logging.FromContext(ctx).Debug().Float64("level", level).Int("webview_id", int(wv.id)).Msg("set webview zoom level")
680693 return nil
681694 }
682695
M internal/logging/logger.go +25 -15
@@ -9,6 +9,12 @@ import (
99 "github.com/rs/zerolog"
1010 )
1111
12+const (
13+ logFormatConsole = "console"
14+ logFormatJSON = "json"
15+ logFormatText = "text"
16+)
17+
1218 // Config holds logging configuration
1319 type Config struct {
1420 Level zerolog.Level
@@ -28,7 +34,7 @@ type FileConfig struct {
2834 func DefaultConfig() Config {
2935 return Config{
3036 Level: zerolog.InfoLevel,
31- Format: "console",
37+ Format: logFormatConsole,
3238 TimeFormat: time.RFC3339,
3339 }
3440 }
@@ -38,12 +44,12 @@ func New(cfg Config) zerolog.Logger {
3844 var output io.Writer = os.Stderr
3945
4046 switch cfg.Format {
41- case "console":
47+ case logFormatConsole:
4248 output = zerolog.ConsoleWriter{
4349 Out: os.Stderr,
4450 TimeFormat: cfg.TimeFormat,
4551 }
46- case "json":
52+ case logFormatJSON:
4753 // JSON is the default zerolog format
4854 output = os.Stderr
4955 }
@@ -60,18 +66,22 @@ func New(cfg Config) zerolog.Logger {
6066 // LogDir must exist before calling this function (handled by config.EnsureDirectories).
6167 // Returns the logger and a cleanup function to close the file.
6268 func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) {
69+ const (
70+ logDirPerm = 0o755
71+ logFilePerm = 0o644
72+ )
6373 var writers []io.Writer
64- var cleanup func() = func() {}
74+ cleanup := func() {}
6575
6676 // Stderr output (only if enabled)
6777 if fileCfg.WriteToStderr {
6878 switch cfg.Format {
69- case "console", "text", "":
79+ case logFormatConsole, logFormatText, "":
7080 writers = append(writers, zerolog.ConsoleWriter{
7181 Out: os.Stderr,
7282 TimeFormat: cfg.TimeFormat,
7383 })
74- case "json":
84+ case logFormatJSON:
7585 writers = append(writers, os.Stderr)
7686 default:
7787 writers = append(writers, zerolog.ConsoleWriter{
@@ -84,18 +94,18 @@ func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error)
8494 // Session file output (JSON format for parsing)
8595 if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" {
8696 // 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 {
8898 return zerolog.Logger{}, nil, err
8999 }
90100
91101 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)
93103 if err != nil {
94104 return zerolog.Logger{}, nil, err
95105 }
96106
97107 writers = append(writers, file)
98- cleanup = func() { file.Close() }
108+ cleanup = func() { _ = file.Close() }
99109 }
100110
101111 // Fallback to discard if no writers configured
@@ -129,7 +139,7 @@ func NewFromEnv() zerolog.Logger {
129139
130140 if format := os.Getenv("DUMBER_LOG_FORMAT"); format != "" {
131141 switch format {
132- case "json", "console", "text":
142+ case logFormatJSON, logFormatConsole, logFormatText:
133143 cfg.Format = format
134144 }
135145 }
@@ -145,12 +155,12 @@ func NewFromConfigValues(level, format string) zerolog.Logger {
145155 cfg.Level = ParseLevel(level)
146156
147157 switch format {
148- case "json":
149- cfg.Format = "json"
150- case "console", "text", "":
151- cfg.Format = "console"
158+ case logFormatJSON:
159+ cfg.Format = logFormatJSON
160+ case logFormatConsole, logFormatText, "":
161+ cfg.Format = logFormatConsole
152162 default:
153- cfg.Format = "console"
163+ cfg.Format = logFormatConsole
154164 }
155165
156166 return New(cfg)
M internal/ui/app.go +17 -8
@@ -229,6 +229,13 @@ func (a *App) onActivate(ctx context.Context) {
229229 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
230230 a.handleModeChange(ctx, from, to)
231231 })
232+ a.keyboardHandler.SetShouldBypassInput(func() bool {
233+ wsView := a.activeWorkspaceView()
234+ if wsView == nil {
235+ return false
236+ }
237+ return wsView.IsOmniboxVisible()
238+ })
232239 a.keyboardHandler.AttachTo(a.mainWindow.Window())
233240
234241 // Store omnibox config (omnibox is created per-pane via WorkspaceView)
@@ -242,7 +249,9 @@ func (a *App) onActivate(ctx context.Context) {
242249 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
243250 UIScale: a.deps.Config.DefaultUIScale,
244251 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+ }
246255 },
247256 }
248257 a.navCoord.SetOmniboxProvider(a)
@@ -355,7 +364,9 @@ func (a *App) initCoordinators(ctx context.Context) {
355364 // Wire tab bar click handling to coordinator
356365 if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
357366 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+ }
359370 })
360371 }
361372
@@ -751,11 +762,10 @@ func (a *App) initConfigWatcher(ctx context.Context) {
751762 // Only appearance is hot-reloaded for now.
752763 a.configManager.OnConfigChange(func(newCfg *config.Config) {
753764 cfgCopy := newCfg
754- var cb glib.SourceFunc
755- cb = func(_ uintptr) bool {
765+ cb := glib.SourceFunc(func(_ uintptr) bool {
756766 a.applyAppearanceConfig(ctx, cfgCopy)
757767 return false
758- }
768+ })
759769 glib.IdleAdd(&cb, 0)
760770 })
761771
@@ -827,11 +837,10 @@ func (a *App) initFilteringAsync(ctx context.Context) {
827837
828838 a.deps.FilterManager.SetStatusCallback(func(status filtering.FilterStatus) {
829839 statusCopy := status // Capture for closure
830- var cb glib.SourceFunc
831- cb = func(_ uintptr) bool {
840+ cb := glib.SourceFunc(func(_ uintptr) bool {
832841 a.showFilterStatus(ctx, statusCopy)
833842 return false // Don't repeat
834- }
843+ })
835844 glib.IdleAdd(&cb, 0)
836845 })
837846 a.deps.FilterManager.LoadAsync(ctx)
M internal/ui/component/find_bar.go +65 -29
@@ -149,6 +149,27 @@ func (fb *FindBar) FindPrevious() {
149149 }
150150
151151 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 {
152173 fb.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
153174 if fb.outerBox == nil {
154175 return errNilWidget("findOuterBox")
@@ -165,7 +186,10 @@ func (fb *FindBar) createWidgets() error {
165186 return errNilWidget("findContainerBox")
166187 }
167188 fb.containerBox.AddCssClass("find-bar-container")
189+ return nil
190+}
168191
192+func (fb *FindBar) initInputRowWidgets() error {
169193 fb.inputRow = gtk.NewBox(gtk.OrientationHorizontalValue, findBarRowSpacing)
170194 if fb.inputRow == nil {
171195 return errNilWidget("findInputRow")
@@ -206,7 +230,10 @@ func (fb *FindBar) createWidgets() error {
206230 return errNilWidget("findCloseBtn")
207231 }
208232 fb.closeBtn.AddCssClass("find-bar-close")
233+ return nil
234+}
209235
236+func (fb *FindBar) initOptionsRowWidgets() error {
210237 fb.optionsRow = gtk.NewBox(gtk.OrientationHorizontalValue, findBarRowSpacing)
211238 if fb.optionsRow == nil {
212239 return errNilWidget("findOptionsRow")
@@ -231,7 +258,10 @@ func (fb *FindBar) createWidgets() error {
231258 }
232259 fb.hlToggle.AddCssClass("find-bar-toggle")
233260 fb.hlToggle.SetActive(true)
261+ return nil
262+}
234263
264+func (fb *FindBar) assembleWidgets() {
235265 fb.inputRow.Append(&fb.entry.Widget)
236266 fb.inputRow.Append(&fb.prevBtn.Widget)
237267 fb.inputRow.Append(&fb.nextBtn.Widget)
@@ -245,20 +275,21 @@ func (fb *FindBar) createWidgets() error {
245275 fb.containerBox.Append(&fb.inputRow.Widget)
246276 fb.containerBox.Append(&fb.optionsRow.Widget)
247277 fb.outerBox.Append(&fb.containerBox.Widget)
248-
249- return nil
250278 }
251279
252-func (fb *FindBar) setupHandlers() {
253- if fb.entry != nil {
254- changedCb := func(_ gtk.SearchEntry) {
255- if fb.uc != nil {
256- fb.uc.SetQuery(fb.entry.GetText())
257- }
280+func (fb *FindBar) connectEntryHandlers() {
281+ if fb.entry == nil {
282+ return
283+ }
284+ changedCb := func(_ gtk.SearchEntry) {
285+ if fb.uc != nil {
286+ fb.uc.SetQuery(fb.entry.GetText())
258287 }
259- fb.entry.ConnectSearchChanged(&changedCb)
260288 }
289+ fb.entry.ConnectSearchChanged(&changedCb)
290+}
261291
292+func (fb *FindBar) connectNavHandlers() {
262293 if fb.prevBtn != nil {
263294 prevCb := func(_ gtk.Button) {
264295 fb.FindPrevious()
@@ -282,7 +313,9 @@ func (fb *FindBar) setupHandlers() {
282313 }
283314 fb.closeBtn.ConnectClicked(&closeCb)
284315 }
316+}
285317
318+func (fb *FindBar) connectToggleHandlers() {
286319 if fb.caseToggle != nil {
287320 caseCb := func(_ gtk.ToggleButton) {
288321 if fb.uc != nil {
@@ -309,31 +342,34 @@ func (fb *FindBar) setupHandlers() {
309342 }
310343 fb.hlToggle.ConnectToggled(&hlCb)
311344 }
345+}
312346
347+func (fb *FindBar) attachKeyController() {
313348 controller := gtk.NewEventControllerKey()
314- if controller != nil {
315- controller.SetPropagationPhase(gtk.PhaseCaptureValue)
316- keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
317- switch keyval {
318- case uint(gdk.KEY_Escape):
319- fb.Hide()
320- if fb.onClose != nil {
321- fb.onClose()
322- }
323- return true
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
349+ if controller == nil {
350+ return
351+ }
352+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
353+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
354+ switch keyval {
355+ case uint(gdk.KEY_Escape):
356+ fb.Hide()
357+ if fb.onClose != nil {
358+ fb.onClose()
331359 }
332- return false
360+ return true
361+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
362+ if state&gdk.ShiftMaskValue != 0 {
363+ fb.FindPrevious()
364+ } else {
365+ fb.FindNext()
366+ }
367+ return true
333368 }
334- controller.ConnectKeyPressed(&keyPressedCb)
335- fb.outerBox.AddController(&controller.EventController)
369+ return false
336370 }
371+ controller.ConnectKeyPressed(&keyPressedCb)
372+ fb.outerBox.AddController(&controller.EventController)
337373 }
338374
339375 func (fb *FindBar) bindUseCase() {
M internal/ui/component/omnibox.go +104 -80
@@ -24,6 +24,7 @@ const (
2424 omniboxMaxWidth = 800 // Maximum width in pixels
2525 omniboxHeightPct = 0.6 // 60% of parent window height
2626 omniboxMaxResults = 10
27+ omniboxTopMarginPct = 0.2 // 20% from top
2728 debounceDelayMs = 150
2829 )
2930
@@ -236,7 +237,8 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
236237 rowHeight = o.measuredHeights.singleRow
237238 } else {
238239 // Fallback to hardcoded estimate (before first measurement)
239- rowHeight = int(60 * o.uiScale)
240+ const fallbackRowHeight = 60
241+ rowHeight = int(fallbackRowHeight * o.uiScale)
240242 }
241243
242244 // Calculate content height for the scrolled window
@@ -275,7 +277,46 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
275277
276278 // createWidgets builds the GTK widget hierarchy.
277279 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 {
279320 o.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
280321 if o.outerBox == nil {
281322 return errNilWidget("outerBox")
@@ -284,15 +325,19 @@ func (o *Omnibox) createWidgets() error {
284325 o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally
285326 o.outerBox.SetValign(gtk.AlignStartValue) // Align to top
286327 o.outerBox.SetVisible(false) // Hidden by default
328+ return nil
329+}
287330
288- // Main content box
331+func (o *Omnibox) initMainBox() error {
289332 o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
290333 if o.mainBox == nil {
291334 return errNilWidget("mainBox")
292335 }
293336 o.mainBox.AddCssClass("omnibox-container")
337+ return nil
338+}
294339
295- // Header with History/Favorites toggle
340+func (o *Omnibox) initHeader() error {
296341 o.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
297342 if o.headerBox == nil {
298343 return errNilWidget("headerBox")
@@ -315,18 +360,16 @@ func (o *Omnibox) createWidgets() error {
315360 o.favoritesBtn.AddCssClass("omnibox-header-btn")
316361 o.favoritesBtn.SetCanFocus(false)
317362
318- // Connect header button clicks
319- historyClickCb := func(btn gtk.Button) {
363+ historyClickCb := func(_ gtk.Button) {
320364 o.setViewMode(ViewModeHistory)
321365 }
322366 o.historyBtn.ConnectClicked(&historyClickCb)
323367
324- favoritesClickCb := func(btn gtk.Button) {
368+ favoritesClickCb := func(_ gtk.Button) {
325369 o.setViewMode(ViewModeFavorites)
326370 }
327371 o.favoritesBtn.ConnectClicked(&favoritesClickCb)
328372
329- // Zoom indicator label (shown when zoom != 100%)
330373 o.zoomLabel = gtk.NewLabel(nil)
331374 if o.zoomLabel != nil {
332375 o.zoomLabel.AddCssClass("omnibox-zoom-indicator")
@@ -334,14 +377,10 @@ func (o *Omnibox) createWidgets() error {
334377 o.zoomLabel.SetHalign(gtk.AlignEndValue)
335378 o.zoomLabel.SetHexpand(true)
336379 }
380+ return nil
381+}
337382
338- o.headerBox.Append(&o.historyBtn.Widget)
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
383+func (o *Omnibox) initEntry() error {
345384 o.entry = gtk.NewSearchEntry()
346385 if o.entry == nil {
347386 return errNilWidget("entry")
@@ -351,8 +390,10 @@ func (o *Omnibox) createWidgets() error {
351390
352391 placeholder := "Search history or enter URL..."
353392 o.entry.SetPlaceholderText(&placeholder)
393+ return nil
394+}
354395
355- // Scrolled window for list
396+func (o *Omnibox) initList() error {
356397 o.scrolledWin = gtk.NewScrolledWindow()
357398 if o.scrolledWin == nil {
358399 return errNilWidget("scrolledWin")
@@ -360,10 +401,8 @@ func (o *Omnibox) createWidgets() error {
360401 o.scrolledWin.AddCssClass("omnibox-scrolled")
361402 o.scrolledWin.SetVexpand(true)
362403 o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
363- // Let GTK handle natural sizing - propagate child height up to max
364404 o.scrolledWin.SetPropagateNaturalHeight(true)
365405
366- // List box for suggestions
367406 o.listBox = gtk.NewListBox()
368407 if o.listBox == nil {
369408 return errNilWidget("listBox")
@@ -371,8 +410,7 @@ func (o *Omnibox) createWidgets() error {
371410 o.listBox.AddCssClass("omnibox-listbox")
372411 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
373412
374- // Connect row selection
375- rowSelectedCb := func(lb gtk.ListBox, rowPtr uintptr) {
413+ rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
376414 if rowPtr == 0 {
377415 o.mu.Lock()
378416 o.selectedIndex = -1
@@ -387,35 +425,21 @@ func (o *Omnibox) createWidgets() error {
387425 }
388426 }
389427 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+ }
390437
391- // Assemble hierarchy
392438 o.scrolledWin.SetChild(&o.listBox.Widget)
393439 o.mainBox.Append(&o.headerBox.Widget)
394440 o.mainBox.Append(&o.entry.Widget)
395441 o.mainBox.Append(&o.scrolledWin.Widget)
396442 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)
419443 }
420444
421445 // setupEntryChanged wires entry text changes to debounced search.
@@ -484,14 +508,16 @@ func (o *Omnibox) handleKeyPress(keyval uint, state gdk.ModifierType) bool {
484508 case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
485509 uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
486510 if ctrl {
487- index := int(keyval - uint(gdk.KEY_1)) // 0-8
511+ const firstShortcutKey = uint(gdk.KEY_1)
512+ index := int(keyval - firstShortcutKey) // 0-8
488513 o.selectAndNavigate(index)
489514 return true
490515 }
491516
492517 case uint(gdk.KEY_0):
493518 if ctrl {
494- o.selectAndNavigate(9) // 10th item
519+ const tenthItemIndex = 9
520+ o.selectAndNavigate(tenthItemIndex)
495521 return true
496522 }
497523 }
@@ -597,11 +623,8 @@ func (o *Omnibox) loadInitialHistory() {
597623 o.idleAddUpdateSuggestions(nil)
598624 return
599625
600- case "most_visited":
626+ case "most_visited", "recent", "":
601627 // TODO: Implement GetMostVisited in use case if needed
602- fallthrough
603-
604- case "recent", "":
605628 results, err := o.historyUC.GetRecent(ctx, omniboxMaxResults, 0)
606629 if err != nil {
607630 log.Error().Err(err).Msg("failed to load recent history")
@@ -759,7 +782,7 @@ func (o *Omnibox) rebuildList() {
759782 }
760783
761784 // 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 {
763786 favicon := gtk.NewImage()
764787 if favicon == nil {
765788 return nil
@@ -769,8 +792,8 @@ func (o *Omnibox) createFaviconImage(url, fallbackIcon string) *gtk.Image {
769792 favicon.AddCssClass("omnibox-favicon")
770793
771794 // Async load favicon from cache
772- if o.faviconAdapter != nil && url != "" {
773- o.faviconAdapter.GetOrFetch(o.ctx, url, func(texture *gdk.Texture) {
795+ if o.faviconAdapter != nil && rawURL != "" {
796+ o.faviconAdapter.GetOrFetch(o.ctx, rawURL, func(texture *gdk.Texture) {
774797 if texture != nil {
775798 var cb glib.SourceFunc = func(data uintptr) bool {
776799 favicon.SetFromPaintable(texture)
@@ -785,21 +808,22 @@ func (o *Omnibox) createFaviconImage(url, fallbackIcon string) *gtk.Image {
785808 }
786809
787810 // 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 {
789812 row := gtk.NewListBoxRow()
790813 if row == nil {
791814 return nil
792815 }
793816 row.AddCssClass("omnibox-row")
794817
795- hbox := gtk.NewBox(gtk.OrientationHorizontalValue, 8)
818+ const rowSpacing = 8
819+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
796820 if hbox == nil {
797821 return nil
798822 }
799823 hbox.SetHexpand(true)
800824
801825 // Favicon image (vertically centered)
802- if favicon := o.createFaviconImage(url, fallbackIcon); favicon != nil {
826+ if favicon := o.createFaviconImage(rawURL, fallbackIcon); favicon != nil {
803827 favicon.SetValign(gtk.AlignCenterValue)
804828 hbox.Append(&favicon.Widget)
805829 }
@@ -815,7 +839,7 @@ func (o *Omnibox) createRowWithFavicon(url, title, fallbackIcon string, index in
815839 // Title label (or URL if no title)
816840 displayTitle := title
817841 if displayTitle == "" {
818- displayTitle = url
842+ displayTitle = rawURL
819843 }
820844 titleLabel := gtk.NewLabel(nil)
821845 if titleLabel != nil {
@@ -827,10 +851,10 @@ func (o *Omnibox) createRowWithFavicon(url, title, fallbackIcon string, index in
827851 }
828852
829853 // URL label (only if title exists and differs from URL)
830- if title != "" && title != url {
854+ if title != "" && title != rawURL {
831855 urlLabel := gtk.NewLabel(nil)
832856 if urlLabel != nil {
833- urlLabel.SetText(url)
857+ urlLabel.SetText(rawURL)
834858 urlLabel.AddCssClass("omnibox-suggestion-url")
835859 urlLabel.SetHalign(gtk.AlignStartValue)
836860 urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
@@ -841,10 +865,11 @@ func (o *Omnibox) createRowWithFavicon(url, title, fallbackIcon string, index in
841865 hbox.Append(&textBox.Widget)
842866
843867 // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
844- if index <= 9 {
868+ const maxShortcutIndex = 9
869+ if index <= maxShortcutIndex {
845870 shortcutLabel := gtk.NewLabel(nil)
846871 if shortcutLabel != nil {
847- if index < 9 {
872+ if index < maxShortcutIndex {
848873 shortcutLabel.SetText(formatShortcut(index + 1))
849874 } else {
850875 shortcutLabel.SetText("Ctrl+0")
@@ -947,33 +972,33 @@ func (o *Omnibox) navigateToSelected() {
947972 o.mu.RUnlock()
948973
949974 entryText := o.entry.GetText()
950- var url string
975+ var targetURL string
951976
952977 // Check if user typed a URL-like string (contains . and no spaces)
953978 // If so, navigate to that URL directly instead of the selection
954979 if o.looksLikeURL(entryText) {
955- url = o.buildURL(entryText)
980+ targetURL = o.buildURL(entryText)
956981 } else if idx < 0 {
957982 // No selection - use entry text as URL/search
958- url = o.buildURL(entryText)
983+ targetURL = o.buildURL(entryText)
959984 } else if mode == ViewModeHistory {
960985 if idx < len(suggestions) {
961- url = suggestions[idx].URL
986+ targetURL = suggestions[idx].URL
962987 }
963988 } else {
964989 if idx < len(favorites) {
965- url = favorites[idx].URL
990+ targetURL = favorites[idx].URL
966991 }
967992 }
968993
969- if url == "" {
994+ if targetURL == "" {
970995 return
971996 }
972997
973998 o.Hide(o.ctx)
974999
9751000 if o.onNavigate != nil {
976- o.onNavigate(url)
1001+ o.onNavigate(targetURL)
9771002 }
9781003 }
9791004
@@ -1016,9 +1041,9 @@ func (o *Omnibox) toggleFavorite() {
10161041
10171042 go func() {
10181043 ctx := o.ctx
1019- log := logging.FromContext(ctx)
1044+ goLog := logging.FromContext(ctx)
10201045
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")
10221047
10231048 input := usecase.AddFavoriteInput{
10241049 URL: s.URL,
@@ -1027,11 +1052,11 @@ func (o *Omnibox) toggleFavorite() {
10271052
10281053 fav, err := o.favoritesUC.Add(ctx, input)
10291054 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")
10311056 return
10321057 }
10331058
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")
10351060 }()
10361061 } else {
10371062 // Remove from favorites
@@ -1082,39 +1107,38 @@ func (o *Omnibox) yankSelectedURL() {
10821107 favorites := o.favorites
10831108 o.mu.RUnlock()
10841109
1085- var url string
1110+ var selectedURL string
10861111 if mode == ViewModeHistory {
10871112 if idx < 0 || idx >= len(suggestions) {
10881113 log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
10891114 return
10901115 }
1091- url = suggestions[idx].URL
1116+ selectedURL = suggestions[idx].URL
10921117 } else {
10931118 if idx < 0 || idx >= len(favorites) {
10941119 log.Debug().Int("index", idx).Msg("yank URL: invalid selection")
10951120 return
10961121 }
1097- url = favorites[idx].URL
1122+ selectedURL = favorites[idx].URL
10981123 }
10991124
1100- if url == "" {
1125+ if selectedURL == "" {
11011126 log.Debug().Msg("yank URL: empty URL")
11021127 return
11031128 }
11041129
11051130 go func() {
11061131 ctx := o.ctx
1107- if err := o.copyURLUC.Copy(ctx, url); err != nil {
1132+ if err := o.copyURLUC.Copy(ctx, selectedURL); err != nil {
11081133 return // Use case already logs the error
11091134 }
11101135
11111136 // Show toast notification on success (must run on GTK main thread)
11121137 if o.onToast != nil {
1113- var cb glib.SourceFunc
1114- cb = func(_ uintptr) bool {
1138+ cb := glib.SourceFunc(func(_ uintptr) bool {
11151139 o.onToast("URL copied")
11161140 return false // Don't repeat
1117- }
1141+ })
11181142 glib.IdleAdd(&cb, 0)
11191143 }
11201144 }()
@@ -1201,7 +1225,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
12011225 if width > omniboxMaxWidth {
12021226 width = omniboxMaxWidth
12031227 }
1204- marginTop := int(float64(parentHeight) * 0.20) // 20% from top
1228+ marginTop := int(float64(parentHeight) * omniboxTopMarginPct)
12051229
12061230 o.mainBox.SetSizeRequest(width, -1)
12071231 o.outerBox.SetMarginTop(marginTop)
M internal/ui/component/progress_bar.go +2 -3
@@ -94,8 +94,7 @@ func (pb *ProgressBar) SetProgress(progress float64) {
9494 // startAnimation begins the smooth progress animation.
9595 // Must be called with lock held.
9696 func (pb *ProgressBar) startAnimation() {
97- var cb glib.SourceFunc
98- cb = func(_ uintptr) bool {
97+ cb := glib.SourceFunc(func(_ uintptr) bool {
9998 pb.mu.Lock()
10099 defer pb.mu.Unlock()
101100
@@ -119,7 +118,7 @@ func (pb *ProgressBar) startAnimation() {
119118 return false
120119 }
121120 return true
122- }
121+ })
123122
124123 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)
125124 }
M internal/ui/component/tab_button.go +2 -1
@@ -46,8 +46,9 @@ func NewTabButton(tab *entity.Tab) *TabButton {
4646 }
4747
4848 // Configure label for text overflow
49+ const maxTabTitleChars = 20
4950 tb.label.SetEllipsize(pango.EllipsizeMiddleValue)
50- tb.label.SetMaxWidthChars(20)
51+ tb.label.SetMaxWidthChars(maxTabTitleChars)
5152 tb.label.AddCssClass("tab-title")
5253
5354 // Set label as button child
M internal/ui/component/toaster.go +2 -3
@@ -176,8 +176,7 @@ func (t *Toaster) hide() {
176176 func (t *Toaster) startDismissTimer(ctx context.Context) {
177177 log := logging.FromContext(ctx)
178178
179- var cb glib.SourceFunc
180- cb = func(_ uintptr) bool {
179+ cb := glib.SourceFunc(func(_ uintptr) bool {
181180 t.mu.Lock()
182181 defer t.mu.Unlock()
183182
@@ -187,7 +186,7 @@ func (t *Toaster) startDismissTimer(ctx context.Context) {
187186
188187 log.Debug().Msg("toast auto-dismissed")
189188 return false // Don't repeat
190- }
189+ })
191190
192191 t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0)
193192 }
M internal/ui/component/workspace_view.go +4 -1
@@ -4,6 +4,7 @@ package component
44 import (
55 "context"
66 "errors"
7+ "fmt"
78 "sync"
89
910 "github.com/bnema/dumber/internal/domain/entity"
@@ -167,7 +168,9 @@ func (wv *WorkspaceView) SetWorkspace(ctx context.Context, ws *entity.Workspace)
167168
168169 // Set initial active pane
169170 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+ }
171174 }
172175
173176 return nil
M internal/ui/component/workspace_view_test.go +12 -12
@@ -14,7 +14,7 @@ import (
1414 "github.com/bnema/dumber/internal/ui/layout/mocks"
1515 )
1616
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) {
1818 ctx := context.Background()
1919 mockContainer := mocks.NewMockBoxWidget(t)
2020 mockOverlay := mocks.NewMockOverlayWidget(t)
@@ -30,7 +30,7 @@ func setupWorkspaceViewBase(t *testing.T, mockFactory *mocks.MockWidgetFactory)
3030 mockOverlay.EXPECT().SetChild(mockContainer).Once()
3131 mockOverlay.EXPECT().SetVisible(true).Once()
3232
33- return ctx, mockContainer, mockOverlay
33+ return ctx, mockContainer
3434 }
3535
3636 func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFactory) (*mocks.MockOverlayWidget, *mocks.MockBoxWidget) {
@@ -76,7 +76,7 @@ func setupStackedLeafMocks(
7676 t *testing.T,
7777 mockFactory *mocks.MockWidgetFactory,
7878 container *mocks.MockOverlayWidget,
79-) (*mocks.MockBoxWidget, *mocks.MockBoxWidget) {
79+) *mocks.MockBoxWidget {
8080 mockStackBox := mocks.NewMockBoxWidget(t)
8181 mockTitleBar := mocks.NewMockBoxWidget(t)
8282 mockFavicon := mocks.NewMockImageWidget(t)
@@ -118,13 +118,13 @@ func setupStackedLeafMocks(
118118 container.EXPECT().SetVisible(true).Once()
119119 mockTitleBar.EXPECT().AddCssClass("active").Once()
120120
121- return mockStackBox, mockTitleBar
121+ return mockStackBox
122122 }
123123
124124 func TestNewWorkspaceView_CreatesEmptyContainer(t *testing.T) {
125125 // Arrange
126126 mockFactory := mocks.NewMockWidgetFactory(t)
127- ctx, mockBox, _ := setupWorkspaceViewBase(t, mockFactory)
127+ ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
128128
129129 // Act
130130 wv := component.NewWorkspaceView(ctx, mockFactory)
@@ -138,7 +138,7 @@ func TestNewWorkspaceView_CreatesEmptyContainer(t *testing.T) {
138138 func TestSetWorkspace_NilWorkspace_ReturnsError(t *testing.T) {
139139 // Arrange
140140 mockFactory := mocks.NewMockWidgetFactory(t)
141- ctx, _, _ := setupWorkspaceViewBase(t, mockFactory)
141+ ctx, _ := setupWorkspaceViewBase(t, mockFactory)
142142
143143 wv := component.NewWorkspaceView(ctx, mockFactory)
144144
@@ -152,12 +152,12 @@ func TestSetWorkspace_NilWorkspace_ReturnsError(t *testing.T) {
152152 func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
153153 // Arrange
154154 mockFactory := mocks.NewMockWidgetFactory(t)
155- ctx, mockBox, _ := setupWorkspaceViewBase(t, mockFactory)
155+ ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
156156
157157 wv := component.NewWorkspaceView(ctx, mockFactory)
158158
159159 mockOverlay, mockBorderBox := setupWorkspacePaneViewMocks(t, mockFactory)
160- mockStackBox, _ := setupStackedLeafMocks(t, mockFactory, mockOverlay)
160+ mockStackBox := setupStackedLeafMocks(t, mockFactory, mockOverlay)
161161
162162 mockStackBox.EXPECT().SetVisible(true).Once()
163163 mockBox.EXPECT().Append(mockStackBox).Once()
@@ -185,7 +185,7 @@ func TestSetWorkspace_SinglePane_CreatesPaneView(t *testing.T) {
185185 func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
186186 // Arrange
187187 mockFactory := mocks.NewMockWidgetFactory(t)
188- ctx, mockBox, _ := setupWorkspaceViewBase(t, mockFactory)
188+ ctx, mockBox := setupWorkspaceViewBase(t, mockFactory)
189189
190190 wv := component.NewWorkspaceView(ctx, mockFactory)
191191
@@ -198,9 +198,9 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
198198 // Create mock widgets for split
199199 mockPaned := mocks.NewMockPanedWidget(t)
200200 mockOverlay1, mockBorderBox1 := setupWorkspacePaneViewMocks(t, mockFactory)
201- mockStackBox1, _ := setupStackedLeafMocks(t, mockFactory, mockOverlay1)
201+ mockStackBox1 := setupStackedLeafMocks(t, mockFactory, mockOverlay1)
202202 mockOverlay2, mockBorderBox2 := setupWorkspacePaneViewMocks(t, mockFactory)
203- mockStackBox2, _ := setupStackedLeafMocks(t, mockFactory, mockOverlay2)
203+ mockStackBox2 := setupStackedLeafMocks(t, mockFactory, mockOverlay2)
204204
205205 // Split view creation
206206 mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once()
@@ -251,7 +251,7 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
251251 func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) {
252252 // Arrange
253253 mockFactory := mocks.NewMockWidgetFactory(t)
254- ctx, _, _ := setupWorkspaceViewBase(t, mockFactory)
254+ ctx, _ := setupWorkspaceViewBase(t, mockFactory)
255255
256256 wv := component.NewWorkspaceView(ctx, mockFactory)
257257
M internal/ui/coordinator/content.go +30 -18
@@ -138,17 +138,17 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
138138 wv.OnLoadChanged = func(event webkit.LoadEvent) {
139139 switch event {
140140 case webkit.LoadStarted:
141- c.onLoadStarted(ctx, paneID)
141+ c.onLoadStarted(paneID)
142142 case webkit.LoadCommitted:
143143 c.onLoadCommitted(ctx, paneID, wv)
144144 case webkit.LoadFinished:
145- c.onLoadFinished(ctx, paneID)
145+ c.onLoadFinished(paneID)
146146 }
147147 }
148148
149149 // Set up progress callback for loading indicator
150150 wv.OnProgressChanged = func(progress float64) {
151- c.onProgressChanged(ctx, paneID, progress)
151+ c.onProgressChanged(paneID, progress)
152152 }
153153
154154 // Set up URI change callback for SPA navigation (History API)
@@ -483,7 +483,13 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P
483483 }
484484
485485 // 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+) {
487493 log := logging.FromContext(ctx)
488494
489495 if ws == nil {
@@ -547,7 +553,13 @@ func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity
547553 }
548554
549555 // 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+) {
551563 log := logging.FromContext(ctx)
552564
553565 if ws == nil {
@@ -653,7 +665,7 @@ func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.
653665 }
654666
655667 // 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) {
657669 _, wsView := c.getActiveWS()
658670 if wsView == nil {
659671 return
@@ -666,7 +678,7 @@ func (c *ContentCoordinator) onLoadStarted(ctx context.Context, paneID entity.Pa
666678 }
667679
668680 // 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) {
670682 _, wsView := c.getActiveWS()
671683 if wsView == nil {
672684 return
@@ -679,7 +691,7 @@ func (c *ContentCoordinator) onLoadFinished(ctx context.Context, paneID entity.P
679691 }
680692
681693 // 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) {
683695 _, wsView := c.getActiveWS()
684696 if wsView == nil {
685697 return
@@ -761,7 +773,7 @@ func (c *ContentCoordinator) handlePopupCreate(
761773 }
762774
763775 // Create related WebView for session sharing
764- parentID := port.WebViewID(parentWV.ID())
776+ parentID := parentWV.ID()
765777 popupWV, err := c.factory.CreateRelated(ctx, parentID)
766778 if err != nil {
767779 log.Error().Err(err).Msg("failed to create related webview for popup")
@@ -772,7 +784,7 @@ func (c *ContentCoordinator) handlePopupCreate(
772784 popupType := DetectPopupType(req.FrameName)
773785
774786 // Store pending popup for ready-to-show handling
775- popupID := port.WebViewID(popupWV.ID())
787+ popupID := popupWV.ID()
776788 pending := &PendingPopup{
777789 WebView: popupWV,
778790 ParentPaneID: parentPaneID,
@@ -863,7 +875,7 @@ func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID
863875
864876 // Request insertion into workspace
865877 if c.onInsertPopup != nil {
866- input := InsertPopupInput{
878+ popupInput := InsertPopupInput{
867879 ParentPaneID: pending.ParentPaneID,
868880 PopupPane: popupPane,
869881 WebView: pending.WebView,
@@ -873,7 +885,7 @@ func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID
873885 TargetURI: pending.TargetURI,
874886 }
875887
876- if err := c.onInsertPopup(ctx, input); err != nil {
888+ if err := c.onInsertPopup(ctx, popupInput); err != nil {
877889 log.Error().Err(err).Msg("failed to insert popup into workspace")
878890 // Clean up on failure
879891 delete(c.webViews, paneID)
@@ -912,7 +924,7 @@ func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.
912924 // Find pane by WebView ID
913925 var paneID entity.PaneID
914926 for pid, wv := range c.webViews {
915- if wv != nil && port.WebViewID(wv.ID()) == popupID {
927+ if wv != nil && wv.ID() == popupID {
916928 paneID = pid
917929 break
918930 }
@@ -952,17 +964,17 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
952964 wv.OnLoadChanged = func(event webkit.LoadEvent) {
953965 switch event {
954966 case webkit.LoadStarted:
955- c.onLoadStarted(ctx, paneID)
967+ c.onLoadStarted(paneID)
956968 case webkit.LoadCommitted:
957969 c.onLoadCommitted(ctx, paneID, wv)
958970 case webkit.LoadFinished:
959- c.onLoadFinished(ctx, paneID)
971+ c.onLoadFinished(paneID)
960972 }
961973 }
962974
963975 // Progress
964976 wv.OnProgressChanged = func(progress float64) {
965- c.onProgressChanged(ctx, paneID, progress)
977+ c.onProgressChanged(paneID, progress)
966978 }
967979
968980 // SPA navigation
@@ -1094,7 +1106,7 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
10941106
10951107 // Request insertion into workspace
10961108 if c.onInsertPopup != nil {
1097- input := InsertPopupInput{
1109+ popupInput := InsertPopupInput{
10981110 ParentPaneID: parentPaneID,
10991111 PopupPane: newPane,
11001112 WebView: newWV,
@@ -1104,7 +1116,7 @@ func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPa
11041116 TargetURI: uri,
11051117 }
11061118
1107- if err := c.onInsertPopup(ctx, input); err != nil {
1119+ if err := c.onInsertPopup(ctx, popupInput); err != nil {
11081120 log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
11091121 // Clean up on failure
11101122 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
320320
321321 // CreateWithPane creates a new tab with a pre-created pane and WebView.
322322 // 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) {
324329 log := logging.FromContext(ctx)
325330
326331 output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{
M internal/ui/coordinator/workspace.go +666 -491
@@ -11,6 +11,12 @@ import (
1111 "github.com/bnema/dumber/internal/ui/component"
1212 "github.com/bnema/dumber/internal/ui/focus"
1313 "github.com/bnema/dumber/internal/ui/layout"
14+ "github.com/rs/zerolog"
15+)
16+
17+const (
18+ defaultPaneTitle = "Untitled"
19+ nilString = "nil"
1420 )
1521
1622 // WorkspaceCoordinator manages pane operations within a workspace.
@@ -39,6 +45,22 @@ type WorkspaceCoordinatorConfig struct {
3945 GenerateID func() string
4046 }
4147
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+
4264 // NewWorkspaceCoordinator creates a new WorkspaceCoordinator.
4365 func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator {
4466 log := logging.FromContext(ctx)
@@ -81,108 +103,150 @@ func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *com
81103 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
82104 log := logging.FromContext(ctx)
83105
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)
84142 if c.panesUC == nil {
85143 log.Warn().Msg("panes use case not available")
86- return nil
144+ return nil, false
87145 }
88146
89147 ws, wsView := c.getActiveWS()
90148 if ws == nil {
91149 log.Warn().Msg("no active workspace")
92- return nil
150+ return nil, false
93151 }
94152
95153 activePane := ws.ActivePane()
96154 if activePane == nil {
97155 log.Warn().Msg("no active pane to split")
98- return nil
156+ return nil, false
99157 }
100158
101- // Check if we're splitting from inside a stack (horizontal split around stack)
102159 isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
103160 activePane.Parent != nil && activePane.Parent.IsStacked
104161
105- // Get the existing widget BEFORE domain changes (for incremental operation)
106162 var existingWidget layout.Widget
107163 if wsView != nil {
108- if 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- }
164+ existingWidget = c.resolveSplitWidget(wsView, activePane, isStackSplit)
121165 }
122166
123- output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
124- Workspace: ws,
125- TargetPane: activePane,
126- Direction: direction,
127- })
128- if err != nil {
129- log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
130- return err
167+ return &splitContext{
168+ ws: ws,
169+ wsView: wsView,
170+ activePane: activePane,
171+ existingWidget: existingWidget,
172+ isStackSplit: isStackSplit,
173+ }, true
174+}
175+
176+func (c *WorkspaceCoordinator) resolveSplitWidget(
177+ wsView *component.WorkspaceView,
178+ activePane *entity.PaneNode,
179+ isStackSplit bool,
180+) layout.Widget {
181+ if wsView == nil {
182+ return nil
131183 }
132184
133- // Remember old active pane before changing
134- oldActivePaneID := activePane.Pane.ID
185+ if !isStackSplit {
186+ return wsView.GetRootWidget()
187+ }
135188
136- // Set the new pane as active
137- ws.ActivePaneID = output.NewPaneNode.Pane.ID
189+ tr := wsView.TreeRenderer()
190+ if tr == nil {
191+ return nil
192+ }
138193
139- // Update the workspace view
140- if wsView != nil && existingWidget != nil {
194+ stackedView := tr.GetStackedViewForPane(string(activePane.Pane.ID))
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 {
141216 var splitErr error
142217 if isStackSplit {
143- // Incremental stack split: reuse existing stack widget
144- splitErr = c.doIncrementalStackSplit(ctx, wsView, ws, output, direction, existingWidget, oldActivePaneID)
218+ splitErr = c.doIncrementalStackSplit(ctx, wsView, output, direction, existingWidget, oldActivePaneID)
145219 } else {
146- // Incremental regular split: reuse existing root widget
147220 splitErr = c.doIncrementalSplit(ctx, wsView, ws, output, direction, existingWidget, oldActivePaneID)
148221 }
149222
150223 if splitErr != nil {
151224 log.Warn().Err(splitErr).Msg("incremental split failed, falling back to rebuild")
152- // Fallback to full rebuild
153225 if err := wsView.Rebuild(ctx); err != nil {
154226 log.Error().Err(err).Msg("failed to rebuild workspace view")
155227 }
156- c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
228+ needsAttach = true
157229 }
158- // Update workspace view's active pane tracking
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
230+ } else {
165231 if err := wsView.Rebuild(ctx); err != nil {
166232 log.Error().Err(err).Msg("failed to rebuild workspace view")
167233 }
168- c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
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)
234+ needsAttach = true
174235 }
175236
176- log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")
177-
178- return nil
237+ if needsAttach {
238+ c.contentCoord.AttachToWorkspace(ctx, ws, wsView)
239+ }
240+ if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
241+ log.Warn().Err(err).Msg("failed to set active pane in workspace view")
242+ }
243+ wsView.FocusPane(ws.ActivePaneID)
179244 }
180245
181246 // doIncrementalStackSplit performs an incremental split around an existing stacked pane.
182247 func (c *WorkspaceCoordinator) doIncrementalStackSplit(
183248 ctx context.Context,
184249 wsView *component.WorkspaceView,
185- ws *entity.Workspace,
186250 output *usecase.SplitPaneOutput,
187251 direction usecase.SplitDirection,
188252 existingStackWidget layout.Widget,
@@ -317,7 +381,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
317381 if grandparentNode != nil {
318382 return grandparentNode.ID
319383 }
320- return "nil"
384+ return nilString
321385 }()).
322386 Msg("determined split type")
323387
@@ -329,106 +393,40 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
329393 newStackedView := layout.NewStackedView(factory)
330394 newStackedView.AddPane(ctx, output.NewPaneNode.Pane.Title, "", newPaneView.Widget())
331395
332- // 6. Determine orientation based on direction
333- var orientation layout.Orientation
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:
396+ orientation, existingFirst, ok := splitOrientation(direction)
397+ if !ok {
349398 return nil
350399 }
351400
352401 // 7. Handle root vs non-root split differently
353402 if isRootSplit {
354- // ROOT SPLIT: Remove from container, wrap in split, set as new root
355- // Clear rootWidget ref first so SetRootWidgetDirect won't double-remove
356- wsView.ClearRootWidgetRef()
357- wsView.Container().Remove(existingRootWidget)
358-
359- var splitView *layout.SplitView
360- if existingFirst {
361- splitView = layout.NewSplitView(ctx, factory, orientation,
362- existingRootWidget, newStackedView.Widget(), output.SplitRatio)
363- } else {
364- splitView = layout.NewSplitView(ctx, factory, orientation,
365- newStackedView.Widget(), existingRootWidget, output.SplitRatio)
403+ if err := c.replaceRootSplit(
404+ ctx,
405+ wsView,
406+ tr,
407+ factory,
408+ existingRootWidget,
409+ newStackedView,
410+ output,
411+ orientation,
412+ existingFirst,
413+ ); err != nil {
414+ return err
366415 }
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")
372416 } else {
373- // NON-ROOT SPLIT: Find grandparent's widget from TreeRenderer
374- grandparentWidget := tr.Lookup(grandparentNode.ID)
375- if grandparentWidget == nil {
376- log.Warn().Str("grandparent_id", grandparentNode.ID).Msg("grandparent widget not found in TreeRenderer")
377- return fmt.Errorf("grandparent widget not found")
378- }
379-
380- // Cast to PanedWidget
381- panedWidget, ok := grandparentWidget.(layout.PanedWidget)
382- if !ok {
383- log.Warn().Msg("grandparent widget is not a PanedWidget, falling back")
384- return fmt.Errorf("grandparent is not a PanedWidget")
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())
417+ if err := c.replaceNonRootSplit(
418+ ctx,
419+ tr,
420+ factory,
421+ grandparentNode,
422+ output,
423+ activePaneWidget,
424+ newStackedView,
425+ orientation,
426+ existingFirst,
427+ ); err != nil {
428+ return err
425429 }
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")
432430 }
433431
434432 // 8. Register the new pane in tracking maps and activate it
@@ -462,6 +460,117 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
462460 return nil
463461 }
464462
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+
465574 // ClosePane closes the active pane.
466575 func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
467576 log := logging.FromContext(ctx)
@@ -531,7 +640,7 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
531640 if siblingNode != nil {
532641 return siblingNode.ID
533642 }
534- return "nil"
643+ return nilString
535644 }()).
536645 Bool("sibling_is_start", siblingIsStartChild).
537646 Bool("parent_is_start_in_grand", parentIsStartInGrand).
@@ -548,47 +657,18 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
548657 return err
549658 }
550659
551- // Try incremental close if we have the context
552- if wsView != nil && parentNode != nil {
553- var closeErr error
554-
555- if parentNode.IsStacked {
556- // Stacked close: remove pane from stack without rebuild
557- closeErr = c.doIncrementalStackClose(ctx, wsView, closingPaneID, parentNode)
558- } else if siblingNode != nil && parentWidget != nil {
559- // Split close: promote sibling without rebuild
560- closeErr = c.doIncrementalClose(ctx, wsView, ws, closingPaneID, siblingNode, parentNode, grandparentNode, parentWidget, siblingIsStartChild, parentIsStartInGrand)
561- } else {
562- closeErr = fmt.Errorf("missing context for incremental close")
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- }
660+ c.finalizePaneClose(
661+ ctx,
662+ wsView,
663+ ws,
664+ closingPaneID,
665+ parentNode,
666+ siblingNode,
667+ grandparentNode,
668+ parentWidget,
669+ siblingIsStartChild,
670+ parentIsStartInGrand,
671+ )
592672
593673 log.Info().Msg("pane closed")
594674 return nil
@@ -664,42 +744,18 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.
664744 return err
665745 }
666746
667- // Try incremental close
668- if wsView != nil && parentNode != nil {
669- var closeErr error
670-
671- if parentNode.IsStacked {
672- closeErr = c.doIncrementalStackClose(ctx, wsView, paneID, parentNode)
673- } else if siblingNode != nil && parentWidget != nil {
674- closeErr = c.doIncrementalClose(ctx, wsView, ws, paneID, siblingNode, parentNode, grandparentNode, parentWidget, siblingIsStartChild, parentIsStartInGrand)
675- } else {
676- closeErr = fmt.Errorf("missing context for incremental close")
677- }
678-
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- }
747+ c.finalizePaneClose(
748+ ctx,
749+ wsView,
750+ ws,
751+ paneID,
752+ parentNode,
753+ siblingNode,
754+ grandparentNode,
755+ parentWidget,
756+ siblingIsStartChild,
757+ parentIsStartInGrand,
758+ )
703759
704760 log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID")
705761 return nil
@@ -709,7 +765,6 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.
709765 func (c *WorkspaceCoordinator) doIncrementalClose(
710766 ctx context.Context,
711767 wsView *component.WorkspaceView,
712- ws *entity.Workspace,
713768 closingPaneID entity.PaneID,
714769 siblingNode *entity.PaneNode,
715770 parentNode *entity.PaneNode,
@@ -835,24 +890,85 @@ func (c *WorkspaceCoordinator) doIncrementalClose(
835890 return nil
836891 }
837892
838-// doIncrementalStackClose removes a pane from a stack without rebuilding the entire tree.
839-func (c *WorkspaceCoordinator) doIncrementalStackClose(
893+func (c *WorkspaceCoordinator) finalizePaneClose(
840894 ctx context.Context,
841895 wsView *component.WorkspaceView,
842- closingPaneID entity.PaneID,
843- stackNode *entity.PaneNode,
844-) error {
845- log := logging.FromContext(ctx)
846- tr := wsView.TreeRenderer()
847- if tr == nil {
848- return fmt.Errorf("tree renderer not available")
896+ ws *entity.Workspace,
897+ paneID entity.PaneID,
898+ parentNode *entity.PaneNode,
899+ siblingNode *entity.PaneNode,
900+ grandparentNode *entity.PaneNode,
901+ parentWidget layout.Widget,
902+ siblingIsStartChild bool,
903+ parentIsStartInGrand bool,
904+) {
905+ if wsView == nil {
906+ return
849907 }
850908
851- log.Debug().
852- Str("closing_pane", string(closingPaneID)).
853- Str("stack_node", stackNode.ID).
854- Int("stack_children", len(stackNode.Children)).
855- Msg("performing incremental stack close")
909+ log := logging.FromContext(ctx)
910+
911+ if parentNode != nil {
912+ var closeErr error
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")
856972
857973 // Get the StackedView for this pane
858974 stackedView := tr.GetStackedViewForPane(string(closingPaneID))
@@ -984,104 +1100,18 @@ func (c *WorkspaceCoordinator) syncStackedViewActive(ctx context.Context, wsView
9841100 func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
9851101 log := logging.FromContext(ctx)
9861102
987- if c.stackedPaneMgr == nil {
988- log.Warn().Msg("stacked pane manager not available")
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")
1103+ stackCtx, ok := c.prepareStackPane(ctx)
1104+ if !ok {
10061105 return nil
10071106 }
10081107
1009- activePaneID := activeNode.Pane.ID
1010-
1011- // Create a new pane entity
10121108 newPaneID := entity.PaneID(c.generateID())
10131109 newPane := entity.NewPane(newPaneID)
10141110 newPane.URI = "about:blank"
1015- newPane.Title = "Untitled"
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- }
1111+ newPane.Title = defaultPaneTitle
10491112
1050- activeNode.Pane = nil
1051- activeNode.IsStacked = true
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
1113+ stackNode, needsFirstPaneTitleUpdate := c.ensureStackNode(ctx, stackCtx.activeNode, stackCtx.activePaneID, stackCtx.originalTitle)
1114+ insertIndex := insertStackChild(stackNode, newPaneID, newPane)
10851115
10861116 log.Debug().
10871117 Int("stack_size", len(stackNode.Children)).
@@ -1091,28 +1121,18 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
10911121
10921122 // Create PaneView for the new pane
10931123 newPaneView := component.NewPaneView(c.widgetFactory, newPaneID, nil)
1094- setupPaneViewHover(ctx, newPaneView, wsView)
1095- wsView.RegisterPaneView(newPaneID, newPaneView)
1124+ setupPaneViewHover(ctx, newPaneView, stackCtx.wsView)
1125+ stackCtx.wsView.RegisterPaneView(newPaneID, newPaneView)
10961126
10971127 // 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 {
10991129 log.Error().Err(err).Msg("failed to add pane to stack")
11001130 return err
11011131 }
11021132
11031133 // Update the first pane's title if we just converted from leaf to stacked
11041134 if needsFirstPaneTitleUpdate {
1105- tr := wsView.TreeRenderer()
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- }
1135+ c.updateFirstStackTitle(ctx, stackCtx.wsView, stackCtx.activePaneID, stackCtx.originalTitle)
11161136 }
11171137
11181138 // Get WebView and attach
@@ -1131,17 +1151,17 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
11311151 }
11321152
11331153 // Update workspace active pane ID
1134- ws.ActivePaneID = newPaneID
1154+ stackCtx.ws.ActivePaneID = newPaneID
11351155
11361156 // Update workspace view
1137- if err := wsView.SetActivePaneID(newPaneID); err != nil {
1157+ if err := stackCtx.wsView.SetActivePaneID(newPaneID); err != nil {
11381158 log.Warn().Err(err).Msg("failed to set active pane")
11391159 }
11401160
11411161 // Set up title bar click callback
1142- tr := wsView.TreeRenderer()
1162+ tr := stackCtx.wsView.TreeRenderer()
11431163 if tr != nil {
1144- stackedView := tr.GetStackedViewForPane(string(activePaneID))
1164+ stackedView := tr.GetStackedViewForPane(string(stackCtx.activePaneID))
11451165 if stackedView != nil {
11461166 capturedStackNode := stackNode
11471167 stackedView.SetOnActivate(func(index int) {
@@ -1151,7 +1171,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
11511171 }
11521172
11531173 log.Info().
1154- Str("original_pane", string(activePaneID)).
1174+ Str("original_pane", string(stackCtx.activePaneID)).
11551175 Str("new_pane", string(newPaneID)).
11561176 Int("stack_size", len(stackNode.Children)).
11571177 Msg("stacked new pane")
@@ -1159,6 +1179,137 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
11591179 return nil
11601180 }
11611181
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+
11621313 // NavigateStack navigates up or down within a stacked pane container.
11631314 func (c *WorkspaceCoordinator) NavigateStack(ctx context.Context, direction string) error {
11641315 log := logging.FromContext(ctx)
@@ -1324,40 +1475,14 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input Inser
13241475 return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
13251476 }
13261477
1327- // Map placement to split direction
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- }
1478+ direction := resolvePopupSplitDirection(input.Placement)
13411479
13421480 // Check if parent is in a stack - if so, split around the stack
13431481 isStackSplit := (direction == usecase.SplitLeft || direction == usecase.SplitRight) &&
13441482 parentNode.Parent != nil && parentNode.Parent.IsStacked
13451483
13461484 // Get existing widget before domain changes
1347- var existingWidget layout.Widget
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- }
1485+ existingWidget := c.resolvePopupSplitWidget(wsView, parentNode, isStackSplit)
13611486
13621487 // Perform the split with pre-created pane
13631488 output, err := c.panesUC.Split(ctx, usecase.SplitPaneInput{
@@ -1374,38 +1499,9 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input Inser
13741499 ws.ActivePaneID = input.PopupPane.ID
13751500
13761501 // Update UI
1377- if wsView != nil && existingWidget != nil {
1378- var splitErr error
1379- if isStackSplit {
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- }
1502+ if wsView != nil {
1503+ c.applySplitToView(ctx, wsView, ws, output, direction, existingWidget, isStackSplit, input.ParentPaneID)
1504+ c.attachPopupWebView(ctx, wsView, input)
14091505 }
14101506
14111507 log.Info().
@@ -1416,6 +1512,68 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input Inser
14161512 return nil
14171513 }
14181514
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+
14191577 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.
14201578 func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {
14211579 log := logging.FromContext(ctx)
@@ -1425,64 +1583,87 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
14251583 return fmt.Errorf("no active workspace")
14261584 }
14271585
1428- // Find parent pane node
14291586 parentNode := ws.FindPane(input.ParentPaneID)
14301587 if parentNode == nil {
14311588 return fmt.Errorf("parent pane not found: %s", input.ParentPaneID)
14321589 }
14331590
1434- // Determine stack node - either parent is already a stack, or we need to convert it
1435- var stackNode *entity.PaneNode
1436- var needsStackConversion bool
1591+ stackNode := c.resolveStackNode(input, parentNode, log)
1592+ if stackNode == nil {
1593+ return fmt.Errorf("failed to resolve stack node for popup")
1594+ }
14371595
1438- if parentNode.Parent != nil && parentNode.Parent.IsStacked {
1439- // Parent is already in a stack - add to existing stack
1440- stackNode = parentNode.Parent
1441- } else if parentNode.IsStacked {
1442- // Parent itself is a stack - add to it
1443- stackNode = parentNode
1444- } else {
1445- // Need to convert parent leaf to a stack
1446- needsStackConversion = true
1447- stackNode = parentNode
1596+ c.insertStackChild(stackNode, input.PopupPane)
1597+
1598+ // Update workspace active pane
1599+ ws.ActivePaneID = input.PopupPane.ID
1600+
1601+ if err := c.attachPopupPaneView(ctx, input, wsView, stackNode, log); err != nil {
1602+ return err
14481603 }
14491604
1450- if needsStackConversion {
1451- // Convert leaf node to stacked container
1452- originalPane := parentNode.Pane
1453- originalTitle := c.contentCoord.GetTitle(input.ParentPaneID)
1454- if originalTitle == "" {
1455- originalTitle = originalPane.Title
1456- }
1457- if originalTitle == "" {
1458- originalTitle = "Untitled"
1459- }
1460- originalPane.Title = originalTitle
1605+ log.Info().
1606+ Str("popup_pane", string(input.PopupPane.ID)).
1607+ Int("stack_size", len(stackNode.Children)).
1608+ Msg("popup inserted as stacked pane")
14611609
1462- originalPaneChild := &entity.PaneNode{
1463- ID: parentNode.ID + "_0",
1464- Pane: originalPane,
1465- Parent: parentNode,
1466- }
1610+ return nil
1611+}
14671612
1468- parentNode.Pane = nil
1469- parentNode.IsStacked = true
1470- parentNode.Children = []*entity.PaneNode{originalPaneChild}
1613+func (c *WorkspaceCoordinator) resolveStackNode(
1614+ input InsertPopupInput,
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+}
14711626
1472- log.Debug().
1473- Str("node_id", parentNode.ID).
1474- Str("original_pane", string(originalPane.ID)).
1475- Msg("converted leaf to stacked node for popup")
1627+func (c *WorkspaceCoordinator) convertToStackedNode(
1628+ input InsertPopupInput,
1629+ parentNode *entity.PaneNode,
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
14761639 }
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")
14771656
1478- // Create new child node for the popup pane
1657+ return parentNode
1658+}
1659+
1660+func (c *WorkspaceCoordinator) insertStackChild(stackNode *entity.PaneNode, pane *entity.Pane) {
14791661 newChildNode := &entity.PaneNode{
1480- ID: stackNode.ID + "_popup_" + string(input.PopupPane.ID),
1481- Pane: input.PopupPane,
1662+ ID: stackNode.ID + "_popup_" + string(pane.ID),
1663+ Pane: pane,
14821664 Parent: stackNode,
14831665 }
14841666
1485- // Insert right after current active pane (not at end)
14861667 currentActiveIndex := stackNode.ActiveStackIndex
14871668 if currentActiveIndex < 0 || currentActiveIndex >= len(stackNode.Children) {
14881669 currentActiveIndex = len(stackNode.Children) - 1
@@ -1492,58 +1673,52 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
14921673 }
14931674 insertIndex := currentActiveIndex + 1
14941675
1495- // Slice insertion at correct position
14961676 stackNode.Children = append(stackNode.Children, nil)
14971677 copy(stackNode.Children[insertIndex+1:], stackNode.Children[insertIndex:])
14981678 stackNode.Children[insertIndex] = newChildNode
14991679 stackNode.ActiveStackIndex = insertIndex
1680+}
15001681
1501- // Update workspace active pane
1502- ws.ActivePaneID = input.PopupPane.ID
1503-
1504- // Create PaneView for the popup
1505- if wsView != nil {
1506- newPaneView := component.NewPaneView(c.widgetFactory, input.PopupPane.ID, nil)
1507- setupPaneViewHover(ctx, newPaneView, wsView)
1508- wsView.RegisterPaneView(input.PopupPane.ID, newPaneView)
1682+func (c *WorkspaceCoordinator) attachPopupPaneView(
1683+ ctx context.Context,
1684+ input InsertPopupInput,
1685+ wsView *component.WorkspaceView,
1686+ stackNode *entity.PaneNode,
1687+ log *zerolog.Logger,
1688+) error {
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)
15091695
1510- // Add to StackedView
1511- if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, input.ParentPaneID, newPaneView, input.PopupPane.Title); err != nil {
1512- log.Error().Err(err).Msg("failed to add popup pane to stack")
1513- return err
1514- }
1696+ 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")
1698+ return err
1699+ }
15151700
1516- // Attach WebView widget
1517- if input.WebView != nil {
1518- widget := c.contentCoord.WrapWidget(ctx, input.WebView)
1519- if widget != nil {
1520- newPaneView.SetWebViewWidget(widget)
1521- }
1701+ if input.WebView != nil {
1702+ widget := c.contentCoord.WrapWidget(ctx, input.WebView)
1703+ if widget != nil {
1704+ newPaneView.SetWebViewWidget(widget)
15221705 }
1706+ }
15231707
1524- // Update active pane tracking
1525- if err := wsView.SetActivePaneID(ws.ActivePaneID); err != nil {
1526- log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1527- }
1708+ if err := wsView.SetActivePaneID(input.PopupPane.ID); err != nil {
1709+ log.Warn().Err(err).Msg("failed to set active pane in workspace view")
1710+ }
15281711
1529- // Setup title bar click callback
1530- tr := wsView.TreeRenderer()
1531- if tr != nil {
1532- stackedView := tr.GetStackedViewForPane(string(input.ParentPaneID))
1533- if stackedView != nil {
1534- capturedStackNode := stackNode
1535- stackedView.SetOnActivate(func(index int) {
1536- c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1537- })
1538- }
1712+ tr := wsView.TreeRenderer()
1713+ if tr != nil {
1714+ stackedView := tr.GetStackedViewForPane(string(input.ParentPaneID))
1715+ if stackedView != nil {
1716+ capturedStackNode := stackNode
1717+ stackedView.SetOnActivate(func(index int) {
1718+ c.onTitleBarClick(ctx, capturedStackNode, stackedView, index)
1719+ })
15391720 }
15401721 }
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-
15471722 return nil
15481723 }
15491724
M internal/ui/dispatcher/keyboard.go +132 -125
@@ -2,6 +2,7 @@ package dispatcher
22
33 import (
44 "context"
5+ "fmt"
56
67 "github.com/bnema/dumber/internal/application/usecase"
78 "github.com/bnema/dumber/internal/domain/entity"
@@ -19,6 +20,7 @@ type KeyboardDispatcher struct {
1920 navCoord *coordinator.NavigationCoordinator
2021 zoomUC *usecase.ManageZoomUseCase
2122 copyURLUC *usecase.CopyURLUseCase
23+ actionHandlers map[input.Action]func(ctx context.Context) error
2224 onQuit func()
2325 onFindOpen func(ctx context.Context) error
2426 onFindNext func(ctx context.Context) error
@@ -38,13 +40,15 @@ func NewKeyboardDispatcher(
3840 log := logging.FromContext(ctx)
3941 log.Debug().Msg("creating keyboard dispatcher")
4042
41- return &KeyboardDispatcher{
43+ dispatcher := &KeyboardDispatcher{
4244 tabCoord: tabCoord,
4345 wsCoord: wsCoord,
4446 navCoord: navCoord,
4547 zoomUC: zoomUC,
4648 copyURLUC: copyURLUC,
4749 }
50+ dispatcher.initActionHandlers()
51+ return dispatcher
4852 }
4953
5054 // SetOnQuit sets the callback for quit action.
@@ -72,135 +76,139 @@ func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error)
7276 d.onFindClose = fn
7377 }
7478
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+
75152 // Dispatch routes a keyboard action to the appropriate coordinator.
76153 func (d *KeyboardDispatcher) Dispatch(ctx context.Context, action input.Action) error {
77154 log := logging.FromContext(ctx)
78155 log.Debug().Str("action", string(action)).Msg("dispatching keyboard action")
79156
80- switch action {
81- // Tab actions
82- case input.ActionNewTab:
83- _, err := d.tabCoord.Create(ctx, "about:blank")
84- return err
85- case input.ActionCloseTab:
86- return d.tabCoord.Close(ctx)
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- }
157+ if handler, ok := d.actionHandlers[action]; ok {
158+ return handler(ctx)
159+ }
160+
161+ log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
162+ return nil
163+}
199164
200- default:
201- log.Warn().Str("action", string(action)).Msg("unhandled keyboard action")
165+func (d *KeyboardDispatcher) handleFindOpen(ctx context.Context) error {
166+ if d.onFindOpen != nil {
167+ return d.onFindOpen(ctx)
202168 }
169+ logging.FromContext(ctx).Debug().Msg("find open action (no handler)")
170+ return nil
171+}
203172
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+ }
204212 return nil
205213 }
206214
@@ -297,11 +305,10 @@ func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
297305 }
298306
299307 // Show toast on GTK main thread
300- var cb glib.SourceFunc
301- cb = func(_ uintptr) bool {
308+ cb := glib.SourceFunc(func(_ uintptr) bool {
302309 d.wsCoord.ShowToastOnActivePane(ctx, "URL copied", component.ToastSuccess)
303310 return false
304- }
311+ })
305312 glib.IdleAdd(&cb, 0)
306313 }()
307314
M internal/ui/input/gesture.go +1 -1
@@ -64,7 +64,7 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
6464 h.clickGesture.SetButton(0)
6565
6666 // Connect pressed handler
67- pressedCb := func(gesture gtk.GestureClick, nPress int, x float64, y float64) {
67+ pressedCb := func(_ gtk.GestureClick, nPress int, _ float64, _ float64) {
6868 h.handlePressed(nPress)
6969 }
7070 h.clickGesture.ConnectPressed(&pressedCb)
M internal/ui/input/hover.go +2 -2
@@ -64,13 +64,13 @@ func (h *HoverHandler) AttachTo(widget *gtk.Widget) {
6464 }
6565
6666 // Connect enter handler with debounce
67- enterCb := func(ctrl gtk.EventControllerMotion, x, y float64) {
67+ enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) {
6868 h.handleEnter()
6969 }
7070 h.motionCtrl.ConnectEnter(&enterCb)
7171
7272 // Connect leave handler to cancel pending focus
73- leaveCb := func(ctrl gtk.EventControllerMotion) {
73+ leaveCb := func(_ gtk.EventControllerMotion) {
7474 h.handleLeave()
7575 }
7676 h.motionCtrl.ConnectLeave(&leaveCb)
M internal/ui/input/keyboard.go +20 -3
@@ -25,6 +25,8 @@ type KeyboardHandler struct {
2525
2626 // Action handler callback
2727 onAction ActionHandler
28+ // Optional bypass check (e.g., omnibox visible)
29+ shouldBypass func() bool
2830
2931 // GTK controller (nil until attached)
3032 controller *gtk.EventControllerKey
@@ -64,6 +66,14 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
6466 h.modal.SetOnModeChange(fn)
6567 }
6668
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+
6777 // Mode returns the current input mode.
6878 func (h *KeyboardHandler) Mode() Mode {
6979 return h.modal.Mode()
@@ -89,8 +99,8 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
8999 h.controller.SetPropagationPhase(gtk.PhaseCaptureValue)
90100
91101 // Connect key pressed handler
92- keyPressedCb := func(ctrl gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
93- return h.handleKeyPress(keyval, keycode, state)
102+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
103+ return h.handleKeyPress(keyval, state)
94104 }
95105 h.controller.ConnectKeyPressed(&keyPressedCb)
96106
@@ -109,7 +119,14 @@ func (h *KeyboardHandler) Detach() {
109119
110120 // handleKeyPress processes a key press event.
111121 // 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+
113130 // Build KeyBinding from event
114131 modifiers := Modifier(state) & modifierMask
115132 binding := KeyBinding{
M internal/ui/input/modal_test.go +1 -1
@@ -225,7 +225,7 @@ func TestModalState_ExitCancelsTimeout(t *testing.T) {
225225
226226 // Callback should not have been called again
227227 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)
229229 }
230230 }
231231
M internal/ui/input/shortcuts.go +107 -127
@@ -27,6 +27,60 @@ const (
2727 // modifierMask filters out non-standard modifiers from GDK state.
2828 const modifierMask = ModCtrl | ModShift | ModAlt
2929
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+
3084 // KeyBinding represents a single key combination.
3185 type KeyBinding struct {
3286 Keyval uint // GDK key value (e.g., gdk.KEY_t)
@@ -140,21 +194,48 @@ func NewShortcutSet(ctx context.Context, cfg *config.WorkspaceConfig) *ShortcutS
140194
141195 // buildGlobalShortcuts populates global shortcuts from config.
142196 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) {
143215 log := logging.FromContext(ctx)
144- // Mode entry from activation shortcuts
145216 if binding, ok := ParseKeyString(cfg.TabMode.ActivationShortcut); ok {
146217 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")
148223 } else {
149224 log.Warn().Str("shortcut", cfg.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
150225 }
151226 if binding, ok := ParseKeyString(cfg.PaneMode.ActivationShortcut); ok {
152227 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")
154233 } else {
155234 log.Warn().Str("shortcut", cfg.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
156235 }
236+}
157237
238+func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
158239 // Note: Ctrl+T is NOT registered globally - it enters tab mode.
159240 // In tab mode, use:
160241 // n = new tab
@@ -174,8 +255,9 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work
174255 if binding, ok := ParseKeyString(cfg.Shortcuts.PreviousTab); ok {
175256 s.Global[binding] = ActionPreviousTab // Ctrl+Shift+Tab
176257 }
258+}
177259
178- // Standard browser shortcuts (hardcoded defaults)
260+func (s *ShortcutSet) registerStandardShortcuts() {
179261 s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
180262 s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
181263 s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext
@@ -196,18 +278,19 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work
196278 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
197279 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
198280 s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL
281+}
199282
200- // Pane navigation (Alt+HJKL like vim)
283+func (s *ShortcutSet) registerPaneNavigationShortcuts() {
201284 s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft
202285 s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight
203286 s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp
204287 s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown
205288
206- // Also support Alt+Arrow keys for pane navigation (except Left/Right used for back/forward)
207289 s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp
208290 s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown
291+}
209292
210- // Direct tab switching (Alt+1-9, Alt+0 for tab 10)
293+func (s *ShortcutSet) registerTabSwitchShortcuts() {
211294 s.Global[KeyBinding{uint(gdk.KEY_1), ModAlt}] = ActionSwitchTabIndex1
212295 s.Global[KeyBinding{uint(gdk.KEY_2), ModAlt}] = ActionSwitchTabIndex2
213296 s.Global[KeyBinding{uint(gdk.KEY_3), ModAlt}] = ActionSwitchTabIndex3
@@ -219,55 +302,38 @@ func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Work
219302 s.Global[KeyBinding{uint(gdk.KEY_9), ModAlt}] = ActionSwitchTabIndex9
220303 s.Global[KeyBinding{uint(gdk.KEY_0), ModAlt}] = ActionSwitchTabIndex10
221304
222- // Alt+Tab style switching (switch to last active tab)
223305 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt}] = ActionSwitchLastTab
224306 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
225307 }
226308
227-// buildTabModeShortcuts populates tab mode shortcuts from config.
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) {
309+func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) {
252310 log := logging.FromContext(ctx)
253- bindings := cfg.PaneMode.GetKeyBindings()
254311 var registered, parseErrors, unknownActions int
255312 for key, configAction := range bindings {
256313 if binding, ok := ParseKeyString(key); ok {
257314 if action := mapConfigAction(configAction); action != "" {
258- s.PaneMode[binding] = action
315+ dest[binding] = action
259316 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")
261323 } else {
262324 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")
264326 }
265327 } else {
266328 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")
268330 }
269331 }
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")
271337 }
272338
273339 // mapConfigAction maps config action names to Action constants.
@@ -368,94 +434,8 @@ func ParseKeyString(s string) (KeyBinding, bool) {
368434
369435 // stringToKeyval converts a key name to its GDK keyval.
370436 func stringToKeyval(s string) (uint, bool) {
371- // Special keys
372- switch s {
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
437+ if keyval, ok := keyvalByName[s]; ok {
438+ return keyval, true
459439 }
460440
461441 // 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) }
156156 func (p *gtkPaned) GetWideHandle() bool { return p.inner.GetWideHandle() }
157157
158158 func (p *gtkPaned) ConnectMap(callback func()) uint32 {
159- cb := func(w gtk.Widget) {
159+ cb := func(_ gtk.Widget) {
160160 callback()
161161 }
162162 return p.inner.ConnectMap(&cb)
@@ -245,7 +245,7 @@ func (b *gtkBox) Remove(child Widget) {
245245 b.inner.Remove(child.GtkWidget())
246246 }
247247
248-func (b *gtkBox) InsertChildAfter(child Widget, sibling Widget) {
248+func (b *gtkBox) InsertChildAfter(child, sibling Widget) {
249249 if child == nil {
250250 return
251251 }
@@ -256,7 +256,7 @@ func (b *gtkBox) InsertChildAfter(child Widget, sibling Widget) {
256256 b.inner.InsertChildAfter(child.GtkWidget(), sibGtk)
257257 }
258258
259-func (b *gtkBox) ReorderChildAfter(child Widget, sibling Widget) {
259+func (b *gtkBox) ReorderChildAfter(child, sibling Widget) {
260260 if child == nil {
261261 return
262262 }
@@ -524,7 +524,7 @@ func (btn *gtkButton) GetChild() Widget {
524524 }
525525
526526 func (btn *gtkButton) ConnectClicked(callback func()) uint32 {
527- cb := func(b gtk.Button) {
527+ cb := func(_ gtk.Button) {
528528 callback()
529529 }
530530 return btn.inner.ConnectClicked(&cb)
M internal/ui/layout/split.go +4 -1
@@ -27,7 +27,10 @@ const maxRetryFrames = 120
2727
2828 // NewSplitView creates a new split view with the given orientation and children.
2929 // 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 {
3134 log := logging.FromContext(ctx)
3235
3336 paned := factory.NewPaned(orientation)
M internal/ui/layout/split_test.go +4 -4
@@ -55,7 +55,7 @@ func TestNewSplitView_Horizontal(t *testing.T) {
5555 // Assert
5656 require.NotNil(t, sv)
5757 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)
5959 assert.Equal(t, mockStartChild, sv.StartChild())
6060 assert.Equal(t, mockEndChild, sv.EndChild())
6161 }
@@ -131,7 +131,7 @@ func TestSetRatio_ValidRange(t *testing.T) {
131131 sv.SetRatio(tt.input)
132132
133133 // Assert
134- assert.Equal(t, tt.expected, sv.GetRatio())
134+ assert.InEpsilon(t, tt.expected, sv.GetRatio(), 0.0001)
135135 })
136136 }
137137 }
@@ -164,7 +164,7 @@ func TestSetRatio_OutOfRange_Clamped(t *testing.T) {
164164 sv.SetRatio(tt.input)
165165
166166 // Assert
167- assert.Equal(t, tt.expected, sv.GetRatio())
167+ assert.InEpsilon(t, tt.expected, sv.GetRatio(), 0.0001)
168168 })
169169 }
170170 }
@@ -368,7 +368,7 @@ func TestNewSplitView_InitialRatio_Clamped(t *testing.T) {
368368 sv := layout.NewSplitView(ctx, mockFactory, layout.OrientationHorizontal, nil, nil, tt.input)
369369
370370 // Assert
371- assert.Equal(t, tt.expected, sv.GetRatio())
371+ assert.InEpsilon(t, tt.expected, sv.GetRatio(), 0.0001)
372372 })
373373 }
374374 }
M internal/ui/layout/stacked.go +4 -2
@@ -17,6 +17,8 @@ var ErrIndexOutOfBounds = errors.New("index out of bounds")
1717 // ErrCannotRemoveLastPane is returned when trying to remove the last pane from a stack.
1818 var ErrCannotRemoveLastPane = errors.New("cannot remove last pane from stack")
1919
20+const stackedTitleMaxWidthChars = 30
21+
2022 // stackedPane represents a single pane within a stacked container.
2123 type stackedPane struct {
2224 titleBar BoxWidget // horizontal box with favicon + title label
@@ -85,7 +87,7 @@ func (sv *StackedView) AddPane(ctx context.Context, title, faviconIconName strin
8587 // Create title label
8688 label := sv.factory.NewLabel(title)
8789 label.SetEllipsize(EllipsizeEnd)
88- label.SetMaxWidthChars(30)
90+ label.SetMaxWidthChars(stackedTitleMaxWidthChars)
8991 label.SetHexpand(true)
9092 label.SetXalign(0.0)
9193 titleBar.Append(label)
@@ -186,7 +188,7 @@ func (sv *StackedView) InsertPaneAfter(
186188 // Create title label
187189 label := sv.factory.NewLabel(title)
188190 label.SetEllipsize(EllipsizeEnd)
189- label.SetMaxWidthChars(30)
191+ label.SetMaxWidthChars(stackedTitleMaxWidthChars)
190192 label.SetHexpand(true)
191193 label.SetXalign(0.0)
192194 titleBar.Append(label)
M internal/ui/layout/stacked_test.go +81 -23
@@ -86,7 +86,10 @@ func TestAddPane_SinglePane_BecomesActive(t *testing.T) {
8686 // Arrange
8787 ctx := context.Background()
8888 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
9093
9194 // Visibility updates for active pane
9295 mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
@@ -110,13 +113,19 @@ func TestAddPane_MultiplePanes_LastBecomesActive(t *testing.T) {
110113 mockFactory, mockBox := setupMockFactory(t)
111114
112115 // 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
114120 mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
115121 mockContainer1.EXPECT().SetVisible(true).Once()
116122 mockTitleBar1.EXPECT().AddCssClass("active").Once()
117123
118124 // 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
120129
121130 // When second pane is added, update visibility for both
122131 mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
@@ -187,7 +196,10 @@ func TestRemovePane_LastPane_ReturnsError(t *testing.T) {
187196 // Arrange
188197 ctx := context.Background()
189198 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
191203
192204 mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
193205 mockContainer.EXPECT().SetVisible(true).Once()
@@ -200,7 +212,7 @@ func TestRemovePane_LastPane_ReturnsError(t *testing.T) {
200212 err := sv.RemovePane(ctx, 0)
201213
202214 // Assert
203- assert.ErrorIs(t, err, layout.ErrCannotRemoveLastPane)
215+ require.ErrorIs(t, err, layout.ErrCannotRemoveLastPane)
204216 assert.Equal(t, 1, sv.Count())
205217 }
206218
@@ -214,14 +226,17 @@ func TestRemovePane_EmptyStack_ReturnsError(t *testing.T) {
214226 err := sv.RemovePane(ctx, 0)
215227
216228 // Assert
217- assert.ErrorIs(t, err, layout.ErrStackEmpty)
229+ require.ErrorIs(t, err, layout.ErrStackEmpty)
218230 }
219231
220232 func TestRemovePane_IndexOutOfBounds(t *testing.T) {
221233 // Arrange
222234 ctx := context.Background()
223235 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
225240
226241 mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
227242 mockContainer.EXPECT().SetVisible(true).Once()
@@ -234,7 +249,7 @@ func TestRemovePane_IndexOutOfBounds(t *testing.T) {
234249 err := sv.RemovePane(ctx, 5)
235250
236251 // Assert
237- assert.ErrorIs(t, err, layout.ErrIndexOutOfBounds)
252+ require.ErrorIs(t, err, layout.ErrIndexOutOfBounds)
238253 }
239254
240255 func TestSetActive_ValidIndex(t *testing.T) {
@@ -246,7 +261,14 @@ func TestSetActive_ValidIndex(t *testing.T) {
246261 titleBars := make([]*mocks.MockBoxWidget, 2)
247262
248263 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+
250272 titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
251273 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
252274 titleBars[i].EXPECT().AddCssClass("active").Maybe()
@@ -269,7 +291,10 @@ func TestSetActive_OutOfBounds(t *testing.T) {
269291 // Arrange
270292 ctx := context.Background()
271293 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
273298
274299 mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
275300 mockContainer.EXPECT().SetVisible(true).Once()
@@ -282,7 +307,7 @@ func TestSetActive_OutOfBounds(t *testing.T) {
282307 err := sv.SetActive(ctx, 5)
283308
284309 // Assert
285- assert.ErrorIs(t, err, layout.ErrIndexOutOfBounds)
310+ require.ErrorIs(t, err, layout.ErrIndexOutOfBounds)
286311 }
287312
288313 func TestSetActive_EmptyStack(t *testing.T) {
@@ -295,7 +320,7 @@ func TestSetActive_EmptyStack(t *testing.T) {
295320 err := sv.SetActive(ctx, 0)
296321
297322 // Assert
298- assert.ErrorIs(t, err, layout.ErrStackEmpty)
323+ require.ErrorIs(t, err, layout.ErrStackEmpty)
299324 }
300325
301326 func TestUpdateTitle(t *testing.T) {
@@ -329,7 +354,7 @@ func TestUpdateTitle_InvalidIndex(t *testing.T) {
329354 err := sv.UpdateTitle(0, "Title")
330355
331356 // Assert
332- assert.ErrorIs(t, err, layout.ErrIndexOutOfBounds)
357+ require.ErrorIs(t, err, layout.ErrIndexOutOfBounds)
333358 }
334359
335360 func TestUpdateFavicon(t *testing.T) {
@@ -358,7 +383,10 @@ func TestGetContainer(t *testing.T) {
358383 // Arrange
359384 ctx := context.Background()
360385 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
362390
363391 mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
364392 mockContainer.EXPECT().SetVisible(true).Once()
@@ -384,7 +412,7 @@ func TestGetContainer_InvalidIndex(t *testing.T) {
384412 container, err := sv.GetContainer(0)
385413
386414 // Assert
387- assert.ErrorIs(t, err, layout.ErrIndexOutOfBounds)
415+ require.ErrorIs(t, err, layout.ErrIndexOutOfBounds)
388416 assert.Nil(t, container)
389417 }
390418
@@ -409,7 +437,14 @@ func TestNavigateNext_WrapsAround(t *testing.T) {
409437 titleBars := make([]*mocks.MockBoxWidget, 2)
410438
411439 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+
413448 titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
414449 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
415450 titleBars[i].EXPECT().AddCssClass("active").Maybe()
@@ -440,7 +475,14 @@ func TestNavigatePrevious_WrapsAround(t *testing.T) {
440475 titleBars := make([]*mocks.MockBoxWidget, 2)
441476
442477 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+
444486 titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
445487 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
446488 titleBars[i].EXPECT().AddCssClass("active").Maybe()
@@ -472,7 +514,7 @@ func TestNavigateNext_EmptyStack(t *testing.T) {
472514 err := sv.NavigateNext(ctx)
473515
474516 // Assert
475- assert.ErrorIs(t, err, layout.ErrStackEmpty)
517+ require.ErrorIs(t, err, layout.ErrStackEmpty)
476518 }
477519
478520 func TestNavigatePrevious_EmptyStack(t *testing.T) {
@@ -485,7 +527,7 @@ func TestNavigatePrevious_EmptyStack(t *testing.T) {
485527 err := sv.NavigatePrevious(ctx)
486528
487529 // Assert
488- assert.ErrorIs(t, err, layout.ErrStackEmpty)
530+ require.ErrorIs(t, err, layout.ErrStackEmpty)
489531 }
490532
491533 func TestSetOnActivate_Callback(t *testing.T) {
@@ -566,7 +608,10 @@ func TestInsertPaneAfter_AtBeginning(t *testing.T) {
566608 mockFactory, mockBox := setupMockFactory(t)
567609
568610 // 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
570615 mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
571616 mockContainer1.EXPECT().SetVisible(true).Once()
572617 mockTitleBar1.EXPECT().AddCssClass("active").Once()
@@ -641,7 +686,10 @@ func TestInsertPaneAfter_AtEnd(t *testing.T) {
641686 mockFactory, mockBox := setupMockFactory(t)
642687
643688 // 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
645693 mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
646694 mockContainer1.EXPECT().SetVisible(true).Once()
647695 mockTitleBar1.EXPECT().AddCssClass("active").Once()
@@ -679,7 +727,14 @@ func TestInsertPaneAfter_MaintainsOrder(t *testing.T) {
679727 titleBars := make([]*mocks.MockBoxWidget, 3)
680728
681729 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+
683738 titleBars[i].EXPECT().GetParent().Return(nil).Maybe()
684739 containers[i].EXPECT().SetVisible(mock.Anything).Maybe()
685740 titleBars[i].EXPECT().AddCssClass("active").Maybe()
@@ -727,7 +782,10 @@ func TestInsertPaneAfter_InvalidIndexClamped(t *testing.T) {
727782 mockFactory, mockBox := setupMockFactory(t)
728783
729784 // 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
731789 mockTitleBar1.EXPECT().GetParent().Return(nil).Maybe()
732790 mockContainer1.EXPECT().SetVisible(true).Once()
733791 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) (
1919 mockStackBox.EXPECT().SetHexpand(true).Once()
2020 mockStackBox.EXPECT().SetVexpand(true).Once()
2121
22- mockTitleBar, _, _, _, mockContainer := setupPaneMocks(t, mockFactory, mockStackBox)
22+ mockTitleBar, mockFavicon, mockOverlay, mockSpinner, mockContainer := setupPaneMocks(t, mockFactory, mockStackBox)
23+ _ = mockFavicon
24+ _ = mockOverlay
25+ _ = mockSpinner
2326 mockTitleBar.EXPECT().GetParent().Return(nil).Maybe()
2427 mockContainer.EXPECT().SetVisible(true).Once()
2528 mockTitleBar.EXPECT().AddCssClass("active").Once()
M internal/ui/theme/find_highlight.go +2 -1
@@ -9,7 +9,8 @@ import (
99 // GenerateFindHighlightCSS builds CSS to style find-in-page matches using the accent color.
1010 func GenerateFindHighlightCSS(p Palette) string {
1111 accent := strings.TrimSpace(p.Accent)
12- dim := rgbaFromHex(accent, 0.50)
12+ const highlightDimAlpha = 0.5
13+ dim := rgbaFromHex(accent, highlightDimAlpha)
1314 active := accent
1415
1516 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