detached a85da87

Merge pull request #59 from bnema/release/v0.22.0

Release v0.22.0
commit
a85da871fcfd30f4e7860ffdd71cf69b5817bba4
author
bnema · · 2025-12-28 20:08 UTC
committer
GitHub · · 2025-12-28 20:08 UTC
merge of
00c4c81 + 678f0cc · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.22.0
98 files · +13274 -496
M .mockery.yaml +5 -0
@@ -8,6 +8,10 @@ packages:
88 filename: mocks.go
99 pkgname: mocks
1010 interfaces:
11+ ConfigMigrator: {}
12+ ConfigSchemaProvider: {}
13+ DatabaseProvider: {}
14+ DiffFormatter: {}
1115 SessionLogger: {}
1216 SessionSpawner: {}
1317 TabListProvider: {}
@@ -51,5 +55,6 @@ packages:
5155 PaneViewFactory: {}
5256 PanedWidget: {}
5357 ProgressBarWidget: {}
58+ SpinnerWidget: {}
5459 Widget: {}
5560 WidgetFactory: {}
M CHANGELOG.md +48 -0
@@ -4,7 +4,55 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+## [0.22.0] - 2025-12-28
8+
9+### Added
10+- **Omnibox bang shortcuts UI**: Typing `!` in the omnibox now shows all configured bangs (search shortcuts) with filtering and Enter-to-autocomplete, plus a header badge when a bang is recognized.
11+- **Loading skeleton with app logo**: New panes show a centered 512x512 faded app logo with a spinner while loading, replacing the blank/white flash. Uses `--muted` theme color for the spinner.
12+- **App logo favicon for internal pages**: `dumb://` URLs now display the dumber logo as favicon in history, favorites, omnibox suggestions, and dmenu/rofi. WebUI and CLI tools share consistent branding.
13+- **Omnibox favorite indicator**: History items that are also bookmarked now display a yellowish left border and subtle background tint in the omnibox. Uses parallel async loading to avoid blocking browser startup.
14+- **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true).
15+- **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.
16+ - `dumber config status`: Shows config file path and number of new settings available.
17+ - `dumber config migrate`: Lists missing keys with types/defaults and adds them to config file.
18+ - GUI toast notification on startup when new settings are available (configurable via `update.notify_on_new_settings`).
19+- **CLI self-update command**: New `dumber update` command to check for and install updates from the command line.
20+ - Interactive spinner animation during check and download phases.
21+ - `--force` / `-f` flag to skip version check and reinstall the current version.
22+ - Reuses existing auto-update infrastructure (check, download, stage, apply on exit).
23+ - Theme-aware styling consistent with other CLI commands.
24+- **Config open command**: New `dumber config open` command to open the config file in your preferred editor. Uses `$EDITOR`, then `$VISUAL`, with fallback to nano/vim/vi.
25+- **Config schema command**: New `dumber config schema` command that displays all configuration keys with their types, defaults, descriptions, and valid values. Supports `--json` flag for machine-readable output.
26+- **CLI gen-docs command**: New `dumber gen-docs` command to generate documentation from CLI command definitions. Supports man pages (installed to `~/.local/share/man/man1/`) and markdown formats. Use `--format markdown` for markdown output or `--output` for custom directory.
27+- **Move pane to tab**: New pane-mode actions to move the active pane to another tab.
28+ - `Ctrl+P → m`: Opens a tab picker modal.
29+ - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed).
30+ - New config: `workspace.switch_to_tab_on_move` (default: true).
31+
32+### Changed
33+- **Cold start optimization**: Reduced startup time by ~150ms (29% faster) through parallel initialization.
34+ - New `internal/bootstrap` package encapsulates all startup orchestration.
35+ - `RunParallelInit()` runs 5 goroutines concurrently: directory resolution, pkg-config check, GStreamer check, theme manager creation, and SQLite WASM pre-compilation.
36+ - WebView pool prewarming creates one WebView synchronously so first `Acquire()` is instant.
37+ - Async session cleanup: stale session marking runs in background, doesn't block startup.
38+ - `StartupTimer` logs timing for each bootstrap phase (parallel init, DB/WebKit, app creation).
39+ - Database initialization runs in background goroutine while WebKit stack initializes on main thread.
40+ - Database path now resolved via `config.GetDatabaseFile()` following clean architecture.
41+ - Added connection pool settings optimized for SQLite (`SetMaxOpenConns(1)`, keep-alive).
42+ - Reduced `mmap_size` from 30GB to 256MB (reasonable for browser history DB).
43+ - Added `PRAGMA foreign_keys = ON` for referential integrity.
44+- **Startup deferrals**: Runtime/media checks and SQLite WASM precompile now run after first paint, and DB initialization uses lazy repositories when auto-restore is off to cut cold-start latency further.
45+- **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging.
46+
747 ### Fixed
48+- **Omnibox favorite toggle reactivity**: Fixed space key toggle in omnibox not updating UI reactively. Now properly toggles favorites (add if not favorite, remove if favorite) with immediate visual feedback (yellow indicator) and toast notifications. Business logic moved to `ManageFavoritesUseCase.Toggle()` for testability.
49+- **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping.
50+- **Config migrate removes deprecated settings**: Fixed `dumber config migrate` not removing deprecated settings from the config file. Previously deprecated keys were preserved but unused; now they are properly deleted. Supports TOML, YAML, and JSON config formats.
51+- **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting.
52+- **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode.
53+- **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes.
54+- **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews.
55+- Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.
856 - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag).
957
1058 ## [0.21.0] - 2025-12-26
M Makefile +6 -1
@@ -1,6 +1,6 @@
11 # Makefile for dumber (Clean Architecture - puregotk)
22
3-.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs
3+.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man
44
55 # Load local overrides from .env.local if present (Makefile syntax)
66 ifneq (,$(wildcard .env.local))
@@ -137,6 +137,11 @@ check: ## Check that all tools and dependencies are working
137137 @$(MAKE) test
138138 @echo "\nAll checks passed!"
139139
140+# Documentation
141+man: build-quick ## Install man pages to ~/.local/share/man/man1/
142+ @echo "Installing man pages..."
143+ $(DIST_DIR)/$(BINARY_NAME) gen-docs
144+
140145 # Native release targets
141146 .PHONY: release-snapshot release
142147
M README.md +15 -2
@@ -77,6 +77,8 @@ Some features are very alpha/experimental (notably consume-or-expel panes) and m
7777 | **↓** / **D** (in pane mode) | Split Down | Create new pane below |
7878 | **S** (in pane mode) | Stack Pane | Create stacked pane (Zellij-style) |
7979 | **X** (in pane mode) | Close Pane | Close current pane |
80+| **m** (in pane mode) | Move Pane To Tab | Opens a tab picker modal |
81+| **M** (in pane mode) | Move Pane To Next Tab | Moves to the next tab, creates new tab if needed |
8082 | **Enter** (in pane mode) | Confirm Action | Confirm pane operation |
8183 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |
8284 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
@@ -242,6 +244,9 @@ sudo apt install va-driver-all
242244 - `dumber browse "!g golang"` # search via bang shortcut
243245 - Show version information:
244246 - `dumber about` # version, commit, and build date
247+- Check for and install updates:
248+ - `dumber update` # check and install if available
249+ - `dumber update --force` # force reinstall (skips version check)
245250 - Launcher integration (dmenu‑style examples with favicon support):
246251 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`
247252 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
@@ -256,6 +261,10 @@ sudo apt install va-driver-all
256261 - Clean up data:
257262 - `dumber purge` # interactive selection (TUI)
258263 - `dumber purge --force` # remove everything (no prompts)
264+- Manage configuration:
265+ - `dumber config status` # show config path and migration availability
266+ - `dumber config migrate` # add missing default settings to config
267+ - `dumber config migrate --yes` # skip confirmation prompt
259268 - Manage sessions:
260269 - `dumber sessions` # interactive session browser (TUI)
261270 - `dumber sessions list` # list saved sessions
@@ -270,6 +279,10 @@ sudo apt install va-driver-all
270279 - `dumber logs -n 200 <session>` # show last N lines
271280 - `dumber logs clear` # clean up old log files
272281 - `dumber logs clear --all` # remove all log files
282+- Generate documentation:
283+ - `dumber gen-docs` # install man pages to ~/.local/share/man/man1/
284+ - `dumber gen-docs --format markdown` # generate markdown docs to ./docs/
285+ - `dumber gen-docs --output ./man` # generate to custom directory
273286
274287 ### Dmenu mode invocation
275288 You can invoke dmenu mode in two ways:
@@ -294,7 +307,7 @@ Each tab holds its own workspace. Each workspace can contain multiple panes (spl
294307 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode
295308 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout
296309
297-Tab mode provides visual feedback with an orange border indicator.
310+
298311
299312 ## Pane Management
300313
@@ -322,7 +335,7 @@ Dumber automatically saves your browser session (tabs, panes, split ratios, URLs
322335 4. **Restore**: Select an exited session to restore it in a new browser window
323336 5. **CLI Access**: Use `dumber sessions` for an interactive TUI, or `dumber sessions list/restore/delete` for scripting
324337
325-Session mode provides visual feedback with a purple border indicator.
338+
326339
327340 ## Configuration
328341
A assets/logo-32.png +0 -0 binary file: no text diff

binary file: no text diff

M assets/logo.svg +1 -1
@@ -1,5 +1,5 @@
11 <?xml version="1.0" encoding="UTF-8"?>
2-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512">
33 <defs>
44 <!-- Background gradient - deeper dark -->
55 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
M assets/webui_embed.go +5 -0
@@ -14,3 +14,8 @@ var WebUIAssets embed.FS
1414 //
1515 //go:embed logo.svg
1616 var LogoSVG []byte
17+
18+// LogoPNG32 contains the dumber logo as 32x32 PNG for CLI tools like dmenu/rofi.
19+//
20+//go:embed logo-32.png
21+var LogoPNG32 []byte
M cmd/dumber/main.go +193 -133
@@ -6,7 +6,6 @@ import (
66 "fmt"
77 "os"
88 "os/signal"
9- "path/filepath"
109 "runtime"
1110 "syscall"
1211
@@ -23,16 +22,14 @@ import (
2322 "github.com/bnema/dumber/internal/infrastructure/deps"
2423 "github.com/bnema/dumber/internal/infrastructure/favicon"
2524 "github.com/bnema/dumber/internal/infrastructure/idle"
26- "github.com/bnema/dumber/internal/infrastructure/media"
2725 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
2826 "github.com/bnema/dumber/internal/infrastructure/updater"
2927 "github.com/bnema/dumber/internal/logging"
3028 "github.com/bnema/dumber/internal/ui"
3129 "github.com/bnema/dumber/internal/ui/theme"
32- "github.com/rs/zerolog"
3330 )
3431
35-// Build-time variables (set via ldflags)
32+// Build-time variables (set via ldflags).
3633 var (
3734 version = "dev"
3835 commit = "unknown"
@@ -48,13 +45,10 @@ var restoreSessionID string
4845 func main() {
4946 // Run GUI mode for browse command
5047 if len(os.Args) > 1 && os.Args[1] == "browse" {
51- // Extract URL if provided
5248 if len(os.Args) > 2 {
5349 initialURL = os.Args[2]
5450 }
55- // Check for session restoration via environment variable
5651 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
57- // Strip "browse" and URL from args so GTK doesn't see them
5852 os.Args = os.Args[:1]
5953 os.Exit(runGUI())
6054 return
@@ -73,75 +67,58 @@ func main() {
7367 }
7468
7569 func runGUI() int {
76- // GTK requires all GTK calls to be made from the main thread
7770 runtime.LockOSThread()
71+ timer := bootstrap.NewStartupTimer()
7872
7973 cfg := initConfig()
74+ timer.Mark("config")
8075
81- // Apply optional /opt-style runtime prefix overrides (if configured)
82- deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
83-
84- bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
85- bootstrapLogger.Info().
86- Str("version", version).
87- Str("commit", commit).
88- Str("build_date", buildDate).
89- Msg("starting dumber")
76+ ctx := initStartupContext(cfg)
77+ timer.Mark("logger")
78+ bootstrapLog := logging.FromContext(ctx)
9079
91- ctx := logging.WithContext(context.Background(), bootstrapLogger)
80+ // Parallel phase: directories and theme setup
81+ initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
82+ Ctx: ctx,
83+ Config: cfg,
84+ })
85+ if err != nil {
86+ handleParallelInitError(ctx, err)
87+ return 1
88+ }
89+ timer.MarkDuration("parallel_phase", initResult.Duration)
9290
93- dataDir, cacheDir := resolveWebKitDirs(bootstrapLogger)
91+ needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore
9492
95- db, dbCleanup := openDatabase(ctx, bootstrapLogger, dataDir)
93+ stack, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB)
94+ if err != nil {
95+ bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit")
96+ }
9697 if dbCleanup != nil {
9798 defer dbCleanup()
9899 }
100+ timer.Mark("db_webkit_parallel")
99101
100- browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)
102+ browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB)
101103 if err != nil {
102- bootstrapLogger.Fatal().Err(err).Msg("failed to start session")
104+ bootstrapLog.Fatal().Err(err).Msg("failed to start session")
103105 }
104106 if browserSession.LogCleanup != nil {
105107 defer browserSession.LogCleanup()
106108 }
107- defer func() {
108- _ = browserSession.End(sessionCtx)
109- }()
109+ defer func() { _ = browserSession.End(sessionCtx) }()
110+ timer.Mark("session")
110111
111112 ctx = sessionCtx
112113 log := logging.FromContext(ctx)
113-
114- checkRuntimeRequirements(ctx, cfg, *log)
115- checkMediaRequirements(ctx, cfg, *log)
116-
117- themeManager := theme.NewManager(ctx, cfg)
118- stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, *log)
119- repos := createRepositories(db)
120-
121- stateDir, _ := config.GetStateDir()
122- buildInfo := build.Info{
123- Version: version,
124- Commit: commit,
125- BuildDate: buildDate,
126- GoVersion: runtime.Version(),
114+ if stack.MessageRouter != nil {
115+ stack.MessageRouter.SetBaseContext(ctx)
127116 }
128- useCases := createUseCases(repos, cfg, stateDir, buildInfo)
129117
130- // Auto-restore: find last restorable session if enabled and no explicit restore requested
131- if cfg.Session.AutoRestore && restoreSessionID == "" {
132- var out *usecase.GetLastRestorableSessionOutput
133- out, err = useCases.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{
134- ExcludeSessionID: browserSession.Session.ID,
135- })
136- if err != nil {
137- log.Warn().Err(err).Msg("auto-restore: failed to find restorable session")
138- } else if out.SessionID != "" {
139- restoreSessionID = string(out.SessionID)
140- log.Info().
141- Str("session_id", restoreSessionID).
142- Int("tabs", len(out.State.Tabs)).
143- Msg("auto-restore: found last session")
144- }
118+ // Repositories and use cases
119+ useCases := createUseCases(repos, cfg)
120+ if needsEagerDB {
121+ handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
145122 }
146123
147124 idleInhibitor := idle.NewPortalInhibitor(ctx)
@@ -150,29 +127,99 @@ func runGUI() int {
150127 _ = idleInhibitor.Close()
151128 }
152129 }()
153- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
130+ timer.Mark("use_cases")
154131
132+ // Build UI
133+ uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
134+ configureDeferredInit(uiDeps, cfg, browserSession)
155135 app, err := ui.New(uiDeps)
156136 if err != nil {
157137 log.Error().Err(err).Msg("failed to create application")
158138 return 1
159139 }
140+ timer.Mark("ui_deps")
141+ timer.Log(ctx)
160142
161- // Ensure sessions are closed on Ctrl+C / SIGTERM.
162- // Without this, the process may exit immediately and skip defers,
163- // leaving the DB session stuck in "active" state.
164- sigCh := make(chan os.Signal, 1)
165- signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
166- defer signal.Stop(sigCh)
167- go func() {
168- <-sigCh
169- log.Info().Msg("received interrupt, quitting")
170- app.Quit()
171- }()
143+ // Signal handling
144+ setupSignalHandler(ctx, app)
145+
146+ return app.Run(ctx, os.Args)
147+}
148+
149+func initStartupContext(cfg *config.Config) context.Context {
150+ deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
151+ bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat(
152+ cfg.Logging.Level,
153+ cfg.Logging.Format,
154+ logging.ConsoleTimeFormat,
155+ )
156+ bootstrapLogger.Info().
157+ Str("version", version).
158+ Str("commit", commit).
159+ Str("build_date", buildDate).
160+ Msg("starting dumber")
161+ ctx := logging.WithContext(context.Background(), bootstrapLogger)
162+ return ctx
163+}
164+
165+func initStackAndRepos(
166+ ctx context.Context,
167+ cfg *config.Config,
168+ initResult *bootstrap.ParallelInitResult,
169+ needsEagerDB bool,
170+) (bootstrap.WebKitStack, *repositories, func(), error) {
171+ if needsEagerDB {
172+ // Parallel phase 2: Database + WebKit stack initialize concurrently
173+ dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{
174+ Ctx: ctx,
175+ Config: cfg,
176+ DataDir: initResult.DataDir,
177+ CacheDir: initResult.CacheDir,
178+ ThemeManager: initResult.ThemeManager,
179+ })
180+ if err != nil {
181+ return bootstrap.WebKitStack{}, nil, nil, err
182+ }
183+ return dbWebKit.Stack, createRepositories(dbWebKit.DB), dbWebKit.DBCleanup, nil
184+ }
172185
173- // Run the application
174- exitCode := app.Run(ctx, os.Args)
175- return exitCode
186+ log := logging.FromContext(ctx)
187+ stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log)
188+
189+ lazyDB, err := bootstrap.CreateLazyDatabase()
190+ if err != nil {
191+ return stack, nil, nil, err
192+ }
193+ dbCleanup := func() { _ = lazyDB.Close() }
194+ return stack, createLazyRepositories(lazyDB), dbCleanup, nil
195+}
196+
197+func configureDeferredInit(
198+ uiDeps *ui.Dependencies,
199+ cfg *config.Config,
200+ session *bootstrap.BrowserSession,
201+) {
202+ if uiDeps == nil {
203+ return
204+ }
205+ uiDeps.OnFirstWebViewShown = func(cbCtx context.Context) {
206+ logger := logging.FromContext(cbCtx)
207+ bgCtx := logging.WithContext(context.Background(), *logger)
208+ go func() {
209+ result := bootstrap.RunDeferredInit(bootstrap.DeferredInitInput{
210+ Ctx: bgCtx,
211+ Config: cfg,
212+ })
213+ logDeferredInitResults(bgCtx, result)
214+ }()
215+ if session != nil && session.Persist != nil {
216+ go func() {
217+ if persistErr := session.Persist(bgCtx); persistErr != nil {
218+ logger.Error().Err(persistErr).Msg("deferred session persistence failed")
219+ }
220+ }()
221+ }
222+ }
176223 }
177224
178225 func initConfig() *config.Config {
@@ -183,77 +230,74 @@ func initConfig() *config.Config {
183230 return config.Get()
184231 }
185232
186-func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
187- probe := deps.NewPkgConfigProbe()
188- checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
189- runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
190- Prefix: cfg.Runtime.Prefix,
191- })
192- if err != nil {
193- logger.Fatal().Err(err).Msg("runtime requirements check failed")
233+func handleParallelInitError(ctx context.Context, err error) {
234+ log := logging.FromContext(ctx)
235+ if runtimeErr, ok := err.(*bootstrap.RuntimeRequirementsError); ok {
236+ runtimeErr.LogDetails(ctx)
237+ log.Fatal().Err(runtimeErr).
238+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
239+ Msg("runtime requirements not met")
194240 }
195- if runtimeOut.OK {
196- return
241+ log.Fatal().Err(err).Msg("initialization failed")
242+}
243+
244+func logDeferredInitResults(ctx context.Context, result bootstrap.DeferredInitResult) {
245+ log := logging.FromContext(ctx)
246+ if result.SQLiteErr != nil {
247+ log.Warn().Err(result.SQLiteErr).Msg("deferred sqlite wasm init failed")
197248 }
198- for _, c := range runtimeOut.Checks {
199- if c.Installed {
200- logger.Error().
201- Str("dependency", c.PkgConfigName).
202- Str("have", c.Version).
203- Str("need", c.RequiredVersion).
204- Bool("ok", c.MeetsRequirement).
205- Msg("runtime dependency")
249+ if result.RuntimeErr != nil {
250+ if runtimeErr, ok := result.RuntimeErr.(*bootstrap.RuntimeRequirementsError); ok {
251+ runtimeErr.LogDetails(ctx)
252+ log.Warn().Err(runtimeErr).
253+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
254+ Msg("runtime requirements not met")
206255 } else {
207- logger.Error().
208- Str("dependency", c.PkgConfigName).
209- Str("need", c.RequiredVersion).
210- Msg("runtime dependency missing")
256+ log.Warn().Err(result.RuntimeErr).Msg("runtime requirements check failed")
211257 }
212258 }
213- logger.Fatal().
214- Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
215- Msg("runtime requirements not met")
216-}
217-
218-func checkMediaRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
219- mediaDiagAdapter := media.New()
220- checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
221- if _, mediaErr := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
222- ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
223- }); mediaErr != nil {
224- logger.Fatal().Err(mediaErr).Msg("media requirements check failed")
259+ if result.MediaErr != nil {
260+ log.Warn().Err(result.MediaErr).Msg("media check failed")
225261 }
262+ log.Debug().Dur("duration", result.Duration).Msg("deferred init complete")
226263 }
227264
228-func resolveWebKitDirs(logger zerolog.Logger) (string, string) {
229- const cacheDirPerm = 0o755
230- dataDir, err := config.GetDataDir()
231- if err != nil {
232- logger.Fatal().Err(err).Msg("failed to resolve data directory")
265+func handleAutoRestore(
266+ ctx context.Context,
267+ cfg *config.Config,
268+ uc *useCases,
269+ currentSessionID entity.SessionID,
270+) {
271+ if !cfg.Session.AutoRestore || restoreSessionID != "" {
272+ return
233273 }
234- stateDir, err := config.GetStateDir()
274+ log := logging.FromContext(ctx)
275+ out, err := uc.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{
276+ ExcludeSessionID: currentSessionID,
277+ })
235278 if err != nil {
236- logger.Fatal().Err(err).Msg("failed to resolve state directory")
279+ log.Warn().Err(err).Msg("auto-restore: failed to find restorable session")
280+ return
237281 }
238- cacheDir := filepath.Join(stateDir, "webkit-cache")
239- if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {
240- logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory")
282+ if out.SessionID != "" {
283+ restoreSessionID = string(out.SessionID)
284+ log.Info().
285+ Str("session_id", restoreSessionID).
286+ Int("tabs", len(out.State.Tabs)).
287+ Msg("auto-restore: found last session")
241288 }
242- return dataDir, cacheDir
243289 }
244290
245-func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (*sql.DB, func()) {
246- dbPath := filepath.Join(dataDir, "dumber.db")
247- db, err := sqlite.NewConnection(ctx, dbPath)
248- if err != nil {
249- logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database")
250- }
251- cleanup := func() {
252- if err := sqlite.Close(db); err != nil {
253- logger.Error().Err(err).Msg("failed to close database")
254- }
255- }
256- return db, cleanup
291+func setupSignalHandler(ctx context.Context, app *ui.App) {
292+ log := logging.FromContext(ctx)
293+ sigCh := make(chan os.Signal, 1)
294+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
295+ go func() {
296+ sig := <-sigCh
297+ signal.Stop(sigCh)
298+ log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting")
299+ app.Quit()
300+ }()
257301 }
258302
259303 // repositories groups infrastructure layer repository implementations.
@@ -279,6 +323,18 @@ func createRepositories(db *sql.DB) *repositories {
279323 }
280324 }
281325
326+func createLazyRepositories(provider port.DatabaseProvider) *repositories {
327+ return &repositories{
328+ history: sqlite.NewLazyHistoryRepository(provider),
329+ favorite: sqlite.NewLazyFavoriteRepository(provider),
330+ folder: sqlite.NewLazyFolderRepository(provider),
331+ tag: sqlite.NewLazyTagRepository(provider),
332+ zoom: sqlite.NewLazyZoomRepository(provider),
333+ session: sqlite.NewLazySessionRepository(provider),
334+ sessionState: sqlite.NewLazySessionStateRepository(provider),
335+ }
336+}
337+
282338 // useCases groups application layer use case implementations.
283339 type useCases struct {
284340 tabs *usecase.ManageTabsUseCase
@@ -296,7 +352,7 @@ type useCases struct {
296352 favicon *favicon.Service
297353 }
298354
299-func createUseCases(repos *repositories, cfg *config.Config, stateDir string, buildInfo build.Info) *useCases {
355+func createUseCases(repos *repositories, cfg *config.Config) *useCases {
300356 const idAlphabetSize = 26
301357 idCounter := uint64(0)
302358 idGenerator := func() string {
@@ -306,13 +362,17 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu
306362
307363 clipboardAdapter := clipboard.New()
308364 faviconCacheDir, _ := config.GetFaviconCacheDir()
309- cacheDir, _ := config.GetXDGDirs()
365+ xdgDirs, _ := config.GetXDGDirs()
366+ stateDir, _ := config.GetStateDir()
310367 defaultZoom := cfg.DefaultWebpageZoom
311-
312- // Create LRU cache for zoom levels to avoid database queries on repeat visits
313368 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
314369
315- // Create update infrastructure
370+ buildInfo := build.Info{
371+ Version: version,
372+ Commit: commit,
373+ BuildDate: buildDate,
374+ GoVersion: runtime.Version(),
375+ }
316376 updateChecker := updater.NewGitHubChecker()
317377 updateDownloader := updater.NewGitHubDownloader()
318378 updateApplier := updater.NewApplier(stateDir)
@@ -328,7 +388,7 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu
328388 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
329389 lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),
330390 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
331- applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, cacheDir.CacheHome),
391+ applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),
332392 clipboard: clipboardAdapter,
333393 favicon: favicon.NewService(faviconCacheDir),
334394 }
M docs/CONFIG.md +51 -8
@@ -201,6 +201,7 @@ border = "#363636"
201201 | Key | Type | Default | Description |
202202 |-----|------|---------|-------------|
203203 | `workspace.new_pane_url` | string | `"about:blank"` | URL loaded for new panes/tabs (supports `http(s)://`, `dumb://`, `file://`, `about:`) |
204+| `workspace.switch_to_tab_on_move` | bool | `true` | When moving a pane to another tab, automatically switch to the destination tab |
204205
205206 **Example:**
206207 ```toml
@@ -225,6 +226,8 @@ split-up = ["arrowup", "u"]
225226 split-down = ["arrowdown", "d"]
226227 stack-pane = ["s"]
227228 close-pane = ["x"]
229+move-pane-to-tab = ["m"]
230+move-pane-to-next-tab = ["M", "shift+m"]
228231
229232 # Consume-or-expel (niri-style) - very alpha
230233 consume-or-expel-left = ["bracketleft", "["]
@@ -330,16 +333,28 @@ Notes:
330333 |-----|------|---------|-------------|
331334 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |
332335 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |
333-| `workspace.styling.pane_mode_border_width` | int | `4` | Pane mode border width (px) - Ctrl+P N overlay |
334-| `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) |
335-| `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay |
336-| `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |
337-| `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay |
338-| `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) |
339-| `workspace.styling.resize_mode_border_width` | int | `4` | Resize mode border width (px) |
340-| `workspace.styling.resize_mode_border_color` | string | `"#00D4AA"` | Resize mode border color (teal) |
336+| `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes |
337+| `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue) - used for border and toaster |
338+| `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster |
339+| `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |
340+| `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster |
341+| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show toaster notification when modal modes are active |
341342 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
342343
344+**Example:**
345+```toml
346+[workspace.styling]
347+border_width = 1
348+border_color = "@theme_selected_bg_color"
349+mode_border_width = 4
350+pane_mode_color = "#4A90E2" # Blue for pane mode
351+tab_mode_color = "#FFA500" # Orange for tab mode
352+session_mode_color = "#9B59B6" # Purple for session mode
353+resize_mode_color = "#00D4AA" # Teal for resize mode
354+mode_indicator_toaster_enabled = true
355+transition_duration = 120
356+```
357+
343358 ## Session
344359
345360 | Key | Type | Default | Description |
@@ -448,6 +463,34 @@ Notes:
448463 - Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases.
449464 - Domain whitelist is managed via the database (`content_whitelist` table), not the config file.
450465
466+## Update
467+
468+| Key | Type | Default | Description |
469+|-----|------|---------|-------------|
470+| `update.enable_on_startup` | bool | `true` | Check for updates when browser starts |
471+| `update.auto_download` | bool | `false` | Automatically download updates in background |
472+| `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available |
473+
474+**Example:**
475+```toml
476+[update]
477+enable_on_startup = true # Check for updates on startup
478+auto_download = false # Don't auto-download (prompt instead)
479+notify_on_new_settings = true # Show toast when config migration available
480+```
481+
482+**CLI Commands:**
483+```bash
484+# Check config status and available migrations
485+dumber config status
486+
487+# Add missing settings with default values
488+dumber config migrate
489+
490+# Skip confirmation prompt
491+dumber config migrate --yes
492+```
493+
451494 ## Environment Variables
452495
453496 All config values can be overridden via environment variables with the prefix `DUMBER_`:
M go.mod +5 -3
@@ -8,14 +8,17 @@ require (
88 github.com/charmbracelet/bubbletea v1.3.10
99 github.com/charmbracelet/lipgloss v1.1.0
1010 github.com/fsnotify/fsnotify v1.9.0
11+ github.com/godbus/dbus/v5 v5.2.1
1112 github.com/jwijenbergh/puregotk v0.0.0-20251222174431-73930527faa3
1213 github.com/ncruces/go-sqlite3 v0.30.3
14+ github.com/pelletier/go-toml/v2 v2.2.4
1315 github.com/pressly/goose/v3 v3.26.0
1416 github.com/rs/zerolog v1.33.0
1517 github.com/spf13/cobra v1.10.2
1618 github.com/spf13/viper v1.21.0
1719 github.com/stretchr/testify v1.11.1
1820 golang.org/x/image v0.34.0
21+ golang.org/x/sys v0.38.0
1922 )
2023
2124 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3
@@ -27,10 +30,10 @@ require (
2730 github.com/charmbracelet/x/ansi v0.10.1 // indirect
2831 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
2932 github.com/charmbracelet/x/term v0.2.1 // indirect
33+ github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
3034 github.com/davecgh/go-spew v1.1.1 // indirect
3135 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
3236 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
33- github.com/godbus/dbus/v5 v5.2.1 // indirect
3437 github.com/inconshreveable/mousetrap v1.1.0 // indirect
3538 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect
3639 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
@@ -43,9 +46,9 @@ require (
4346 github.com/muesli/cancelreader v0.2.2 // indirect
4447 github.com/muesli/termenv v0.16.0 // indirect
4548 github.com/ncruces/julianday v1.0.0 // indirect
46- github.com/pelletier/go-toml/v2 v2.2.4 // indirect
4749 github.com/pmezard/go-difflib v1.0.0 // indirect
4850 github.com/rivo/uniseg v0.4.7 // indirect
51+ github.com/russross/blackfriday/v2 v2.1.0 // indirect
4952 github.com/sagikazarmark/locafero v0.11.0 // indirect
5053 github.com/sahilm/fuzzy v0.1.1 // indirect
5154 github.com/sethvargo/go-retry v0.3.0 // indirect
@@ -60,7 +63,6 @@ require (
6063 go.uber.org/multierr v1.11.0 // indirect
6164 go.yaml.in/yaml/v3 v3.0.4 // indirect
6265 golang.org/x/sync v0.19.0 // indirect
63- golang.org/x/sys v0.38.0 // indirect
6466 golang.org/x/text v0.32.0 // indirect
6567 gopkg.in/yaml.v3 v3.0.1 // indirect
6668 )
M go.sum +2 -0 generated file: collapsed
A internal/application/port/config_migrator.go +81 -0
@@ -0,0 +1,81 @@
1+package port
2+
3+// MigrationResult contains the result of a config migration check.
4+type MigrationResult struct {
5+ // MissingKeys contains the keys that exist in defaults but not in user config.
6+ MissingKeys []string
7+ // ConfigFile is the path to the user's config file.
8+ ConfigFile string
9+}
10+
11+// KeyInfo contains metadata about a config key for display purposes.
12+type KeyInfo struct {
13+ // Key is the dot-notation key path (e.g., "update.enable_on_startup").
14+ Key string
15+ // Type is the Go type of the value (e.g., "bool", "int", "string").
16+ Type string
17+ // DefaultValue is a string representation of the default value.
18+ DefaultValue string
19+}
20+
21+// KeyChangeType indicates the type of config key change.
22+type KeyChangeType int
23+
24+const (
25+ // KeyChangeAdded indicates a new key was added to defaults.
26+ KeyChangeAdded KeyChangeType = iota
27+ // KeyChangeRemoved indicates a key in user config no longer exists in defaults.
28+ KeyChangeRemoved
29+ // KeyChangeRenamed indicates a key was renamed (detected via similarity).
30+ KeyChangeRenamed
31+)
32+
33+// String returns a display symbol for the change type.
34+func (t KeyChangeType) String() string {
35+ switch t {
36+ case KeyChangeAdded:
37+ return "+"
38+ case KeyChangeRemoved:
39+ return "-"
40+ case KeyChangeRenamed:
41+ return "~"
42+ default:
43+ return "?"
44+ }
45+}
46+
47+// KeyChange represents a detected change between user config and defaults.
48+type KeyChange struct {
49+ Type KeyChangeType // Type of change
50+ OldKey string // Old key name (for removed/renamed)
51+ NewKey string // New key name (for added/renamed)
52+ OldValue string // Old value (for renamed/consolidated)
53+ NewValue string // New/default value
54+}
55+
56+// ConfigMigrator checks for and applies config migrations.
57+type ConfigMigrator interface {
58+ // CheckMigration checks if user config is missing any default keys.
59+ // Returns nil if no migration is needed (config file doesn't exist or is complete).
60+ CheckMigration() (*MigrationResult, error)
61+
62+ // DetectChanges analyzes user config and returns all detected changes.
63+ // This provides a detailed diff-like view of what migration would do.
64+ DetectChanges() ([]KeyChange, error)
65+
66+ // Migrate adds missing default keys and removes deprecated keys from the user's config file.
67+ // Returns the list of keys that were added/renamed/removed.
68+ Migrate() ([]string, error)
69+
70+ // GetKeyInfo returns detailed information about a config key.
71+ GetKeyInfo(key string) KeyInfo
72+
73+ // GetConfigFile returns the path to the user's config file.
74+ GetConfigFile() (string, error)
75+}
76+
77+// DiffFormatter formats config changes for display.
78+type DiffFormatter interface {
79+ // FormatChangesAsDiff returns changes formatted as a diff-like string for display.
80+ FormatChangesAsDiff(changes []KeyChange) string
81+}
A internal/application/port/config_schema.go +9 -0
@@ -0,0 +1,9 @@
1+package port
2+
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// ConfigSchemaProvider provides configuration schema information.
6+type ConfigSchemaProvider interface {
7+ // GetSchema returns all configuration keys with their metadata.
8+ GetSchema() []entity.ConfigKeyInfo
9+}
A internal/application/port/database.go +25 -0
@@ -0,0 +1,25 @@
1+// Package port defines interfaces for infrastructure adapters.
2+package port
3+
4+import (
5+ "context"
6+ "database/sql"
7+)
8+
9+// DatabaseProvider provides access to the database connection.
10+// Implementations may initialize the database lazily on first access.
11+//
12+// Currently used by the lazy repository infrastructure (sqlite.LazyDB and lazy_repos.go),
13+// which is kept for potential future optimization scenarios where deferring database
14+// initialization past first paint provides latency benefits.
15+type DatabaseProvider interface {
16+ // DB returns the database connection, initializing it if necessary.
17+ // Returns an error if initialization fails.
18+ DB(ctx context.Context) (*sql.DB, error)
19+
20+ // Close closes the database connection if it was initialized.
21+ Close() error
22+
23+ // IsInitialized returns true if the database has been initialized.
24+ IsInitialized() bool
25+}
M internal/application/port/mocks/mocks.go +625 -0
@@ -6,6 +6,7 @@ package mocks
66
77 import (
88 "context"
9+ "database/sql"
910
1011 "github.com/bnema/dumber/internal/application/port"
1112 "github.com/bnema/dumber/internal/domain/entity"
@@ -13,6 +14,630 @@ import (
1314 mock "github.com/stretchr/testify/mock"
1415 )
1516
17+// NewMockConfigMigrator creates a new instance of MockConfigMigrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
18+// The first argument is typically a *testing.T value.
19+func NewMockConfigMigrator(t interface {
20+ mock.TestingT
21+ Cleanup(func())
22+}) *MockConfigMigrator {
23+ mock := &MockConfigMigrator{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() { mock.AssertExpectations(t) })
27+
28+ return mock
29+}
30+
31+// MockConfigMigrator is an autogenerated mock type for the ConfigMigrator type
32+type MockConfigMigrator struct {
33+ mock.Mock
34+}
35+
36+type MockConfigMigrator_Expecter struct {
37+ mock *mock.Mock
38+}
39+
40+func (_m *MockConfigMigrator) EXPECT() *MockConfigMigrator_Expecter {
41+ return &MockConfigMigrator_Expecter{mock: &_m.Mock}
42+}
43+
44+// CheckMigration provides a mock function for the type MockConfigMigrator
45+func (_mock *MockConfigMigrator) CheckMigration() (*port.MigrationResult, error) {
46+ ret := _mock.Called()
47+
48+ if len(ret) == 0 {
49+ panic("no return value specified for CheckMigration")
50+ }
51+
52+ var r0 *port.MigrationResult
53+ var r1 error
54+ if returnFunc, ok := ret.Get(0).(func() (*port.MigrationResult, error)); ok {
55+ return returnFunc()
56+ }
57+ if returnFunc, ok := ret.Get(0).(func() *port.MigrationResult); ok {
58+ r0 = returnFunc()
59+ } else {
60+ if ret.Get(0) != nil {
61+ r0 = ret.Get(0).(*port.MigrationResult)
62+ }
63+ }
64+ if returnFunc, ok := ret.Get(1).(func() error); ok {
65+ r1 = returnFunc()
66+ } else {
67+ r1 = ret.Error(1)
68+ }
69+ return r0, r1
70+}
71+
72+// MockConfigMigrator_CheckMigration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckMigration'
73+type MockConfigMigrator_CheckMigration_Call struct {
74+ *mock.Call
75+}
76+
77+// CheckMigration is a helper method to define mock.On call
78+func (_e *MockConfigMigrator_Expecter) CheckMigration() *MockConfigMigrator_CheckMigration_Call {
79+ return &MockConfigMigrator_CheckMigration_Call{Call: _e.mock.On("CheckMigration")}
80+}
81+
82+func (_c *MockConfigMigrator_CheckMigration_Call) Run(run func()) *MockConfigMigrator_CheckMigration_Call {
83+ _c.Call.Run(func(args mock.Arguments) {
84+ run()
85+ })
86+ return _c
87+}
88+
89+func (_c *MockConfigMigrator_CheckMigration_Call) Return(migrationResult *port.MigrationResult, err error) *MockConfigMigrator_CheckMigration_Call {
90+ _c.Call.Return(migrationResult, err)
91+ return _c
92+}
93+
94+func (_c *MockConfigMigrator_CheckMigration_Call) RunAndReturn(run func() (*port.MigrationResult, error)) *MockConfigMigrator_CheckMigration_Call {
95+ _c.Call.Return(run)
96+ return _c
97+}
98+
99+// DetectChanges provides a mock function for the type MockConfigMigrator
100+func (_mock *MockConfigMigrator) DetectChanges() ([]port.KeyChange, error) {
101+ ret := _mock.Called()
102+
103+ if len(ret) == 0 {
104+ panic("no return value specified for DetectChanges")
105+ }
106+
107+ var r0 []port.KeyChange
108+ var r1 error
109+ if returnFunc, ok := ret.Get(0).(func() ([]port.KeyChange, error)); ok {
110+ return returnFunc()
111+ }
112+ if returnFunc, ok := ret.Get(0).(func() []port.KeyChange); ok {
113+ r0 = returnFunc()
114+ } else {
115+ if ret.Get(0) != nil {
116+ r0 = ret.Get(0).([]port.KeyChange)
117+ }
118+ }
119+ if returnFunc, ok := ret.Get(1).(func() error); ok {
120+ r1 = returnFunc()
121+ } else {
122+ r1 = ret.Error(1)
123+ }
124+ return r0, r1
125+}
126+
127+// MockConfigMigrator_DetectChanges_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetectChanges'
128+type MockConfigMigrator_DetectChanges_Call struct {
129+ *mock.Call
130+}
131+
132+// DetectChanges is a helper method to define mock.On call
133+func (_e *MockConfigMigrator_Expecter) DetectChanges() *MockConfigMigrator_DetectChanges_Call {
134+ return &MockConfigMigrator_DetectChanges_Call{Call: _e.mock.On("DetectChanges")}
135+}
136+
137+func (_c *MockConfigMigrator_DetectChanges_Call) Run(run func()) *MockConfigMigrator_DetectChanges_Call {
138+ _c.Call.Run(func(args mock.Arguments) {
139+ run()
140+ })
141+ return _c
142+}
143+
144+func (_c *MockConfigMigrator_DetectChanges_Call) Return(keyChanges []port.KeyChange, err error) *MockConfigMigrator_DetectChanges_Call {
145+ _c.Call.Return(keyChanges, err)
146+ return _c
147+}
148+
149+func (_c *MockConfigMigrator_DetectChanges_Call) RunAndReturn(run func() ([]port.KeyChange, error)) *MockConfigMigrator_DetectChanges_Call {
150+ _c.Call.Return(run)
151+ return _c
152+}
153+
154+// GetConfigFile provides a mock function for the type MockConfigMigrator
155+func (_mock *MockConfigMigrator) GetConfigFile() (string, error) {
156+ ret := _mock.Called()
157+
158+ if len(ret) == 0 {
159+ panic("no return value specified for GetConfigFile")
160+ }
161+
162+ var r0 string
163+ var r1 error
164+ if returnFunc, ok := ret.Get(0).(func() (string, error)); ok {
165+ return returnFunc()
166+ }
167+ if returnFunc, ok := ret.Get(0).(func() string); ok {
168+ r0 = returnFunc()
169+ } else {
170+ r0 = ret.Get(0).(string)
171+ }
172+ if returnFunc, ok := ret.Get(1).(func() error); ok {
173+ r1 = returnFunc()
174+ } else {
175+ r1 = ret.Error(1)
176+ }
177+ return r0, r1
178+}
179+
180+// MockConfigMigrator_GetConfigFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfigFile'
181+type MockConfigMigrator_GetConfigFile_Call struct {
182+ *mock.Call
183+}
184+
185+// GetConfigFile is a helper method to define mock.On call
186+func (_e *MockConfigMigrator_Expecter) GetConfigFile() *MockConfigMigrator_GetConfigFile_Call {
187+ return &MockConfigMigrator_GetConfigFile_Call{Call: _e.mock.On("GetConfigFile")}
188+}
189+
190+func (_c *MockConfigMigrator_GetConfigFile_Call) Run(run func()) *MockConfigMigrator_GetConfigFile_Call {
191+ _c.Call.Run(func(args mock.Arguments) {
192+ run()
193+ })
194+ return _c
195+}
196+
197+func (_c *MockConfigMigrator_GetConfigFile_Call) Return(s string, err error) *MockConfigMigrator_GetConfigFile_Call {
198+ _c.Call.Return(s, err)
199+ return _c
200+}
201+
202+func (_c *MockConfigMigrator_GetConfigFile_Call) RunAndReturn(run func() (string, error)) *MockConfigMigrator_GetConfigFile_Call {
203+ _c.Call.Return(run)
204+ return _c
205+}
206+
207+// GetKeyInfo provides a mock function for the type MockConfigMigrator
208+func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo {
209+ ret := _mock.Called(key)
210+
211+ if len(ret) == 0 {
212+ panic("no return value specified for GetKeyInfo")
213+ }
214+
215+ var r0 port.KeyInfo
216+ if returnFunc, ok := ret.Get(0).(func(string) port.KeyInfo); ok {
217+ r0 = returnFunc(key)
218+ } else {
219+ r0 = ret.Get(0).(port.KeyInfo)
220+ }
221+ return r0
222+}
223+
224+// MockConfigMigrator_GetKeyInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetKeyInfo'
225+type MockConfigMigrator_GetKeyInfo_Call struct {
226+ *mock.Call
227+}
228+
229+// GetKeyInfo is a helper method to define mock.On call
230+// - key string
231+func (_e *MockConfigMigrator_Expecter) GetKeyInfo(key interface{}) *MockConfigMigrator_GetKeyInfo_Call {
232+ return &MockConfigMigrator_GetKeyInfo_Call{Call: _e.mock.On("GetKeyInfo", key)}
233+}
234+
235+func (_c *MockConfigMigrator_GetKeyInfo_Call) Run(run func(key string)) *MockConfigMigrator_GetKeyInfo_Call {
236+ _c.Call.Run(func(args mock.Arguments) {
237+ var arg0 string
238+ if args[0] != nil {
239+ arg0 = args[0].(string)
240+ }
241+ run(
242+ arg0,
243+ )
244+ })
245+ return _c
246+}
247+
248+func (_c *MockConfigMigrator_GetKeyInfo_Call) Return(keyInfo port.KeyInfo) *MockConfigMigrator_GetKeyInfo_Call {
249+ _c.Call.Return(keyInfo)
250+ return _c
251+}
252+
253+func (_c *MockConfigMigrator_GetKeyInfo_Call) RunAndReturn(run func(key string) port.KeyInfo) *MockConfigMigrator_GetKeyInfo_Call {
254+ _c.Call.Return(run)
255+ return _c
256+}
257+
258+// Migrate provides a mock function for the type MockConfigMigrator
259+func (_mock *MockConfigMigrator) Migrate() ([]string, error) {
260+ ret := _mock.Called()
261+
262+ if len(ret) == 0 {
263+ panic("no return value specified for Migrate")
264+ }
265+
266+ var r0 []string
267+ var r1 error
268+ if returnFunc, ok := ret.Get(0).(func() ([]string, error)); ok {
269+ return returnFunc()
270+ }
271+ if returnFunc, ok := ret.Get(0).(func() []string); ok {
272+ r0 = returnFunc()
273+ } else {
274+ if ret.Get(0) != nil {
275+ r0 = ret.Get(0).([]string)
276+ }
277+ }
278+ if returnFunc, ok := ret.Get(1).(func() error); ok {
279+ r1 = returnFunc()
280+ } else {
281+ r1 = ret.Error(1)
282+ }
283+ return r0, r1
284+}
285+
286+// MockConfigMigrator_Migrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Migrate'
287+type MockConfigMigrator_Migrate_Call struct {
288+ *mock.Call
289+}
290+
291+// Migrate is a helper method to define mock.On call
292+func (_e *MockConfigMigrator_Expecter) Migrate() *MockConfigMigrator_Migrate_Call {
293+ return &MockConfigMigrator_Migrate_Call{Call: _e.mock.On("Migrate")}
294+}
295+
296+func (_c *MockConfigMigrator_Migrate_Call) Run(run func()) *MockConfigMigrator_Migrate_Call {
297+ _c.Call.Run(func(args mock.Arguments) {
298+ run()
299+ })
300+ return _c
301+}
302+
303+func (_c *MockConfigMigrator_Migrate_Call) Return(strings []string, err error) *MockConfigMigrator_Migrate_Call {
304+ _c.Call.Return(strings, err)
305+ return _c
306+}
307+
308+func (_c *MockConfigMigrator_Migrate_Call) RunAndReturn(run func() ([]string, error)) *MockConfigMigrator_Migrate_Call {
309+ _c.Call.Return(run)
310+ return _c
311+}
312+
313+// NewMockDiffFormatter creates a new instance of MockDiffFormatter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
314+// The first argument is typically a *testing.T value.
315+func NewMockDiffFormatter(t interface {
316+ mock.TestingT
317+ Cleanup(func())
318+}) *MockDiffFormatter {
319+ mock := &MockDiffFormatter{}
320+ mock.Mock.Test(t)
321+
322+ t.Cleanup(func() { mock.AssertExpectations(t) })
323+
324+ return mock
325+}
326+
327+// MockDiffFormatter is an autogenerated mock type for the DiffFormatter type
328+type MockDiffFormatter struct {
329+ mock.Mock
330+}
331+
332+type MockDiffFormatter_Expecter struct {
333+ mock *mock.Mock
334+}
335+
336+func (_m *MockDiffFormatter) EXPECT() *MockDiffFormatter_Expecter {
337+ return &MockDiffFormatter_Expecter{mock: &_m.Mock}
338+}
339+
340+// FormatChangesAsDiff provides a mock function for the type MockDiffFormatter
341+func (_mock *MockDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string {
342+ ret := _mock.Called(changes)
343+
344+ if len(ret) == 0 {
345+ panic("no return value specified for FormatChangesAsDiff")
346+ }
347+
348+ var r0 string
349+ if returnFunc, ok := ret.Get(0).(func([]port.KeyChange) string); ok {
350+ r0 = returnFunc(changes)
351+ } else {
352+ r0 = ret.Get(0).(string)
353+ }
354+ return r0
355+}
356+
357+// MockDiffFormatter_FormatChangesAsDiff_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FormatChangesAsDiff'
358+type MockDiffFormatter_FormatChangesAsDiff_Call struct {
359+ *mock.Call
360+}
361+
362+// FormatChangesAsDiff is a helper method to define mock.On call
363+// - changes []port.KeyChange
364+func (_e *MockDiffFormatter_Expecter) FormatChangesAsDiff(changes interface{}) *MockDiffFormatter_FormatChangesAsDiff_Call {
365+ return &MockDiffFormatter_FormatChangesAsDiff_Call{Call: _e.mock.On("FormatChangesAsDiff", changes)}
366+}
367+
368+func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Run(run func(changes []port.KeyChange)) *MockDiffFormatter_FormatChangesAsDiff_Call {
369+ _c.Call.Run(func(args mock.Arguments) {
370+ var arg0 []port.KeyChange
371+ if args[0] != nil {
372+ arg0 = args[0].([]port.KeyChange)
373+ }
374+ run(
375+ arg0,
376+ )
377+ })
378+ return _c
379+}
380+
381+func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Return(s string) *MockDiffFormatter_FormatChangesAsDiff_Call {
382+ _c.Call.Return(s)
383+ return _c
384+}
385+
386+func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) RunAndReturn(run func(changes []port.KeyChange) string) *MockDiffFormatter_FormatChangesAsDiff_Call {
387+ _c.Call.Return(run)
388+ return _c
389+}
390+
391+// NewMockConfigSchemaProvider creates a new instance of MockConfigSchemaProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
392+// The first argument is typically a *testing.T value.
393+func NewMockConfigSchemaProvider(t interface {
394+ mock.TestingT
395+ Cleanup(func())
396+}) *MockConfigSchemaProvider {
397+ mock := &MockConfigSchemaProvider{}
398+ mock.Mock.Test(t)
399+
400+ t.Cleanup(func() { mock.AssertExpectations(t) })
401+
402+ return mock
403+}
404+
405+// MockConfigSchemaProvider is an autogenerated mock type for the ConfigSchemaProvider type
406+type MockConfigSchemaProvider struct {
407+ mock.Mock
408+}
409+
410+type MockConfigSchemaProvider_Expecter struct {
411+ mock *mock.Mock
412+}
413+
414+func (_m *MockConfigSchemaProvider) EXPECT() *MockConfigSchemaProvider_Expecter {
415+ return &MockConfigSchemaProvider_Expecter{mock: &_m.Mock}
416+}
417+
418+// GetSchema provides a mock function for the type MockConfigSchemaProvider
419+func (_mock *MockConfigSchemaProvider) GetSchema() []entity.ConfigKeyInfo {
420+ ret := _mock.Called()
421+
422+ if len(ret) == 0 {
423+ panic("no return value specified for GetSchema")
424+ }
425+
426+ var r0 []entity.ConfigKeyInfo
427+ if returnFunc, ok := ret.Get(0).(func() []entity.ConfigKeyInfo); ok {
428+ r0 = returnFunc()
429+ } else {
430+ if ret.Get(0) != nil {
431+ r0 = ret.Get(0).([]entity.ConfigKeyInfo)
432+ }
433+ }
434+ return r0
435+}
436+
437+// MockConfigSchemaProvider_GetSchema_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSchema'
438+type MockConfigSchemaProvider_GetSchema_Call struct {
439+ *mock.Call
440+}
441+
442+// GetSchema is a helper method to define mock.On call
443+func (_e *MockConfigSchemaProvider_Expecter) GetSchema() *MockConfigSchemaProvider_GetSchema_Call {
444+ return &MockConfigSchemaProvider_GetSchema_Call{Call: _e.mock.On("GetSchema")}
445+}
446+
447+func (_c *MockConfigSchemaProvider_GetSchema_Call) Run(run func()) *MockConfigSchemaProvider_GetSchema_Call {
448+ _c.Call.Run(func(args mock.Arguments) {
449+ run()
450+ })
451+ return _c
452+}
453+
454+func (_c *MockConfigSchemaProvider_GetSchema_Call) Return(configKeyInfos []entity.ConfigKeyInfo) *MockConfigSchemaProvider_GetSchema_Call {
455+ _c.Call.Return(configKeyInfos)
456+ return _c
457+}
458+
459+func (_c *MockConfigSchemaProvider_GetSchema_Call) RunAndReturn(run func() []entity.ConfigKeyInfo) *MockConfigSchemaProvider_GetSchema_Call {
460+ _c.Call.Return(run)
461+ return _c
462+}
463+
464+// NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
465+// The first argument is typically a *testing.T value.
466+func NewMockDatabaseProvider(t interface {
467+ mock.TestingT
468+ Cleanup(func())
469+}) *MockDatabaseProvider {
470+ mock := &MockDatabaseProvider{}
471+ mock.Mock.Test(t)
472+
473+ t.Cleanup(func() { mock.AssertExpectations(t) })
474+
475+ return mock
476+}
477+
478+// MockDatabaseProvider is an autogenerated mock type for the DatabaseProvider type
479+type MockDatabaseProvider struct {
480+ mock.Mock
481+}
482+
483+type MockDatabaseProvider_Expecter struct {
484+ mock *mock.Mock
485+}
486+
487+func (_m *MockDatabaseProvider) EXPECT() *MockDatabaseProvider_Expecter {
488+ return &MockDatabaseProvider_Expecter{mock: &_m.Mock}
489+}
490+
491+// Close provides a mock function for the type MockDatabaseProvider
492+func (_mock *MockDatabaseProvider) Close() error {
493+ ret := _mock.Called()
494+
495+ if len(ret) == 0 {
496+ panic("no return value specified for Close")
497+ }
498+
499+ var r0 error
500+ if returnFunc, ok := ret.Get(0).(func() error); ok {
501+ r0 = returnFunc()
502+ } else {
503+ r0 = ret.Error(0)
504+ }
505+ return r0
506+}
507+
508+// MockDatabaseProvider_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
509+type MockDatabaseProvider_Close_Call struct {
510+ *mock.Call
511+}
512+
513+// Close is a helper method to define mock.On call
514+func (_e *MockDatabaseProvider_Expecter) Close() *MockDatabaseProvider_Close_Call {
515+ return &MockDatabaseProvider_Close_Call{Call: _e.mock.On("Close")}
516+}
517+
518+func (_c *MockDatabaseProvider_Close_Call) Run(run func()) *MockDatabaseProvider_Close_Call {
519+ _c.Call.Run(func(args mock.Arguments) {
520+ run()
521+ })
522+ return _c
523+}
524+
525+func (_c *MockDatabaseProvider_Close_Call) Return(err error) *MockDatabaseProvider_Close_Call {
526+ _c.Call.Return(err)
527+ return _c
528+}
529+
530+func (_c *MockDatabaseProvider_Close_Call) RunAndReturn(run func() error) *MockDatabaseProvider_Close_Call {
531+ _c.Call.Return(run)
532+ return _c
533+}
534+
535+// DB provides a mock function for the type MockDatabaseProvider
536+func (_mock *MockDatabaseProvider) DB(ctx context.Context) (*sql.DB, error) {
537+ ret := _mock.Called(ctx)
538+
539+ if len(ret) == 0 {
540+ panic("no return value specified for DB")
541+ }
542+
543+ var r0 *sql.DB
544+ var r1 error
545+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*sql.DB, error)); ok {
546+ return returnFunc(ctx)
547+ }
548+ if returnFunc, ok := ret.Get(0).(func(context.Context) *sql.DB); ok {
549+ r0 = returnFunc(ctx)
550+ } else {
551+ if ret.Get(0) != nil {
552+ r0 = ret.Get(0).(*sql.DB)
553+ }
554+ }
555+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
556+ r1 = returnFunc(ctx)
557+ } else {
558+ r1 = ret.Error(1)
559+ }
560+ return r0, r1
561+}
562+
563+// MockDatabaseProvider_DB_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DB'
564+type MockDatabaseProvider_DB_Call struct {
565+ *mock.Call
566+}
567+
568+// DB is a helper method to define mock.On call
569+// - ctx context.Context
570+func (_e *MockDatabaseProvider_Expecter) DB(ctx interface{}) *MockDatabaseProvider_DB_Call {
571+ return &MockDatabaseProvider_DB_Call{Call: _e.mock.On("DB", ctx)}
572+}
573+
574+func (_c *MockDatabaseProvider_DB_Call) Run(run func(ctx context.Context)) *MockDatabaseProvider_DB_Call {
575+ _c.Call.Run(func(args mock.Arguments) {
576+ var arg0 context.Context
577+ if args[0] != nil {
578+ arg0 = args[0].(context.Context)
579+ }
580+ run(
581+ arg0,
582+ )
583+ })
584+ return _c
585+}
586+
587+func (_c *MockDatabaseProvider_DB_Call) Return(dB *sql.DB, err error) *MockDatabaseProvider_DB_Call {
588+ _c.Call.Return(dB, err)
589+ return _c
590+}
591+
592+func (_c *MockDatabaseProvider_DB_Call) RunAndReturn(run func(ctx context.Context) (*sql.DB, error)) *MockDatabaseProvider_DB_Call {
593+ _c.Call.Return(run)
594+ return _c
595+}
596+
597+// IsInitialized provides a mock function for the type MockDatabaseProvider
598+func (_mock *MockDatabaseProvider) IsInitialized() bool {
599+ ret := _mock.Called()
600+
601+ if len(ret) == 0 {
602+ panic("no return value specified for IsInitialized")
603+ }
604+
605+ var r0 bool
606+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
607+ r0 = returnFunc()
608+ } else {
609+ r0 = ret.Get(0).(bool)
610+ }
611+ return r0
612+}
613+
614+// MockDatabaseProvider_IsInitialized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsInitialized'
615+type MockDatabaseProvider_IsInitialized_Call struct {
616+ *mock.Call
617+}
618+
619+// IsInitialized is a helper method to define mock.On call
620+func (_e *MockDatabaseProvider_Expecter) IsInitialized() *MockDatabaseProvider_IsInitialized_Call {
621+ return &MockDatabaseProvider_IsInitialized_Call{Call: _e.mock.On("IsInitialized")}
622+}
623+
624+func (_c *MockDatabaseProvider_IsInitialized_Call) Run(run func()) *MockDatabaseProvider_IsInitialized_Call {
625+ _c.Call.Run(func(args mock.Arguments) {
626+ run()
627+ })
628+ return _c
629+}
630+
631+func (_c *MockDatabaseProvider_IsInitialized_Call) Return(b bool) *MockDatabaseProvider_IsInitialized_Call {
632+ _c.Call.Return(b)
633+ return _c
634+}
635+
636+func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool) *MockDatabaseProvider_IsInitialized_Call {
637+ _c.Call.Return(run)
638+ return _c
639+}
640+
16641 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17642 // The first argument is typically a *testing.T value.
18643 func NewMockSessionLogger(t interface {
M internal/application/port/xdg.go +4 -0
@@ -10,4 +10,8 @@ type XDGPaths interface {
1010 FilterJSONDir() (string, error)
1111 FilterStoreDir() (string, error)
1212 FilterCacheDir() (string, error)
13+
14+ // ManDir returns the user man page directory (man1 section).
15+ // Typically $XDG_DATA_HOME/man/man1 or ~/.local/share/man/man1.
16+ ManDir() (string, error)
1317 }
A internal/application/usecase/get_config_schema.go +38 -0
@@ -0,0 +1,38 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// GetConfigSchemaUseCase retrieves configuration schema information.
11+type GetConfigSchemaUseCase struct {
12+ provider port.ConfigSchemaProvider
13+}
14+
15+// NewGetConfigSchemaUseCase creates a new GetConfigSchemaUseCase.
16+func NewGetConfigSchemaUseCase(provider port.ConfigSchemaProvider) *GetConfigSchemaUseCase {
17+ return &GetConfigSchemaUseCase{
18+ provider: provider,
19+ }
20+}
21+
22+// GetConfigSchemaInput contains input parameters for schema retrieval.
23+type GetConfigSchemaInput struct {
24+ // Reserved for future filtering options (e.g., by section)
25+}
26+
27+// GetConfigSchemaOutput contains the schema information.
28+type GetConfigSchemaOutput struct {
29+ Keys []entity.ConfigKeyInfo
30+}
31+
32+// Execute retrieves all configuration keys with their metadata.
33+func (uc *GetConfigSchemaUseCase) Execute(_ context.Context, _ GetConfigSchemaInput) (*GetConfigSchemaOutput, error) {
34+ keys := uc.provider.GetSchema()
35+ return &GetConfigSchemaOutput{
36+ Keys: keys,
37+ }, nil
38+}
A internal/application/usecase/get_config_schema_test.go +106 -0
@@ -0,0 +1,106 @@
1+package usecase_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port/mocks"
12+ "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/domain/entity"
14+)
15+
16+func TestGetConfigSchemaUseCase_Execute(t *testing.T) {
17+ t.Run("returns schema keys from provider", func(t *testing.T) {
18+ // Arrange
19+ mockProvider := mocks.NewMockConfigSchemaProvider(t)
20+ expectedKeys := []entity.ConfigKeyInfo{
21+ {
22+ Key: "appearance.color_scheme",
23+ Type: "string",
24+ Default: "default",
25+ Description: "Theme preference",
26+ Values: []string{"default", "prefer-dark", "prefer-light"},
27+ Section: "Appearance",
28+ },
29+ {
30+ Key: "logging.level",
31+ Type: "string",
32+ Default: "info",
33+ Description: "Log verbosity level",
34+ Values: []string{"trace", "debug", "info", "warn", "error", "fatal"},
35+ Section: "Logging",
36+ },
37+ }
38+ mockProvider.EXPECT().GetSchema().Return(expectedKeys)
39+
40+ uc := usecase.NewGetConfigSchemaUseCase(mockProvider)
41+
42+ // Act
43+ result, err := uc.Execute(context.Background(), usecase.GetConfigSchemaInput{})
44+
45+ // Assert
46+ require.NoError(t, err)
47+ assert.NotNil(t, result)
48+ assert.Len(t, result.Keys, 2)
49+ assert.Equal(t, "appearance.color_scheme", result.Keys[0].Key)
50+ assert.Equal(t, "logging.level", result.Keys[1].Key)
51+ mock.AssertExpectationsForObjects(t, mockProvider)
52+ })
53+
54+ t.Run("returns empty slice when no keys", func(t *testing.T) {
55+ // Arrange
56+ mockProvider := mocks.NewMockConfigSchemaProvider(t)
57+ mockProvider.EXPECT().GetSchema().Return([]entity.ConfigKeyInfo{})
58+
59+ uc := usecase.NewGetConfigSchemaUseCase(mockProvider)
60+
61+ // Act
62+ result, err := uc.Execute(context.Background(), usecase.GetConfigSchemaInput{})
63+
64+ // Assert
65+ require.NoError(t, err)
66+ assert.NotNil(t, result)
67+ assert.Empty(t, result.Keys)
68+ mock.AssertExpectationsForObjects(t, mockProvider)
69+ })
70+
71+ t.Run("keys include all expected fields", func(t *testing.T) {
72+ // Arrange
73+ mockProvider := mocks.NewMockConfigSchemaProvider(t)
74+ expectedKeys := []entity.ConfigKeyInfo{
75+ {
76+ Key: "appearance.default_font_size",
77+ Type: "int",
78+ Default: "16",
79+ Description: "Default font size in CSS pixels",
80+ Range: "1-72",
81+ Section: "Appearance",
82+ },
83+ }
84+ mockProvider.EXPECT().GetSchema().Return(expectedKeys)
85+
86+ uc := usecase.NewGetConfigSchemaUseCase(mockProvider)
87+
88+ // Act
89+ result, err := uc.Execute(context.Background(), usecase.GetConfigSchemaInput{})
90+
91+ // Assert
92+ require.NoError(t, err)
93+ assert.NotNil(t, result)
94+ assert.Len(t, result.Keys, 1)
95+
96+ key := result.Keys[0]
97+ assert.Equal(t, "appearance.default_font_size", key.Key)
98+ assert.Equal(t, "int", key.Type)
99+ assert.Equal(t, "16", key.Default)
100+ assert.Equal(t, "Default font size in CSS pixels", key.Description)
101+ assert.Equal(t, "1-72", key.Range)
102+ assert.Equal(t, "Appearance", key.Section)
103+ assert.Empty(t, key.Values) // No enum values for int type
104+ mock.AssertExpectationsForObjects(t, mockProvider)
105+ })
106+}
M internal/application/usecase/manage_favorites.go +71 -0
@@ -196,6 +196,77 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b
196196 return fav != nil, nil
197197 }
198198
199+// ToggleResult indicates the result of a toggle operation.
200+type ToggleResult struct {
201+ Added bool // true if favorite was added, false if removed
202+ URL string
203+ Title string
204+ Message string // User-friendly message
205+}
206+
207+// Toggle adds or removes a URL from favorites based on current state.
208+// If the URL is already a favorite, it removes it. Otherwise, it adds it.
209+func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) {
210+ log := logging.FromContext(ctx)
211+ log.Debug().Str("url", url).Msg("toggling favorite")
212+
213+ if url == "" {
214+ return nil, fmt.Errorf("URL cannot be empty")
215+ }
216+
217+ // Check if already favorited
218+ existing, err := uc.favoriteRepo.FindByURL(ctx, url)
219+ if err != nil {
220+ return nil, fmt.Errorf("failed to check existing favorite: %w", err)
221+ }
222+
223+ if existing != nil {
224+ // Remove favorite
225+ if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
226+ return nil, fmt.Errorf("failed to remove favorite: %w", err)
227+ }
228+ log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
229+ return &ToggleResult{
230+ Added: false,
231+ URL: url,
232+ Title: existing.Title,
233+ Message: "Favorite removed",
234+ }, nil
235+ }
236+
237+ // Add favorite
238+ fav := entity.NewFavorite(url, title)
239+ if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
240+ return nil, fmt.Errorf("failed to add favorite: %w", err)
241+ }
242+ log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
243+ return &ToggleResult{
244+ Added: true,
245+ URL: url,
246+ Title: title,
247+ Message: "Favorite added",
248+ }, nil
249+}
250+
251+// GetAllURLs returns a set of all favorited URLs for efficient batch lookup.
252+func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) {
253+ log := logging.FromContext(ctx)
254+ log.Debug().Msg("getting all favorite URLs")
255+
256+ favs, err := uc.favoriteRepo.GetAll(ctx)
257+ if err != nil {
258+ return nil, fmt.Errorf("failed to get favorites: %w", err)
259+ }
260+
261+ urls := make(map[string]struct{}, len(favs))
262+ for _, fav := range favs {
263+ urls[fav.URL] = struct{}{}
264+ }
265+
266+ log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
267+ return urls, nil
268+}
269+
199270 // GetTree builds a complete hierarchical view of folders and favorites.
200271 func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {
201272 log := logging.FromContext(ctx)
A internal/application/usecase/manage_favorites_test.go +200 -0
@@ -0,0 +1,200 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestManageFavoritesUseCase_GetAllURLs_ReturnsURLSet(t *testing.T) {
16+ ctx := testContext()
17+
18+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
19+ folderRepo := repomocks.NewMockFolderRepository(t)
20+ tagRepo := repomocks.NewMockTagRepository(t)
21+
22+ favorites := []*entity.Favorite{
23+ {ID: 1, URL: "https://example.com"},
24+ {ID: 2, URL: "https://github.com"},
25+ {ID: 3, URL: "https://google.com"},
26+ }
27+
28+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
29+
30+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
31+
32+ urls, err := uc.GetAllURLs(ctx)
33+ require.NoError(t, err)
34+ require.NotNil(t, urls)
35+
36+ assert.Len(t, urls, 3)
37+ _, hasExample := urls["https://example.com"]
38+ _, hasGithub := urls["https://github.com"]
39+ _, hasGoogle := urls["https://google.com"]
40+ assert.True(t, hasExample)
41+ assert.True(t, hasGithub)
42+ assert.True(t, hasGoogle)
43+}
44+
45+func TestManageFavoritesUseCase_GetAllURLs_ReturnsEmptySetWhenNoFavorites(t *testing.T) {
46+ ctx := testContext()
47+
48+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
49+ folderRepo := repomocks.NewMockFolderRepository(t)
50+ tagRepo := repomocks.NewMockTagRepository(t)
51+
52+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
53+
54+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
55+
56+ urls, err := uc.GetAllURLs(ctx)
57+ require.NoError(t, err)
58+ require.NotNil(t, urls)
59+ assert.Empty(t, urls)
60+}
61+
62+func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing.T) {
63+ ctx := testContext()
64+
65+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
66+ folderRepo := repomocks.NewMockFolderRepository(t)
67+ tagRepo := repomocks.NewMockTagRepository(t)
68+
69+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(nil, errors.New("db connection failed"))
70+
71+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
72+
73+ urls, err := uc.GetAllURLs(ctx)
74+ require.Error(t, err)
75+ assert.Nil(t, urls)
76+ assert.Contains(t, err.Error(), "failed to get favorites")
77+}
78+
79+func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) {
80+ ctx := testContext()
81+
82+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
83+ folderRepo := repomocks.NewMockFolderRepository(t)
84+ tagRepo := repomocks.NewMockTagRepository(t)
85+
86+ // URL is not a favorite
87+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil)
88+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(f *entity.Favorite) bool {
89+ return f.URL == "https://example.com" && f.Title == "Example"
90+ })).Return(nil)
91+
92+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
93+
94+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
95+ require.NoError(t, err)
96+ require.NotNil(t, result)
97+
98+ assert.True(t, result.Added)
99+ assert.Equal(t, "https://example.com", result.URL)
100+ assert.Equal(t, "Example", result.Title)
101+ assert.Equal(t, "Favorite added", result.Message)
102+}
103+
104+func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T) {
105+ ctx := testContext()
106+
107+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
108+ folderRepo := repomocks.NewMockFolderRepository(t)
109+ tagRepo := repomocks.NewMockTagRepository(t)
110+
111+ existingFav := &entity.Favorite{
112+ ID: 42,
113+ URL: "https://example.com",
114+ Title: "Example Site",
115+ }
116+
117+ // URL is already a favorite
118+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)
119+ favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil)
120+
121+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
122+
123+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
124+ require.NoError(t, err)
125+ require.NotNil(t, result)
126+
127+ assert.False(t, result.Added)
128+ assert.Equal(t, "https://example.com", result.URL)
129+ assert.Equal(t, "Example Site", result.Title) // Uses existing title
130+ assert.Equal(t, "Favorite removed", result.Message)
131+}
132+
133+func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) {
134+ ctx := testContext()
135+
136+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
137+ folderRepo := repomocks.NewMockFolderRepository(t)
138+ tagRepo := repomocks.NewMockTagRepository(t)
139+
140+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
141+
142+ result, err := uc.Toggle(ctx, "", "Title")
143+ require.Error(t, err)
144+ assert.Nil(t, result)
145+ assert.Contains(t, err.Error(), "URL cannot be empty")
146+}
147+
148+func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) {
149+ ctx := testContext()
150+
151+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
152+ folderRepo := repomocks.NewMockFolderRepository(t)
153+ tagRepo := repomocks.NewMockTagRepository(t)
154+
155+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error"))
156+
157+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
158+
159+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
160+ require.Error(t, err)
161+ assert.Nil(t, result)
162+ assert.Contains(t, err.Error(), "failed to check existing favorite")
163+}
164+
165+func TestManageFavoritesUseCase_Toggle_SaveErrorReturnsError(t *testing.T) {
166+ ctx := testContext()
167+
168+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
169+ folderRepo := repomocks.NewMockFolderRepository(t)
170+ tagRepo := repomocks.NewMockTagRepository(t)
171+
172+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil)
173+ favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error"))
174+
175+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
176+
177+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
178+ require.Error(t, err)
179+ assert.Nil(t, result)
180+ assert.Contains(t, err.Error(), "failed to add favorite")
181+}
182+
183+func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) {
184+ ctx := testContext()
185+
186+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
187+ folderRepo := repomocks.NewMockFolderRepository(t)
188+ tagRepo := repomocks.NewMockTagRepository(t)
189+
190+ existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"}
191+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)
192+ favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error"))
193+
194+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
195+
196+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
197+ require.Error(t, err)
198+ assert.Nil(t, result)
199+ assert.Contains(t, err.Error(), "failed to remove favorite")
200+}
A internal/application/usecase/migrate_config.go +166 -0
@@ -0,0 +1,166 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// CheckConfigMigrationInput holds the input for checking config migration.
11+type CheckConfigMigrationInput struct{}
12+
13+// CheckConfigMigrationOutput holds the result of the migration check.
14+type CheckConfigMigrationOutput struct {
15+ // NeedsMigration is true if there are missing keys.
16+ NeedsMigration bool
17+ // MissingKeys contains info about each missing key.
18+ MissingKeys []port.KeyInfo
19+ // ConfigFile is the path to the config file.
20+ ConfigFile string
21+}
22+
23+// DetectChangesInput holds the input for detecting config changes.
24+type DetectChangesInput struct{}
25+
26+// DetectChangesOutput holds the result of change detection.
27+type DetectChangesOutput struct {
28+ // HasChanges is true if any changes were detected.
29+ HasChanges bool
30+ // Changes contains all detected changes.
31+ Changes []port.KeyChange
32+ // DiffText is a formatted diff-like string representation.
33+ DiffText string
34+}
35+
36+// MigrateConfigInput holds the input for migrating config.
37+type MigrateConfigInput struct{}
38+
39+// MigrateConfigOutput holds the result of the migration.
40+type MigrateConfigOutput struct {
41+ // AddedKeys contains the keys that were added.
42+ AddedKeys []string
43+ // ConfigFile is the path to the config file.
44+ ConfigFile string
45+}
46+
47+// MigrateConfigUseCase handles config migration operations.
48+type MigrateConfigUseCase struct {
49+ migrator port.ConfigMigrator
50+ diffFormatter port.DiffFormatter
51+}
52+
53+// NewMigrateConfigUseCase creates a new migrate config use case.
54+func NewMigrateConfigUseCase(migrator port.ConfigMigrator, diffFormatter port.DiffFormatter) *MigrateConfigUseCase {
55+ return &MigrateConfigUseCase{
56+ migrator: migrator,
57+ diffFormatter: diffFormatter,
58+ }
59+}
60+
61+// Check checks if the user config is missing any default keys.
62+func (uc *MigrateConfigUseCase) Check(ctx context.Context, _ CheckConfigMigrationInput) (*CheckConfigMigrationOutput, error) {
63+ log := logging.FromContext(ctx)
64+
65+ result, err := uc.migrator.CheckMigration()
66+ if err != nil {
67+ log.Warn().Err(err).Msg("config migration check failed")
68+ return nil, err
69+ }
70+
71+ // No migration needed
72+ if result == nil || len(result.MissingKeys) == 0 {
73+ log.Debug().Msg("config is up to date, no migration needed")
74+ return &CheckConfigMigrationOutput{
75+ NeedsMigration: false,
76+ MissingKeys: nil,
77+ ConfigFile: "",
78+ }, nil
79+ }
80+
81+ // Build key info for each missing key
82+ keyInfos := make([]port.KeyInfo, 0, len(result.MissingKeys))
83+ for _, key := range result.MissingKeys {
84+ keyInfos = append(keyInfos, uc.migrator.GetKeyInfo(key))
85+ }
86+
87+ log.Debug().
88+ Int("missing_keys", len(result.MissingKeys)).
89+ Str("config_file", result.ConfigFile).
90+ Msg("config migration check completed")
91+
92+ return &CheckConfigMigrationOutput{
93+ NeedsMigration: true,
94+ MissingKeys: keyInfos,
95+ ConfigFile: result.ConfigFile,
96+ }, nil
97+}
98+
99+// DetectChanges detects all config changes and returns a diff-like output.
100+func (uc *MigrateConfigUseCase) DetectChanges(ctx context.Context, _ DetectChangesInput) (*DetectChangesOutput, error) {
101+ log := logging.FromContext(ctx)
102+
103+ changes, err := uc.migrator.DetectChanges()
104+ if err != nil {
105+ log.Warn().Err(err).Msg("config change detection failed")
106+ return nil, err
107+ }
108+
109+ if len(changes) == 0 {
110+ log.Debug().Msg("no config changes detected")
111+ return &DetectChangesOutput{
112+ HasChanges: false,
113+ Changes: nil,
114+ DiffText: "No changes detected.",
115+ }, nil
116+ }
117+
118+ log.Debug().
119+ Int("changes", len(changes)).
120+ Msg("config changes detected")
121+
122+ return &DetectChangesOutput{
123+ HasChanges: true,
124+ Changes: changes,
125+ DiffText: uc.diffFormatter.FormatChangesAsDiff(changes),
126+ }, nil
127+}
128+
129+// Execute adds missing default keys and removes deprecated keys from the user's config file.
130+func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) {
131+ log := logging.FromContext(ctx)
132+
133+ // Check for any changes (added, removed, or renamed keys)
134+ changes, err := uc.migrator.DetectChanges()
135+ if err != nil {
136+ return nil, err
137+ }
138+
139+ if len(changes) == 0 {
140+ log.Debug().Msg("no migration needed")
141+ return &MigrateConfigOutput{
142+ AddedKeys: nil,
143+ ConfigFile: "",
144+ }, nil
145+ }
146+
147+ // Get config file path for output
148+ configFile, _ := uc.migrator.GetConfigFile()
149+
150+ // Perform migration
151+ appliedKeys, err := uc.migrator.Migrate()
152+ if err != nil {
153+ log.Error().Err(err).Msg("config migration failed")
154+ return nil, err
155+ }
156+
157+ log.Info().
158+ Int("applied_keys", len(appliedKeys)).
159+ Str("config_file", configFile).
160+ Msg("config migration completed")
161+
162+ return &MigrateConfigOutput{
163+ AddedKeys: appliedKeys,
164+ ConfigFile: configFile,
165+ }, nil
166+}
A internal/application/usecase/migrate_config_test.go +373 -0
@@ -0,0 +1,373 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/application/port/mocks"
14+)
15+
16+func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) {
17+ mockMigrator := mocks.NewMockConfigMigrator(t)
18+ mockFormatter := mocks.NewMockDiffFormatter(t)
19+ mockMigrator.EXPECT().CheckMigration().Return(nil, nil)
20+
21+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
22+ ctx := context.Background()
23+
24+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
25+
26+ require.NoError(t, err)
27+ assert.False(t, result.NeedsMigration)
28+ assert.Empty(t, result.MissingKeys)
29+}
30+
31+func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) {
32+ mockMigrator := mocks.NewMockConfigMigrator(t)
33+ mockFormatter := mocks.NewMockDiffFormatter(t)
34+
35+ migrationResult := &port.MigrationResult{
36+ MissingKeys: []string{"key1", "key2"},
37+ ConfigFile: "/path/to/config.toml",
38+ }
39+ mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
40+
41+ // Mock GetKeyInfo for each missing key
42+ mockMigrator.EXPECT().GetKeyInfo("key1").Return(port.KeyInfo{
43+ Key: "key1",
44+ Type: "bool",
45+ DefaultValue: "true",
46+ })
47+ mockMigrator.EXPECT().GetKeyInfo("key2").Return(port.KeyInfo{
48+ Key: "key2",
49+ Type: "string",
50+ DefaultValue: `"default"`,
51+ })
52+
53+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
54+ ctx := context.Background()
55+
56+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
57+
58+ require.NoError(t, err)
59+ assert.True(t, result.NeedsMigration)
60+ assert.Len(t, result.MissingKeys, 2)
61+ assert.Equal(t, "/path/to/config.toml", result.ConfigFile)
62+
63+ // Verify key info
64+ assert.Equal(t, "key1", result.MissingKeys[0].Key)
65+ assert.Equal(t, "bool", result.MissingKeys[0].Type)
66+ assert.Equal(t, "key2", result.MissingKeys[1].Key)
67+ assert.Equal(t, "string", result.MissingKeys[1].Type)
68+}
69+
70+func TestMigrateConfigUseCase_Check_Error(t *testing.T) {
71+ mockMigrator := mocks.NewMockConfigMigrator(t)
72+ mockFormatter := mocks.NewMockDiffFormatter(t)
73+ expectedErr := errors.New("check failed")
74+ mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr)
75+
76+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
77+ ctx := context.Background()
78+
79+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
80+
81+ require.Error(t, err)
82+ assert.Nil(t, result)
83+ assert.Equal(t, expectedErr, err)
84+}
85+
86+func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) {
87+ mockMigrator := mocks.NewMockConfigMigrator(t)
88+ mockFormatter := mocks.NewMockDiffFormatter(t)
89+ mockMigrator.EXPECT().DetectChanges().Return(nil, nil)
90+
91+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
92+ ctx := context.Background()
93+
94+ result, err := uc.Execute(ctx, MigrateConfigInput{})
95+
96+ require.NoError(t, err)
97+ assert.Empty(t, result.AddedKeys)
98+}
99+
100+func TestMigrateConfigUseCase_Execute_Success(t *testing.T) {
101+ mockMigrator := mocks.NewMockConfigMigrator(t)
102+ mockFormatter := mocks.NewMockDiffFormatter(t)
103+
104+ changes := []port.KeyChange{
105+ {Type: port.KeyChangeAdded, NewKey: "key1"},
106+ {Type: port.KeyChangeAdded, NewKey: "key2"},
107+ }
108+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
109+ mockMigrator.EXPECT().GetConfigFile().Return("/path/to/config.toml", nil)
110+ mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil)
111+
112+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
113+ ctx := context.Background()
114+
115+ result, err := uc.Execute(ctx, MigrateConfigInput{})
116+
117+ require.NoError(t, err)
118+ assert.Len(t, result.AddedKeys, 2)
119+ assert.Equal(t, "/path/to/config.toml", result.ConfigFile)
120+}
121+
122+func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) {
123+ mockMigrator := mocks.NewMockConfigMigrator(t)
124+ mockFormatter := mocks.NewMockDiffFormatter(t)
125+
126+ changes := []port.KeyChange{
127+ {Type: port.KeyChangeAdded, NewKey: "key1"},
128+ }
129+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
130+ mockMigrator.EXPECT().GetConfigFile().Return("/path/to/config.toml", nil)
131+
132+ expectedErr := errors.New("migrate failed")
133+ mockMigrator.EXPECT().Migrate().Return(nil, expectedErr)
134+
135+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
136+ ctx := context.Background()
137+
138+ result, err := uc.Execute(ctx, MigrateConfigInput{})
139+
140+ require.Error(t, err)
141+ assert.Nil(t, result)
142+ assert.Equal(t, expectedErr, err)
143+}
144+
145+func TestMigrateConfigUseCase_Execute_DetectChangesError(t *testing.T) {
146+ mockMigrator := mocks.NewMockConfigMigrator(t)
147+ mockFormatter := mocks.NewMockDiffFormatter(t)
148+
149+ expectedErr := errors.New("detect changes failed")
150+ mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr)
151+
152+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
153+ ctx := context.Background()
154+
155+ result, err := uc.Execute(ctx, MigrateConfigInput{})
156+
157+ require.Error(t, err)
158+ assert.Nil(t, result)
159+ assert.Equal(t, expectedErr, err)
160+}
161+
162+func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) {
163+ mockMigrator := mocks.NewMockConfigMigrator(t)
164+ mockFormatter := mocks.NewMockDiffFormatter(t)
165+
166+ // Return a result with empty missing keys
167+ migrationResult := &port.MigrationResult{
168+ MissingKeys: []string{},
169+ ConfigFile: "/path/to/config.toml",
170+ }
171+ mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil)
172+
173+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
174+ ctx := context.Background()
175+
176+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
177+
178+ require.NoError(t, err)
179+ assert.False(t, result.NeedsMigration)
180+ assert.Empty(t, result.MissingKeys)
181+}
182+
183+func TestNewMigrateConfigUseCase(t *testing.T) {
184+ mockMigrator := mocks.NewMockConfigMigrator(t)
185+ mockFormatter := mocks.NewMockDiffFormatter(t)
186+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
187+
188+ assert.NotNil(t, uc)
189+ assert.Equal(t, mockMigrator, uc.migrator)
190+ assert.Equal(t, mockFormatter, uc.diffFormatter)
191+}
192+
193+// Ensure mock expectations are set up correctly
194+func TestMigrateConfigUseCase_MockExpectations(t *testing.T) {
195+ mockMigrator := mocks.NewMockConfigMigrator(t)
196+ mockFormatter := mocks.NewMockDiffFormatter(t)
197+
198+ // Set up expectations with mock.Anything for flexibility
199+ mockMigrator.On("CheckMigration").Return(&port.MigrationResult{
200+ MissingKeys: []string{"test.key"},
201+ ConfigFile: "/test/config.toml",
202+ }, nil).Once()
203+
204+ mockMigrator.On("GetKeyInfo", mock.AnythingOfType("string")).Return(port.KeyInfo{
205+ Key: "test.key",
206+ Type: "string",
207+ DefaultValue: `"test"`,
208+ }).Once()
209+
210+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
211+ ctx := context.Background()
212+
213+ result, err := uc.Check(ctx, CheckConfigMigrationInput{})
214+
215+ require.NoError(t, err)
216+ assert.True(t, result.NeedsMigration)
217+ mockMigrator.AssertExpectations(t)
218+}
219+
220+func TestMigrateConfigUseCase_DetectChanges_NoChanges(t *testing.T) {
221+ mockMigrator := mocks.NewMockConfigMigrator(t)
222+ mockFormatter := mocks.NewMockDiffFormatter(t)
223+ mockMigrator.EXPECT().DetectChanges().Return(nil, nil)
224+
225+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
226+ ctx := context.Background()
227+
228+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
229+
230+ require.NoError(t, err)
231+ assert.False(t, result.HasChanges)
232+ assert.Empty(t, result.Changes)
233+ assert.Equal(t, "No changes detected.", result.DiffText)
234+}
235+
236+func TestMigrateConfigUseCase_DetectChanges_WithAddedKey(t *testing.T) {
237+ mockMigrator := mocks.NewMockConfigMigrator(t)
238+ mockFormatter := mocks.NewMockDiffFormatter(t)
239+
240+ changes := []port.KeyChange{
241+ {
242+ Type: port.KeyChangeAdded,
243+ NewKey: "workspace.styling.mode_indicator_toaster_enabled",
244+ NewValue: "true",
245+ },
246+ }
247+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
248+ mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n + workspace.styling.mode_indicator_toaster_enabled = true\n")
249+
250+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
251+ ctx := context.Background()
252+
253+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
254+
255+ require.NoError(t, err)
256+ assert.True(t, result.HasChanges)
257+ assert.Len(t, result.Changes, 1)
258+ assert.Contains(t, result.DiffText, "+ workspace.styling.mode_indicator_toaster_enabled")
259+}
260+
261+func TestMigrateConfigUseCase_DetectChanges_WithRenamedKey(t *testing.T) {
262+ mockMigrator := mocks.NewMockConfigMigrator(t)
263+ mockFormatter := mocks.NewMockDiffFormatter(t)
264+
265+ changes := []port.KeyChange{
266+ {
267+ Type: port.KeyChangeRenamed,
268+ OldKey: "workspace.styling.pane_mode_border_color",
269+ NewKey: "workspace.styling.pane_mode_color",
270+ OldValue: `"#4A90E2"`,
271+ },
272+ }
273+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
274+ mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n ~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color\n (value: \"#4A90E2\")\n")
275+
276+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
277+ ctx := context.Background()
278+
279+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
280+
281+ require.NoError(t, err)
282+ assert.True(t, result.HasChanges)
283+ assert.Len(t, result.Changes, 1)
284+ assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color")
285+}
286+
287+func TestMigrateConfigUseCase_DetectChanges_WithRemovedKey(t *testing.T) {
288+ mockMigrator := mocks.NewMockConfigMigrator(t)
289+ mockFormatter := mocks.NewMockDiffFormatter(t)
290+
291+ changes := []port.KeyChange{
292+ {
293+ Type: port.KeyChangeRemoved,
294+ OldKey: "workspace.styling.pane_mode_border_width",
295+ OldValue: "4",
296+ },
297+ }
298+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
299+ mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n - workspace.styling.pane_mode_border_width = 4 (deprecated)\n")
300+
301+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
302+ ctx := context.Background()
303+
304+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
305+
306+ require.NoError(t, err)
307+ assert.True(t, result.HasChanges)
308+ assert.Len(t, result.Changes, 1)
309+ assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width")
310+ assert.Contains(t, result.DiffText, "(deprecated)")
311+}
312+
313+func TestMigrateConfigUseCase_DetectChanges_Error(t *testing.T) {
314+ mockMigrator := mocks.NewMockConfigMigrator(t)
315+ mockFormatter := mocks.NewMockDiffFormatter(t)
316+ expectedErr := errors.New("detect changes failed")
317+ mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr)
318+
319+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
320+ ctx := context.Background()
321+
322+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
323+
324+ require.Error(t, err)
325+ assert.Nil(t, result)
326+ assert.Equal(t, expectedErr, err)
327+}
328+
329+func TestMigrateConfigUseCase_DetectChanges_MultipleChanges(t *testing.T) {
330+ mockMigrator := mocks.NewMockConfigMigrator(t)
331+ mockFormatter := mocks.NewMockDiffFormatter(t)
332+
333+ changes := []port.KeyChange{
334+ {
335+ Type: port.KeyChangeAdded,
336+ NewKey: "workspace.styling.mode_border_width",
337+ NewValue: "4",
338+ },
339+ {
340+ Type: port.KeyChangeRenamed,
341+ OldKey: "workspace.styling.pane_mode_border_color",
342+ NewKey: "workspace.styling.pane_mode_color",
343+ OldValue: `"#4A90E2"`,
344+ },
345+ {
346+ Type: port.KeyChangeRemoved,
347+ OldKey: "workspace.styling.pane_mode_border_width",
348+ OldValue: "4",
349+ },
350+ }
351+ mockMigrator.EXPECT().DetectChanges().Return(changes, nil)
352+ mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return(
353+ "Config migration changes:\n\n" +
354+ " + workspace.styling.mode_border_width = 4\n" +
355+ " ~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color\n" +
356+ " (value: \"#4A90E2\")\n" +
357+ " - workspace.styling.pane_mode_border_width = 4 (deprecated)\n",
358+ )
359+
360+ uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter)
361+ ctx := context.Background()
362+
363+ result, err := uc.DetectChanges(ctx, DetectChangesInput{})
364+
365+ require.NoError(t, err)
366+ assert.True(t, result.HasChanges)
367+ assert.Len(t, result.Changes, 3)
368+
369+ // Verify diff contains all change types
370+ assert.Contains(t, result.DiffText, "+ workspace.styling.mode_border_width")
371+ assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color")
372+ assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width")
373+}
A internal/application/usecase/move_pane_to_tab.go +382 -0
@@ -0,0 +1,382 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// MovePaneToTabUseCase moves a pane from one tab's workspace to another.
10+//
11+// It is pure domain manipulation: it depends only on entities and an ID generator.
12+type MovePaneToTabUseCase struct {
13+ idGenerator IDGenerator
14+}
15+
16+func NewMovePaneToTabUseCase(idGenerator IDGenerator) *MovePaneToTabUseCase {
17+ return &MovePaneToTabUseCase{idGenerator: idGenerator}
18+}
19+
20+type MovePaneToTabInput struct {
21+ TabList *entity.TabList
22+ SourceTabID entity.TabID
23+ SourcePaneID entity.PaneID
24+ TargetTabID entity.TabID // empty means create new tab
25+}
26+
27+type MovePaneToTabOutput struct {
28+ TargetTab *entity.Tab
29+ MovedPaneNode *entity.PaneNode
30+ SourceTabClosed bool
31+ NewTabCreated bool
32+}
33+
34+func (uc *MovePaneToTabUseCase) Execute(input MovePaneToTabInput) (*MovePaneToTabOutput, error) {
35+ if err := validateMovePaneToTabInput(uc, input); err != nil {
36+ return nil, err
37+ }
38+
39+ sourceTab, err := findSourceTab(input.TabList, input.SourceTabID)
40+ if err != nil {
41+ return nil, err
42+ }
43+
44+ movedPane, sourceNode, err := findSourcePane(sourceTab.Workspace, input.SourcePaneID)
45+ if err != nil {
46+ return nil, err
47+ }
48+
49+ if detachErr := detachPaneFromWorkspace(sourceTab.Workspace, sourceNode); detachErr != nil {
50+ return nil, detachErr
51+ }
52+
53+ sourceTabClosed := closeSourceTabIfEmpty(input.TabList, sourceTab)
54+
55+ targetTab, newTabCreated, err := uc.resolveTargetTab(input.TabList, input.TargetTabID, movedPane)
56+ if err != nil {
57+ return nil, err
58+ }
59+ if targetTab == nil || targetTab.Workspace == nil {
60+ return nil, fmt.Errorf("target tab/workspace is nil")
61+ }
62+
63+ if newTabCreated {
64+ return &MovePaneToTabOutput{
65+ TargetTab: targetTab,
66+ MovedPaneNode: targetTab.Workspace.Root,
67+ SourceTabClosed: sourceTabClosed,
68+ NewTabCreated: true,
69+ }, nil
70+ }
71+
72+ movedNode, err := uc.insertIntoTargetWorkspace(targetTab.Workspace, movedPane)
73+ if err != nil {
74+ return nil, err
75+ }
76+
77+ return &MovePaneToTabOutput{
78+ TargetTab: targetTab,
79+ MovedPaneNode: movedNode,
80+ SourceTabClosed: sourceTabClosed,
81+ NewTabCreated: false,
82+ }, nil
83+}
84+
85+func validateMovePaneToTabInput(uc *MovePaneToTabUseCase, input MovePaneToTabInput) error {
86+ if uc == nil {
87+ return fmt.Errorf("move pane to tab use case is nil")
88+ }
89+ if input.TabList == nil {
90+ return fmt.Errorf("tab list is required")
91+ }
92+ if input.SourceTabID == "" {
93+ return fmt.Errorf("source tab id is required")
94+ }
95+ if input.SourcePaneID == "" {
96+ return fmt.Errorf("source pane id is required")
97+ }
98+ if input.TargetTabID == input.SourceTabID {
99+ return fmt.Errorf("cannot move pane to same tab")
100+ }
101+ return nil
102+}
103+
104+func findSourceTab(tl *entity.TabList, id entity.TabID) (*entity.Tab, error) {
105+ sourceTab := tl.Find(id)
106+ if sourceTab == nil {
107+ return nil, fmt.Errorf("source tab not found: %s", id)
108+ }
109+ if sourceTab.Workspace == nil {
110+ return nil, fmt.Errorf("source workspace is nil")
111+ }
112+ return sourceTab, nil
113+}
114+
115+func findSourcePane(ws *entity.Workspace, paneID entity.PaneID) (*entity.Pane, *entity.PaneNode, error) {
116+ if ws == nil {
117+ return nil, nil, fmt.Errorf("workspace is required")
118+ }
119+ sourceNode := ws.FindPane(paneID)
120+ if sourceNode == nil || sourceNode.Pane == nil {
121+ return nil, nil, fmt.Errorf("source pane not found: %s", paneID)
122+ }
123+ return sourceNode.Pane, sourceNode, nil
124+}
125+
126+func closeSourceTabIfEmpty(tl *entity.TabList, sourceTab *entity.Tab) bool {
127+ if tl == nil || sourceTab == nil || sourceTab.Workspace == nil {
128+ return false
129+ }
130+ if sourceTab.Workspace.PaneCount() != 0 {
131+ return false
132+ }
133+ return tl.Remove(sourceTab.ID)
134+}
135+
136+func (uc *MovePaneToTabUseCase) resolveTargetTab(
137+ tl *entity.TabList,
138+ targetID entity.TabID,
139+ movedPane *entity.Pane,
140+) (*entity.Tab, bool, error) {
141+ if tl == nil {
142+ return nil, false, fmt.Errorf("tab list is required")
143+ }
144+ if movedPane == nil {
145+ return nil, false, fmt.Errorf("moved pane is required")
146+ }
147+
148+ if targetID != "" {
149+ if targetTab := tl.Find(targetID); targetTab != nil {
150+ return targetTab, false, nil
151+ }
152+ // Treat missing as "create new".
153+ }
154+
155+ if uc.idGenerator == nil {
156+ return nil, false, fmt.Errorf("id generator is required to create new tab")
157+ }
158+ tabID := entity.TabID(uc.idGenerator())
159+ wsID := entity.WorkspaceID(uc.idGenerator())
160+ targetTab := entity.NewTab(tabID, wsID, movedPane)
161+ tl.Add(targetTab)
162+ return targetTab, true, nil
163+}
164+
165+func (uc *MovePaneToTabUseCase) insertIntoTargetWorkspace(ws *entity.Workspace, movedPane *entity.Pane) (*entity.PaneNode, error) {
166+ if ws == nil {
167+ return nil, fmt.Errorf("workspace is required")
168+ }
169+ if movedPane == nil {
170+ return nil, fmt.Errorf("moved pane is required")
171+ }
172+
173+ movedNode := &entity.PaneNode{ID: string(movedPane.ID), Pane: movedPane}
174+
175+ if ws.Root == nil {
176+ ws.Root = movedNode
177+ ws.ActivePaneID = movedPane.ID
178+ return movedNode, nil
179+ }
180+
181+ targetActive := ws.ActivePane()
182+ if targetActive == nil || targetActive.Pane == nil {
183+ return nil, fmt.Errorf("target tab has no active pane")
184+ }
185+
186+ if err := insertPaneRightOfActive(ws, targetActive, movedNode, uc.idGenerator); err != nil {
187+ return nil, err
188+ }
189+ ws.ActivePaneID = movedPane.ID
190+ return movedNode, nil
191+}
192+
193+func detachPaneFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
194+ if ws == nil {
195+ return fmt.Errorf("workspace is required")
196+ }
197+ if leaf == nil || leaf.Pane == nil {
198+ return fmt.Errorf("pane node is required")
199+ }
200+
201+ detachedFromStack, err := detachFromStackIfNeeded(ws, leaf)
202+ if err != nil {
203+ return err
204+ }
205+ if detachedFromStack {
206+ return nil
207+ }
208+
209+ if !leaf.IsLeaf() {
210+ return fmt.Errorf("can only move leaf panes")
211+ }
212+ return detachLeafFromWorkspace(ws, leaf)
213+}
214+
215+func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, error) {
216+ if ws == nil || leaf == nil {
217+ return false, nil
218+ }
219+ if leaf.Parent == nil || !leaf.Parent.IsStacked {
220+ return false, nil
221+ }
222+
223+ stackNode := leaf.Parent
224+
225+ idx := -1
226+ for i, child := range stackNode.Children {
227+ if child == leaf {
228+ idx = i
229+ break
230+ }
231+ }
232+ if idx < 0 {
233+ return false, fmt.Errorf("pane not found in stack")
234+ }
235+
236+ stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
237+ if stackNode.ActiveStackIndex >= len(stackNode.Children) {
238+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
239+ }
240+ if stackNode.ActiveStackIndex < 0 {
241+ stackNode.ActiveStackIndex = 0
242+ }
243+
244+ // If only one pane remains in stack, dissolve it into a leaf node.
245+ if len(stackNode.Children) == 1 {
246+ remaining := stackNode.Children[0]
247+ stackNode.Pane = remaining.Pane
248+ stackNode.Children = nil
249+ stackNode.IsStacked = false
250+ stackNode.ActiveStackIndex = 0
251+ if stackNode.Pane != nil {
252+ ws.ActivePaneID = stackNode.Pane.ID
253+ } else {
254+ ws.ActivePaneID = ""
255+ }
256+ return true, nil
257+ }
258+
259+ // Otherwise, set workspace active pane to the stack's current active.
260+ if stackNode.ActivePane() != nil && stackNode.ActivePane().Pane != nil {
261+ ws.ActivePaneID = stackNode.ActivePane().Pane.ID
262+ }
263+ return true, nil
264+}
265+
266+func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error {
267+ parent := leaf.Parent
268+ if parent == nil {
269+ ws.Root = nil
270+ ws.ActivePaneID = ""
271+ return nil
272+ }
273+ if !parent.IsSplit() {
274+ return fmt.Errorf("pane parent is not a split")
275+ }
276+
277+ sibling := findSibling(parent, leaf)
278+ if sibling == nil {
279+ return fmt.Errorf("no sibling found")
280+ }
281+
282+ promoteSibling(ws, parent, sibling)
283+ ws.ActivePaneID = findFirstLeafPaneID(sibling)
284+ return nil
285+}
286+
287+func findSibling(parent, leaf *entity.PaneNode) *entity.PaneNode {
288+ if parent == nil {
289+ return nil
290+ }
291+ for _, child := range parent.Children {
292+ if child != leaf {
293+ return child
294+ }
295+ }
296+ return nil
297+}
298+
299+func promoteSibling(ws *entity.Workspace, parent, sibling *entity.PaneNode) {
300+ if ws == nil || sibling == nil {
301+ return
302+ }
303+
304+ grandparent := parent.Parent
305+ if grandparent == nil {
306+ ws.Root = sibling
307+ sibling.Parent = nil
308+ return
309+ }
310+ for i, child := range grandparent.Children {
311+ if child == parent {
312+ grandparent.Children[i] = sibling
313+ break
314+ }
315+ }
316+ sibling.Parent = grandparent
317+}
318+
319+func findFirstLeafPaneID(node *entity.PaneNode) entity.PaneID {
320+ if node == nil {
321+ return ""
322+ }
323+ var active entity.PaneID
324+ node.Walk(func(n *entity.PaneNode) bool {
325+ if n.IsLeaf() && n.Pane != nil {
326+ active = n.Pane.ID
327+ return false
328+ }
329+ return true
330+ })
331+ return active
332+}
333+
334+func insertPaneRightOfActive(ws *entity.Workspace, activeNode, newLeaf *entity.PaneNode, idGen IDGenerator) error {
335+ if ws == nil {
336+ return fmt.Errorf("workspace is required")
337+ }
338+ if activeNode == nil {
339+ return fmt.Errorf("active pane is required")
340+ }
341+ if newLeaf == nil || newLeaf.Pane == nil {
342+ return fmt.Errorf("new pane node is required")
343+ }
344+ if idGen == nil {
345+ return fmt.Errorf("id generator is required")
346+ }
347+
348+ targetNode := activeNode
349+ // Split around stacks when inserting left/right.
350+ if targetNode.Parent != nil && targetNode.Parent.IsStacked {
351+ targetNode = targetNode.Parent
352+ }
353+
354+ parentID := idGen()
355+ splitParent := &entity.PaneNode{
356+ ID: parentID,
357+ SplitDir: entity.SplitHorizontal,
358+ SplitRatio: 0.5,
359+ Children: make([]*entity.PaneNode, 2),
360+ }
361+
362+ splitParent.Children[0] = targetNode
363+ splitParent.Children[1] = newLeaf
364+
365+ newLeaf.Parent = splitParent
366+ oldParent := targetNode.Parent
367+ targetNode.Parent = splitParent
368+
369+ if oldParent == nil {
370+ ws.Root = splitParent
371+ } else {
372+ for i, child := range oldParent.Children {
373+ if child == targetNode {
374+ oldParent.Children[i] = splitParent
375+ break
376+ }
377+ }
378+ splitParent.Parent = oldParent
379+ }
380+
381+ return nil
382+}
A internal/application/usecase/move_pane_to_tab_test.go +200 -0
@@ -0,0 +1,200 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestMovePaneToTab_MoveToExistingTab(t *testing.T) {
12+ id := newTestIDGen()
13+ uc := NewMovePaneToTabUseCase(id)
14+
15+ tabs := entity.NewTabList()
16+
17+ paneA := entity.NewPane(entity.PaneID("pA"))
18+ tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
19+ tabs.Add(tabA)
20+
21+ paneB := entity.NewPane(entity.PaneID("pB"))
22+ tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB)
23+ tabs.Add(tabB)
24+ // Ensure tabB active pane is paneB.
25+ tabB.Workspace.ActivePaneID = paneB.ID
26+
27+ out, err := uc.Execute(MovePaneToTabInput{
28+ TabList: tabs,
29+ SourceTabID: tabA.ID,
30+ SourcePaneID: paneA.ID,
31+ TargetTabID: tabB.ID,
32+ })
33+ require.NoError(t, err)
34+ require.True(t, out.SourceTabClosed)
35+ require.False(t, out.NewTabCreated)
36+ require.Equal(t, tabB.ID, out.TargetTab.ID)
37+ require.NotNil(t, out.MovedPaneNode)
38+
39+ require.Nil(t, tabs.Find(tabA.ID))
40+ // Inserted as split in tabB.
41+ require.NotNil(t, tabB.Workspace.Root)
42+ require.True(t, tabB.Workspace.Root.IsSplit())
43+ require.Equal(t, paneB.ID, tabB.Workspace.Root.Left().Pane.ID)
44+ require.Equal(t, paneA.ID, tabB.Workspace.Root.Right().Pane.ID)
45+ // Active pane becomes moved pane.
46+ require.Equal(t, paneA.ID, tabB.Workspace.ActivePaneID)
47+}
48+
49+func TestMovePaneToTab_MoveCreatesNewTabWhenOnlyOneTab(t *testing.T) {
50+ id := newTestIDGen()
51+ uc := NewMovePaneToTabUseCase(id)
52+
53+ tabs := entity.NewTabList()
54+ paneA := entity.NewPane(entity.PaneID("pA"))
55+ tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
56+ tabs.Add(tabA)
57+
58+ out, err := uc.Execute(MovePaneToTabInput{
59+ TabList: tabs,
60+ SourceTabID: tabA.ID,
61+ SourcePaneID: paneA.ID,
62+ TargetTabID: "",
63+ })
64+ require.NoError(t, err)
65+ require.True(t, out.NewTabCreated)
66+ require.True(t, out.SourceTabClosed)
67+ require.Equal(t, 1, tabs.Count())
68+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.ActivePaneID)
69+ require.NotNil(t, out.TargetTab.Workspace.Root)
70+ require.True(t, out.TargetTab.Workspace.Root.IsLeaf())
71+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Pane.ID)
72+}
73+
74+func TestMovePaneToTab_CannotMoveToSameTab(t *testing.T) {
75+ uc := NewMovePaneToTabUseCase(newTestIDGen())
76+ abs := entity.NewTabList()
77+ pane := entity.NewPane(entity.PaneID("pA"))
78+ tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
79+ abs.Add(tab)
80+
81+ _, err := uc.Execute(MovePaneToTabInput{
82+ TabList: abs,
83+ SourceTabID: tab.ID,
84+ SourcePaneID: pane.ID,
85+ TargetTabID: tab.ID,
86+ })
87+ require.Error(t, err)
88+}
89+
90+func TestMovePaneToTab_SourcePaneNotFound(t *testing.T) {
91+ uc := NewMovePaneToTabUseCase(newTestIDGen())
92+ tabs := entity.NewTabList()
93+ pane := entity.NewPane(entity.PaneID("pA"))
94+ tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane)
95+ tabs.Add(tab)
96+
97+ _, err := uc.Execute(MovePaneToTabInput{
98+ TabList: tabs,
99+ SourceTabID: tab.ID,
100+ SourcePaneID: entity.PaneID("missing"),
101+ TargetTabID: entity.TabID("tB"),
102+ })
103+ require.Error(t, err)
104+}
105+
106+func TestMovePaneToTab_MoveFromSplitClosesSourceTabIfLastPane(t *testing.T) {
107+ id := newTestIDGen()
108+ uc := NewMovePaneToTabUseCase(id)
109+
110+ tabs := entity.NewTabList()
111+
112+ // Source workspace: split with two panes; move one, leaving one.
113+ paneLeft := entity.NewPane(entity.PaneID("pL"))
114+ paneRight := entity.NewPane(entity.PaneID("pR"))
115+ source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneLeft)
116+ // Build a split root manually.
117+ source.Workspace.Root = &entity.PaneNode{
118+ ID: "root",
119+ SplitDir: entity.SplitHorizontal,
120+ SplitRatio: 0.5,
121+ Children: []*entity.PaneNode{
122+ {ID: string(paneLeft.ID), Pane: paneLeft},
123+ {ID: string(paneRight.ID), Pane: paneRight},
124+ },
125+ }
126+ source.Workspace.Root.Children[0].Parent = source.Workspace.Root
127+ source.Workspace.Root.Children[1].Parent = source.Workspace.Root
128+ source.Workspace.ActivePaneID = paneRight.ID
129+ tabs.Add(source)
130+
131+ targetPane := entity.NewPane(entity.PaneID("pT"))
132+ target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), targetPane)
133+ tabs.Add(target)
134+
135+ out, err := uc.Execute(MovePaneToTabInput{
136+ TabList: tabs,
137+ SourceTabID: source.ID,
138+ SourcePaneID: paneRight.ID,
139+ TargetTabID: target.ID,
140+ })
141+ require.NoError(t, err)
142+ require.False(t, out.SourceTabClosed)
143+ // Source should now be single leaf.
144+ require.Equal(t, 1, source.Workspace.PaneCount())
145+ require.NotNil(t, source.Workspace.Root)
146+ require.True(t, source.Workspace.Root.IsLeaf())
147+ require.Equal(t, paneLeft.ID, source.Workspace.Root.Pane.ID)
148+}
149+
150+func TestMovePaneToTab_InsertsRightOfActiveEvenWhenActiveInStack(t *testing.T) {
151+ id := newTestIDGen()
152+ uc := NewMovePaneToTabUseCase(id)
153+
154+ tabs := entity.NewTabList()
155+
156+ paneA := entity.NewPane(entity.PaneID("pA"))
157+ source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA)
158+ tabs.Add(source)
159+
160+ // Target workspace: stacked container with two panes; active is second.
161+ pane1 := entity.NewPane(entity.PaneID("p1"))
162+ pane2 := entity.NewPane(entity.PaneID("p2"))
163+ target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), pane1)
164+ stack := &entity.PaneNode{
165+ ID: "stack",
166+ IsStacked: true,
167+ ActiveStackIndex: 1,
168+ Children: []*entity.PaneNode{
169+ {ID: string(pane1.ID), Pane: pane1},
170+ {ID: string(pane2.ID), Pane: pane2},
171+ },
172+ }
173+ stack.Children[0].Parent = stack
174+ stack.Children[1].Parent = stack
175+ target.Workspace.Root = stack
176+ target.Workspace.ActivePaneID = pane2.ID
177+ tabs.Add(target)
178+
179+ out, err := uc.Execute(MovePaneToTabInput{
180+ TabList: tabs,
181+ SourceTabID: source.ID,
182+ SourcePaneID: paneA.ID,
183+ TargetTabID: target.ID,
184+ })
185+ require.NoError(t, err)
186+ require.NotNil(t, out.TargetTab.Workspace.Root)
187+ require.True(t, out.TargetTab.Workspace.Root.IsSplit())
188+ // Left child should be the stack container.
189+ require.True(t, out.TargetTab.Workspace.Root.Left().IsStacked)
190+ // Right child is moved pane.
191+ require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Right().Pane.ID)
192+}
193+
194+func newTestIDGen() func() string {
195+ counter := 0
196+ return func() string {
197+ counter++
198+ return fmt.Sprintf("id%d", counter)
199+ }
200+}
A internal/application/usecase/search_shortcuts.go +226 -0
@@ -0,0 +1,226 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "sort"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/domain/url"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// SearchShortcut represents a search shortcut configuration.
13+// This is a domain-level type to avoid depending on infrastructure config.
14+type SearchShortcut struct {
15+ URL string
16+ Description string
17+}
18+
19+// BangSuggestion represents a configured bang shortcut for display.
20+type BangSuggestion struct {
21+ Key string
22+ Description string
23+}
24+
25+// SearchShortcutsUseCase handles bang shortcut filtering and resolution.
26+type SearchShortcutsUseCase struct {
27+ shortcuts map[string]SearchShortcut
28+}
29+
30+// NewSearchShortcutsUseCase creates a new search shortcuts use case.
31+func NewSearchShortcutsUseCase(shortcuts map[string]SearchShortcut) *SearchShortcutsUseCase {
32+ return &SearchShortcutsUseCase{
33+ shortcuts: shortcuts,
34+ }
35+}
36+
37+// FilterBangsInput contains parameters for filtering bang suggestions.
38+type FilterBangsInput struct {
39+ Query string // e.g., "!" or "!g" or "!g query"
40+}
41+
42+// FilterBangsOutput contains filtered bang suggestions.
43+type FilterBangsOutput struct {
44+ Suggestions []BangSuggestion
45+}
46+
47+// FilterBangs returns bang shortcuts matching the query prefix.
48+// Query should start with "!". The prefix is extracted before any space.
49+func (uc *SearchShortcutsUseCase) FilterBangs(ctx context.Context, input FilterBangsInput) *FilterBangsOutput {
50+ log := logging.FromContext(ctx)
51+
52+ suggestions := uc.buildBangSuggestions(input.Query)
53+
54+ log.Debug().
55+ Str("query", input.Query).
56+ Int("matches", len(suggestions)).
57+ Msg("filtered bang suggestions")
58+
59+ return &FilterBangsOutput{Suggestions: suggestions}
60+}
61+
62+// DetectBangKeyInput contains parameters for detecting a completed bang key.
63+type DetectBangKeyInput struct {
64+ Query string // e.g., "!gh query"
65+}
66+
67+// DetectBangKeyOutput contains the detected bang key if found.
68+type DetectBangKeyOutput struct {
69+ Key string // The matched key (empty if not found)
70+ Description string // Description of the matched shortcut
71+}
72+
73+// DetectBangKey checks if the query contains a valid, completed bang key.
74+// A completed bang key requires "!<key> " with a space after the key.
75+func (uc *SearchShortcutsUseCase) DetectBangKey(ctx context.Context, input DetectBangKeyInput) *DetectBangKeyOutput {
76+ log := logging.FromContext(ctx)
77+
78+ key := uc.detectBangKey(input.Query)
79+ if key == "" {
80+ return &DetectBangKeyOutput{}
81+ }
82+
83+ shortcut, ok := uc.shortcuts[key]
84+ if !ok {
85+ return &DetectBangKeyOutput{}
86+ }
87+
88+ description := shortcut.Description
89+ if description == "" {
90+ description = shortcut.URL
91+ }
92+
93+ log.Debug().
94+ Str("query", input.Query).
95+ Str("detected_key", key).
96+ Msg("detected bang key")
97+
98+ return &DetectBangKeyOutput{
99+ Key: key,
100+ Description: description,
101+ }
102+}
103+
104+// BuildNavigationTextInput contains parameters for building navigation text.
105+type BuildNavigationTextInput struct {
106+ EntryText string // e.g., "!GH dumber"
107+}
108+
109+// BuildNavigationTextOutput contains the normalized navigation text.
110+type BuildNavigationTextOutput struct {
111+ Text string // Normalized text (e.g., "!gh dumber") or empty if invalid
112+ Valid bool // Whether the entry text represents a valid bang navigation
113+}
114+
115+// BuildNavigationText normalizes a bang query for navigation.
116+// Returns the normalized text with the canonical key case.
117+func (uc *SearchShortcutsUseCase) BuildNavigationText(ctx context.Context, input BuildNavigationTextInput) *BuildNavigationTextOutput {
118+ log := logging.FromContext(ctx)
119+
120+ text := uc.buildBangNavigationText(input.EntryText)
121+ if text == "" {
122+ return &BuildNavigationTextOutput{}
123+ }
124+
125+ log.Debug().
126+ Str("entry_text", input.EntryText).
127+ Str("navigation_text", text).
128+ Msg("built bang navigation text")
129+
130+ return &BuildNavigationTextOutput{
131+ Text: text,
132+ Valid: true,
133+ }
134+}
135+
136+// GetShortcut returns a shortcut by key (case-insensitive).
137+func (uc *SearchShortcutsUseCase) GetShortcut(key string) (SearchShortcut, bool) {
138+ normalizedKey, ok := uc.normalizeBangKey(key)
139+ if !ok {
140+ return SearchShortcut{}, false
141+ }
142+ shortcut, ok := uc.shortcuts[normalizedKey]
143+ return shortcut, ok
144+}
145+
146+// ShortcutURLs returns a map of shortcut keys to URL templates.
147+// This is useful for passing to url.BuildSearchURL.
148+func (uc *SearchShortcutsUseCase) ShortcutURLs() map[string]string {
149+ result := make(map[string]string, len(uc.shortcuts))
150+ for key, shortcut := range uc.shortcuts {
151+ result[key] = shortcut.URL
152+ }
153+ return result
154+}
155+
156+// buildBangSuggestions filters and sorts shortcuts matching the query prefix.
157+func (uc *SearchShortcutsUseCase) buildBangSuggestions(query string) []BangSuggestion {
158+ prefix := strings.TrimPrefix(query, "!")
159+ if idx := strings.Index(prefix, " "); idx >= 0 {
160+ prefix = prefix[:idx]
161+ }
162+ prefix = strings.ToLower(prefix)
163+
164+ suggestions := make([]BangSuggestion, 0, len(uc.shortcuts))
165+ for key, shortcut := range uc.shortcuts {
166+ if !strings.HasPrefix(strings.ToLower(key), prefix) {
167+ continue
168+ }
169+ description := shortcut.Description
170+ if description == "" {
171+ description = shortcut.URL
172+ }
173+ suggestions = append(suggestions, BangSuggestion{
174+ Key: key,
175+ Description: description,
176+ })
177+ }
178+
179+ sort.Slice(suggestions, func(i, j int) bool {
180+ return strings.ToLower(suggestions[i].Key) < strings.ToLower(suggestions[j].Key)
181+ })
182+
183+ return suggestions
184+}
185+
186+// detectBangKey returns the matched shortcut key if the query has a valid bang prefix.
187+func (uc *SearchShortcutsUseCase) detectBangKey(query string) string {
188+ spaceIdx := strings.Index(query, " ")
189+ if !strings.HasPrefix(query, "!") || spaceIdx <= 1 {
190+ return ""
191+ }
192+
193+ candidate := query[1:spaceIdx]
194+ for key := range uc.shortcuts {
195+ if strings.EqualFold(key, candidate) {
196+ return key
197+ }
198+ }
199+
200+ return ""
201+}
202+
203+// normalizeBangKey returns the canonical key for a shortcut (case-insensitive lookup).
204+func (uc *SearchShortcutsUseCase) normalizeBangKey(shortcutKey string) (string, bool) {
205+ for key := range uc.shortcuts {
206+ if strings.EqualFold(key, shortcutKey) {
207+ return key, true
208+ }
209+ }
210+ return "", false
211+}
212+
213+// buildBangNavigationText normalizes a bang query for navigation.
214+func (uc *SearchShortcutsUseCase) buildBangNavigationText(entryText string) string {
215+ shortcutKey, query, found := url.ParseBangShortcut(entryText)
216+ if !found || query == "" {
217+ return ""
218+ }
219+
220+ resolvedKey, ok := uc.normalizeBangKey(shortcutKey)
221+ if !ok {
222+ return ""
223+ }
224+
225+ return "!" + resolvedKey + " " + query
226+}
A internal/application/usecase/search_shortcuts_test.go +175 -0
@@ -0,0 +1,175 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+)
7+
8+func TestFilterBangs(t *testing.T) {
9+ shortcuts := map[string]SearchShortcut{
10+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo search"},
11+ "g": {URL: "https://google.com/search?q=%s", Description: "Google search"},
12+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
13+ "n": {URL: "https://news.ycombinator.com/", Description: ""},
14+ }
15+ uc := NewSearchShortcutsUseCase(shortcuts)
16+ ctx := context.Background()
17+
18+ cases := []struct {
19+ name string
20+ query string
21+ wantKeys []string
22+ wantDescr map[string]string
23+ }{
24+ {
25+ name: "bang only returns all sorted",
26+ query: "!",
27+ wantKeys: []string{"ddg", "g", "gh", "n"},
28+ },
29+ {
30+ name: "filters by prefix",
31+ query: "!g",
32+ wantKeys: []string{"g", "gh"},
33+ },
34+ {
35+ name: "filters by prefix case-insensitive",
36+ query: "!DD",
37+ wantKeys: []string{"ddg"},
38+ },
39+ {
40+ name: "stops prefix at space",
41+ query: "!g query",
42+ wantKeys: []string{"g", "gh"},
43+ },
44+ {
45+ name: "falls back to url when description empty",
46+ query: "!n",
47+ wantKeys: []string{"n"},
48+ wantDescr: map[string]string{
49+ "n": "https://news.ycombinator.com/",
50+ },
51+ },
52+ }
53+
54+ for _, tc := range cases {
55+ t.Run(tc.name, func(t *testing.T) {
56+ output := uc.FilterBangs(ctx, FilterBangsInput{Query: tc.query})
57+ got := output.Suggestions
58+ if len(got) != len(tc.wantKeys) {
59+ t.Fatalf("len=%d want=%d", len(got), len(tc.wantKeys))
60+ }
61+ for i, wantKey := range tc.wantKeys {
62+ if got[i].Key != wantKey {
63+ t.Fatalf("idx %d key=%q want=%q", i, got[i].Key, wantKey)
64+ }
65+ if tc.wantDescr != nil {
66+ if wantD, ok := tc.wantDescr[wantKey]; ok {
67+ if got[i].Description != wantD {
68+ t.Fatalf("key %q description=%q want=%q", wantKey, got[i].Description, wantD)
69+ }
70+ }
71+ }
72+ }
73+ })
74+ }
75+}
76+
77+func TestDetectBangKey(t *testing.T) {
78+ shortcuts := map[string]SearchShortcut{
79+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"},
80+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: ""},
81+ }
82+ uc := NewSearchShortcutsUseCase(shortcuts)
83+ ctx := context.Background()
84+
85+ cases := []struct {
86+ name string
87+ query string
88+ wantKey string
89+ wantDescr string
90+ }{
91+ {name: "no bang prefix", query: "gh test", wantKey: "", wantDescr: ""},
92+ {name: "bang only has no space", query: "!gh", wantKey: "", wantDescr: ""},
93+ {name: "space at position 1", query: "! test", wantKey: "", wantDescr: ""},
94+ {name: "unknown bang", query: "!nope test", wantKey: "", wantDescr: ""},
95+ {name: "case-insensitive match", query: "!GH test", wantKey: "gh", wantDescr: "GitHub search"},
96+ {name: "valid bang key", query: "!ddg test", wantKey: "ddg", wantDescr: "https://duckduckgo.com/?q=%s"},
97+ {name: "description fallback to URL", query: "!DDG query", wantKey: "ddg", wantDescr: "https://duckduckgo.com/?q=%s"},
98+ }
99+
100+ for _, tc := range cases {
101+ t.Run(tc.name, func(t *testing.T) {
102+ output := uc.DetectBangKey(ctx, DetectBangKeyInput{Query: tc.query})
103+ if output.Key != tc.wantKey {
104+ t.Fatalf("DetectBangKey(%q).Key=%q want=%q", tc.query, output.Key, tc.wantKey)
105+ }
106+ if output.Description != tc.wantDescr {
107+ t.Fatalf("DetectBangKey(%q).Description=%q want=%q", tc.query, output.Description, tc.wantDescr)
108+ }
109+ })
110+ }
111+}
112+
113+func TestBuildNavigationText(t *testing.T) {
114+ shortcuts := map[string]SearchShortcut{
115+ "gh": {URL: "https://github.com/search?q=%s"},
116+ "ddg": {URL: "https://duckduckgo.com/?q=%s"},
117+ }
118+ uc := NewSearchShortcutsUseCase(shortcuts)
119+ ctx := context.Background()
120+
121+ cases := []struct {
122+ name string
123+ entryText string
124+ want string
125+ wantValid bool
126+ }{
127+ {name: "not a bang shortcut", entryText: "example.com", want: "", wantValid: false},
128+ {name: "bang key without query", entryText: "!gh ", want: "", wantValid: false},
129+ {name: "unknown bang key", entryText: "!nope test", want: "", wantValid: false},
130+ {name: "normalizes key case", entryText: "!GH dumber", want: "!gh dumber", wantValid: true},
131+ {name: "keeps query unchanged", entryText: "!ddg some query", want: "!ddg some query", wantValid: true},
132+ }
133+
134+ for _, tc := range cases {
135+ t.Run(tc.name, func(t *testing.T) {
136+ output := uc.BuildNavigationText(ctx, BuildNavigationTextInput{EntryText: tc.entryText})
137+ if output.Text != tc.want {
138+ t.Fatalf("BuildNavigationText(%q).Text=%q want=%q", tc.entryText, output.Text, tc.want)
139+ }
140+ if output.Valid != tc.wantValid {
141+ t.Fatalf("BuildNavigationText(%q).Valid=%v want=%v", tc.entryText, output.Valid, tc.wantValid)
142+ }
143+ })
144+ }
145+}
146+
147+func TestGetShortcut(t *testing.T) {
148+ shortcuts := map[string]SearchShortcut{
149+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"},
150+ }
151+ uc := NewSearchShortcutsUseCase(shortcuts)
152+
153+ cases := []struct {
154+ name string
155+ key string
156+ wantOK bool
157+ wantURL string
158+ }{
159+ {name: "exact match", key: "gh", wantOK: true, wantURL: "https://github.com/search?q=%s"},
160+ {name: "case insensitive", key: "GH", wantOK: true, wantURL: "https://github.com/search?q=%s"},
161+ {name: "not found", key: "unknown", wantOK: false, wantURL: ""},
162+ }
163+
164+ for _, tc := range cases {
165+ t.Run(tc.name, func(t *testing.T) {
166+ shortcut, ok := uc.GetShortcut(tc.key)
167+ if ok != tc.wantOK {
168+ t.Fatalf("GetShortcut(%q) ok=%v want=%v", tc.key, ok, tc.wantOK)
169+ }
170+ if shortcut.URL != tc.wantURL {
171+ t.Fatalf("GetShortcut(%q).URL=%q want=%q", tc.key, shortcut.URL, tc.wantURL)
172+ }
173+ })
174+ }
175+}
A internal/bootstrap/gui.go +308 -0
@@ -0,0 +1,308 @@
1+// Package bootstrap provides initialization utilities for the browser.
2+package bootstrap
3+
4+import (
5+ "context"
6+ "database/sql"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+ "github.com/bnema/dumber/internal/infrastructure/deps"
16+ "github.com/bnema/dumber/internal/infrastructure/media"
17+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
18+ "github.com/bnema/dumber/internal/logging"
19+ "github.com/bnema/dumber/internal/ui/theme"
20+ sqlite3 "github.com/ncruces/go-sqlite3"
21+)
22+
23+// DatabaseResult holds database connection and cleanup function.
24+type DatabaseResult struct {
25+ DB *sql.DB
26+ Cleanup func()
27+}
28+
29+// ParallelInitResult holds the results of parallel initialization phase.
30+type ParallelInitResult struct {
31+ DataDir string
32+ CacheDir string
33+ ThemeManager *theme.Manager
34+ Duration time.Duration
35+}
36+
37+// DeferredInitResult holds results from deferred initialization checks.
38+type DeferredInitResult struct {
39+ RuntimeErr error
40+ MediaErr error
41+ SQLiteErr error
42+ Duration time.Duration
43+}
44+
45+// ParallelInitInput holds the input for parallel initialization.
46+type ParallelInitInput struct {
47+ Ctx context.Context
48+ Config *config.Config
49+}
50+
51+// DeferredInitInput holds the input for deferred initialization.
52+type DeferredInitInput struct {
53+ Ctx context.Context
54+ Config *config.Config
55+}
56+
57+// RuntimeRequirementsError contains details about missing runtime dependencies.
58+type RuntimeRequirementsError struct {
59+ Checks []usecase.RuntimeDependencyStatus
60+}
61+
62+func (*RuntimeRequirementsError) Error() string {
63+ return "runtime requirements not met"
64+}
65+
66+// LogDetails logs detailed information about missing dependencies.
67+func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
68+ log := logging.FromContext(ctx)
69+ for _, c := range e.Checks {
70+ if c.Installed {
71+ log.Error().
72+ Str("dependency", c.PkgConfigName).
73+ Str("have", c.Version).
74+ Str("need", c.RequiredVersion).
75+ Bool("ok", c.MeetsRequirement).
76+ Msg("runtime dependency")
77+ } else {
78+ log.Error().
79+ Str("dependency", c.PkgConfigName).
80+ Str("need", c.RequiredVersion).
81+ Msg("runtime dependency missing")
82+ }
83+ }
84+}
85+
86+// RunParallelInit runs the essential parallel initialization phase.
87+// This includes directory resolution and theme creation.
88+// Returns the first fatal error encountered, or nil with the results.
89+func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
90+ var (
91+ dataDir, cacheDir string
92+ themeManager *theme.Manager
93+ dirsErr error
94+ wg sync.WaitGroup
95+ )
96+
97+ start := time.Now()
98+ wg.Add(2)
99+
100+ // Resolve directories
101+ go func() {
102+ defer wg.Done()
103+ dataDir, cacheDir, dirsErr = resolveWebKitDirs()
104+ }()
105+
106+ // Theme manager (CPU-bound, no I/O)
107+ go func() {
108+ defer wg.Done()
109+ themeManager = theme.NewManager(input.Ctx, input.Config)
110+ }()
111+
112+ wg.Wait()
113+ duration := time.Since(start)
114+
115+ // Return first fatal error
116+ if dirsErr != nil {
117+ return nil, fmt.Errorf("resolve directories: %w", dirsErr)
118+ }
119+
120+ return &ParallelInitResult{
121+ DataDir: dataDir,
122+ CacheDir: cacheDir,
123+ ThemeManager: themeManager,
124+ Duration: duration,
125+ }, nil
126+}
127+
128+// RunDeferredInit runs deferred initialization checks off the critical path.
129+// This includes SQLite WASM precompile, runtime requirements, and media checks.
130+func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
131+ var (
132+ runtimeErr error
133+ mediaErr error
134+ sqliteErr error
135+ wg sync.WaitGroup
136+ )
137+
138+ start := time.Now()
139+ wg.Add(3)
140+
141+ go func() {
142+ defer wg.Done()
143+ sqliteErr = sqlite3.Initialize()
144+ }()
145+
146+ go func() {
147+ defer wg.Done()
148+ runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config)
149+ }()
150+
151+ go func() {
152+ defer wg.Done()
153+ mediaErr = CheckMediaRequirements(input.Ctx, input.Config)
154+ }()
155+
156+ wg.Wait()
157+
158+ return DeferredInitResult{
159+ RuntimeErr: runtimeErr,
160+ MediaErr: mediaErr,
161+ SQLiteErr: sqliteErr,
162+ Duration: time.Since(start),
163+ }
164+}
165+
166+// CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies.
167+// Returns error if requirements are not met; caller should log details and exit.
168+func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error {
169+ probe := deps.NewPkgConfigProbe()
170+ checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
171+ runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
172+ Prefix: cfg.Runtime.Prefix,
173+ })
174+ if err != nil {
175+ return fmt.Errorf("runtime check execution failed: %w", err)
176+ }
177+ if runtimeOut.OK {
178+ return nil
179+ }
180+ return &RuntimeRequirementsError{Checks: runtimeOut.Checks}
181+}
182+
183+// CheckMediaRequirements verifies GStreamer and media codec availability.
184+// Returns error if media subsystem initialization fails.
185+func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error {
186+ mediaDiagAdapter := media.New()
187+ checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
188+ if _, err := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
189+ ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
190+ }); err != nil {
191+ return fmt.Errorf("media check failed: %w", err)
192+ }
193+ return nil
194+}
195+
196+// resolveWebKitDirs resolves and creates the data and cache directories for WebKit.
197+func resolveWebKitDirs() (dataDir, cacheDir string, err error) {
198+ const cacheDirPerm = 0o755
199+ dataDir, err = config.GetDataDir()
200+ if err != nil {
201+ return "", "", fmt.Errorf("resolve data directory: %w", err)
202+ }
203+ stateDir, err := config.GetStateDir()
204+ if err != nil {
205+ return "", "", fmt.Errorf("resolve state directory: %w", err)
206+ }
207+ cacheDir = filepath.Join(stateDir, "webkit-cache")
208+ if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {
209+ return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr)
210+ }
211+ return dataDir, cacheDir, nil
212+}
213+
214+// OpenDatabase opens and initializes the SQLite database using XDG paths.
215+// Returns error if database cannot be opened or migrations fail.
216+func OpenDatabase(ctx context.Context) (*DatabaseResult, error) {
217+ dbPath, err := config.GetDatabaseFile()
218+ if err != nil {
219+ return nil, fmt.Errorf("resolve database path: %w", err)
220+ }
221+ db, err := sqlite.NewConnection(ctx, dbPath)
222+ if err != nil {
223+ return nil, fmt.Errorf("initialize database at %s: %w", dbPath, err)
224+ }
225+ return &DatabaseResult{
226+ DB: db,
227+ Cleanup: func() {
228+ _ = sqlite.Close(db)
229+ },
230+ }, nil
231+}
232+
233+// CreateLazyDatabase creates a lazy database provider that defers initialization.
234+// The database is initialized on first access, allowing the UI to render faster.
235+//
236+// Currently unused: the application uses OpenDatabase with RunParallelDBWebKit for
237+// eager initialization. This function is kept for potential future use when lazy
238+// initialization past first paint becomes beneficial.
239+func CreateLazyDatabase() (*sqlite.LazyDB, error) {
240+ dbPath, err := config.GetDatabaseFile()
241+ if err != nil {
242+ return nil, fmt.Errorf("resolve database path: %w", err)
243+ }
244+ return sqlite.NewLazyDB(dbPath), nil
245+}
246+
247+// ParallelDBWebKitResult holds results from parallel DB and WebKit initialization.
248+type ParallelDBWebKitResult struct {
249+ DB *sql.DB
250+ DBCleanup func()
251+ Stack WebKitStack
252+}
253+
254+// ParallelDBWebKitInput holds inputs for parallel DB and WebKit initialization.
255+type ParallelDBWebKitInput struct {
256+ Ctx context.Context
257+ Config *config.Config
258+ DataDir string // For WebKit context
259+ CacheDir string // For WebKit cache
260+ ThemeManager *theme.Manager
261+}
262+
263+// Note: Database path is resolved via config.GetDatabaseFile() internally.
264+
265+// dbInitResult holds the result of database initialization for channel communication.
266+type dbInitResult struct {
267+ db *sql.DB
268+ cleanup func()
269+ err error
270+}
271+
272+// RunParallelDBWebKit initializes database in background while WebKit runs on main thread.
273+// Database init happens in goroutine (pure Go/WASM), WebKit must stay on main thread (GTK).
274+// This saves ~150ms by overlapping DB migrations with WebKit context creation.
275+func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult, error) {
276+ log := logging.FromContext(input.Ctx)
277+
278+ dbCh := make(chan dbInitResult, 1)
279+
280+ // Database initialization in background (WASM is thread-safe)
281+ go func() {
282+ dbResult, err := OpenDatabase(input.Ctx)
283+ if err != nil {
284+ dbCh <- dbInitResult{err: err}
285+ return
286+ }
287+ dbCh <- dbInitResult{
288+ db: dbResult.DB,
289+ cleanup: dbResult.Cleanup,
290+ }
291+ }()
292+
293+ // WebKit stack on main thread (GTK requirement)
294+ stack := BuildWebKitStack(input.Ctx, input.Config, input.DataDir, input.CacheDir, input.ThemeManager, *log)
295+
296+ // Wait for database
297+ dbRes := <-dbCh
298+
299+ if dbRes.err != nil {
300+ return nil, fmt.Errorf("database initialization: %w", dbRes.err)
301+ }
302+
303+ return &ParallelDBWebKitResult{
304+ DB: dbRes.db,
305+ DBCleanup: dbRes.cleanup,
306+ Stack: stack,
307+ }, nil
308+}
M internal/bootstrap/session.go +147 -52
@@ -2,20 +2,20 @@ package bootstrap
22
33 import (
44 "context"
5- "database/sql"
65 "errors"
76 "fmt"
87 "os"
98 "path/filepath"
9+ "sync"
1010 "syscall"
1111 "time"
1212
1313 "github.com/bnema/dumber/internal/application/port"
1414 "github.com/bnema/dumber/internal/application/usecase"
1515 "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/domain/repository"
1617 "github.com/bnema/dumber/internal/infrastructure/config"
1718 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
18- "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
1919 corelogging "github.com/bnema/dumber/internal/logging"
2020 "github.com/rs/zerolog"
2121 )
@@ -31,11 +31,20 @@ type BrowserSession struct {
3131 Logger zerolog.Logger
3232 LogCleanup func()
3333
34- End func(context.Context) error
34+ Persist func(context.Context) error
35+ End func(context.Context) error
3536 }
3637
37-func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*BrowserSession, context.Context, error) {
38+func StartBrowserSession(
39+ ctx context.Context,
40+ cfg *config.Config,
41+ sessionRepo repository.SessionRepository,
42+ deferPersist bool,
43+) (*BrowserSession, context.Context, error) {
3844 log := corelogging.FromContext(ctx)
45+ if sessionRepo == nil {
46+ return nil, ctx, errors.New("session repository is nil")
47+ }
3948
4049 lockDir := cfg.Logging.LogDir
4150 if lockDir == "" {
@@ -44,72 +53,112 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*
4453 }
4554 }
4655
47- sessionRepo := sqlite.NewSessionRepository(db)
4856 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
4957 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
58+ cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
5059
51- // If a previous browser process died without cleanup, it may have left sessions "active".
52- // Use per-session lock files to distinguish truly running sessions from stale ones.
53- if lockDir != "" {
54- if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil {
55- log.Warn().Err(err).Msg("failed to mark stale sessions ended")
56- }
60+ now := time.Now()
61+ session := &entity.Session{
62+ ID: entity.SessionID(corelogging.GenerateSessionID()),
63+ Type: entity.SessionTypeBrowser,
64+ StartedAt: now.UTC(),
5765 }
58-
59- // Clean up old exited sessions based on config limits
60- cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
61- cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{
62- MaxExitedSessions: cfg.Session.MaxExitedSessions,
63- MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
64- })
65- if err != nil {
66- log.Warn().Err(err).Msg("failed to cleanup old sessions")
67- } else if cleanupOutput.TotalDeleted > 0 {
68- log.Info().
69- Int64("deleted", cleanupOutput.TotalDeleted).
70- Msg("cleaned up old sessions on startup")
66+ if err := session.Validate(); err != nil {
67+ return nil, ctx, err
7168 }
7269
73- now := time.Now()
74- out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{
75- Type: entity.SessionTypeBrowser,
76- Now: now,
77- LogConfig: port.SessionLogConfig{
78- Level: cfg.Logging.Level,
79- Format: cfg.Logging.Format,
80- TimeFormat: "15:04:05",
81- LogDir: cfg.Logging.LogDir,
82- WriteToStderr: true,
83- EnableFileLog: cfg.Logging.EnableFileLog,
84- },
70+ logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
71+ Level: cfg.Logging.Level,
72+ Format: cfg.Logging.Format,
73+ TimeFormat: corelogging.ConsoleTimeFormat,
74+ LogDir: cfg.Logging.LogDir,
75+ WriteToStderr: true,
76+ EnableFileLog: cfg.Logging.EnableFileLog,
8577 })
8678 if err != nil {
87- return nil, ctx, err
79+ log.Warn().Err(err).Msg("failed to create session logger")
8880 }
8981
90- lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID)
91- if lockErr != nil {
92- // Don’t fail startup if locking fails; it only affects stale-session cleanup.
93- out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
94- }
82+ sessionCtx := corelogging.WithContext(context.Background(), logger)
83+
84+ var (
85+ lockMu sync.Mutex
86+ lockFile *os.File
87+ lockPath string
88+ persistMu sync.Once
89+ persistErr error
90+ )
91+
92+ // persistFn ensures the session is saved exactly once. Using sync.Once is intentional:
93+ // if the first persist fails, we cache the error and return it on subsequent calls
94+ // rather than retrying. This prevents duplicate session records and ensures consistent
95+ // error handling during shutdown sequences where persistFn may be called multiple times.
96+ persistFn := func(persistCtx context.Context) error {
97+ persistMu.Do(func() {
98+ saveCtx := corelogging.WithContext(context.Background(), logger)
99+ if err := sessionRepo.Save(saveCtx, session); err != nil {
100+ persistErr = fmt.Errorf("save session: %w", err)
101+ return
102+ }
95103
96- sessionCtx := corelogging.WithContext(context.Background(), out.Logger)
104+ logger.Info().
105+ Str("session_id", string(session.ID)).
106+ Str("type", string(session.Type)).
107+ Msg("session started")
108+
109+ lf, lp, lockErr := lockSessionFile(lockDir, session.ID)
110+ if lockErr != nil {
111+ // Don’t fail startup if locking fails; it only affects stale-session cleanup.
112+ logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
113+ } else {
114+ lockMu.Lock()
115+ lockFile = lf
116+ lockPath = lp
117+ lockMu.Unlock()
118+ }
119+
120+ // Defer stale session cleanup and old session pruning to background.
121+ // This avoids blocking startup for non-critical housekeeping tasks.
122+ runSessionCleanupAsync(persistCtx, sessionUC, cleanupUC, cfg, lockDir, log)
123+ })
124+ return persistErr
125+ }
97126
98127 endFn := func(endCtx context.Context) error {
99- endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now())
100- if lockFile != nil {
101- _ = unlockAndClose(lockFile)
102- _ = os.Remove(lockPath)
128+ _ = persistFn(endCtx)
129+ endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
130+
131+ lockMu.Lock()
132+ lf := lockFile
133+ lp := lockPath
134+ lockMu.Unlock()
135+
136+ if lf != nil {
137+ _ = unlockAndClose(lf)
138+ _ = os.Remove(lp)
103139 }
104140 return endErr
105141 }
106142
107- return &BrowserSession{
108- Session: out.Session,
109- Logger: out.Logger,
110- LogCleanup: out.LogCleanup,
143+ browserSession := &BrowserSession{
144+ Session: session,
145+ Logger: logger,
146+ LogCleanup: cleanup,
111147 End: endFn,
112- }, sessionCtx, nil
148+ }
149+
150+ if deferPersist {
151+ browserSession.Persist = persistFn
152+ } else {
153+ if err := persistFn(ctx); err != nil {
154+ if cleanup != nil {
155+ cleanup()
156+ }
157+ return nil, ctx, err
158+ }
159+ }
160+
161+ return browserSession, sessionCtx, nil
113162 }
114163
115164 func endStaleActiveBrowserSessions(
@@ -213,3 +262,49 @@ func unlockAndClose(f *os.File) error {
213262 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
214263 return f.Close()
215264 }
265+
266+// runSessionCleanupAsync performs stale session cleanup and old session pruning
267+// in a background goroutine. This avoids blocking startup for non-critical tasks.
268+// Uses context.Background() to ensure cleanup completes even if startup context is canceled.
269+func runSessionCleanupAsync(
270+ startupCtx context.Context,
271+ sessionUC *usecase.ManageSessionUseCase,
272+ cleanupUC *usecase.CleanupSessionsUseCase,
273+ cfg *config.Config,
274+ lockDir string,
275+ log *zerolog.Logger,
276+) {
277+ // Silence unused parameter warning - startupCtx is intentionally unused.
278+ _ = startupCtx
279+
280+ go func() {
281+ // Use a detached background context instead of the startup context:
282+ // session cleanup is critical and must run to completion even if the
283+ // startup context is canceled or times out.
284+ bgCtx := context.Background()
285+
286+ // End stale active sessions (orphaned from crashed processes)
287+ if lockDir != "" {
288+ if err := endStaleActiveBrowserSessions(bgCtx, sessionUC, lockDir, log); err != nil {
289+ if log != nil {
290+ log.Warn().Err(err).Msg("background: failed to mark stale sessions ended")
291+ }
292+ }
293+ }
294+
295+ // Clean up old exited sessions based on config limits
296+ cleanupOutput, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{
297+ MaxExitedSessions: cfg.Session.MaxExitedSessions,
298+ MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
299+ })
300+ if err != nil {
301+ if log != nil {
302+ log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
303+ }
304+ } else if cleanupOutput.TotalDeleted > 0 {
305+ if log != nil {
306+ log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
307+ }
308+ }
309+ }()
310+}
A internal/bootstrap/timing.go +94 -0
@@ -0,0 +1,94 @@
1+// Package bootstrap provides initialization utilities for the browser.
2+package bootstrap
3+
4+import (
5+ "context"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// StartupTimer tracks timing for cold start phases.
13+// Thread-safe for use with parallel initialization.
14+type StartupTimer struct {
15+ start time.Time
16+ phases map[string]time.Duration
17+ order []string // Track insertion order for logging
18+ last time.Time
19+ mu sync.Mutex
20+}
21+
22+// NewStartupTimer creates a new timer starting from now.
23+func NewStartupTimer() *StartupTimer {
24+ now := time.Now()
25+ return &StartupTimer{
26+ start: now,
27+ phases: make(map[string]time.Duration),
28+ order: make([]string, 0),
29+ last: now,
30+ }
31+}
32+
33+// Mark records the duration since the last mark (or start) for the given phase.
34+func (t *StartupTimer) Mark(phase string) {
35+ t.mu.Lock()
36+ defer t.mu.Unlock()
37+
38+ now := time.Now()
39+ t.phases[phase] = now.Sub(t.last)
40+ t.order = append(t.order, phase)
41+ t.last = now
42+}
43+
44+// MarkDuration records a specific duration for a phase.
45+// Useful for operations timed independently (e.g., parallel goroutines).
46+func (t *StartupTimer) MarkDuration(phase string, d time.Duration) {
47+ t.mu.Lock()
48+ defer t.mu.Unlock()
49+
50+ t.phases[phase] = d
51+ t.order = append(t.order, phase)
52+}
53+
54+// Total returns the total elapsed time since timer creation.
55+// Thread-safe: uses mutex for memory visibility guarantees.
56+func (t *StartupTimer) Total() time.Duration {
57+ t.mu.Lock()
58+ defer t.mu.Unlock()
59+ return time.Since(t.start)
60+}
61+
62+// Log outputs all timing information to the context logger.
63+func (t *StartupTimer) Log(ctx context.Context) {
64+ t.mu.Lock()
65+ defer t.mu.Unlock()
66+
67+ log := logging.FromContext(ctx)
68+ total := time.Since(t.start)
69+
70+ event := log.Info().Dur("total", total)
71+ for _, phase := range t.order {
72+ if dur, ok := t.phases[phase]; ok {
73+ event = event.Dur(phase, dur)
74+ }
75+ }
76+ event.Msg("startup timing")
77+}
78+
79+// LogDebug outputs timing as debug level (for less verbose production logs).
80+func (t *StartupTimer) LogDebug(ctx context.Context) {
81+ t.mu.Lock()
82+ defer t.mu.Unlock()
83+
84+ log := logging.FromContext(ctx)
85+ total := time.Since(t.start)
86+
87+ event := log.Debug().Dur("total", total)
88+ for _, phase := range t.order {
89+ if dur, ok := t.phases[phase]; ok {
90+ event = event.Dur(phase, dur)
91+ }
92+ }
93+ event.Msg("startup timing")
94+}
M internal/bootstrap/webkit_stack.go +13 -0
@@ -95,6 +95,9 @@ func BuildWebKitStack(
9595 settings := webkit.NewSettingsManager(ctx, cfg)
9696 injector := webkit.NewContentInjector(themeManager.PrefersDark())
9797
98+ // Set background color on injector for early CSS injection (prevents white flash)
99+ injector.SetBackgroundColor(themeManager.GetCurrentPalette().Background)
100+
98101 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
99102 themeCSSText := themeManager.GetWebUIThemeCSS()
100103 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {
@@ -108,11 +111,21 @@ func BuildWebKitStack(
108111 poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount
109112 }
110113 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
114+ // Ensure prewarmed WebViews pick up the theme background color.
115+ bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA()
116+ pool.SetBackgroundColor(bgR, bgG, bgB, bgA)
111117
112118 if filterManager != nil {
113119 pool.SetFilterApplier(filterManager)
114120 }
115121
122+ // Pre-create ONE WebView synchronously for instant first navigation.
123+ // This is the heaviest operation but happens before window is shown,
124+ // so users perceive it as part of the normal "loading" phase.
125+ if err := pool.PrewarmFirst(ctx); err != nil {
126+ logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
127+ }
128+
116129 return WebKitStack{
117130 Context: wkCtx,
118131 Settings: settings,
A internal/cli/cmd/config.go +416 -0
@@ -0,0 +1,416 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+
9+ "github.com/charmbracelet/bubbles/spinner"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/lipgloss"
12+ "github.com/spf13/cobra"
13+
14+ "github.com/bnema/dumber/internal/application/port"
15+ "github.com/bnema/dumber/internal/application/usecase"
16+ "github.com/bnema/dumber/internal/cli/styles"
17+ "github.com/bnema/dumber/internal/infrastructure/config"
18+)
19+
20+var configYes bool
21+
22+var configCmd = &cobra.Command{
23+ Use: "config",
24+ Short: "Manage configuration",
25+ Long: `View configuration status and migrate to add new default settings.`,
26+}
27+
28+var configOpenCmd = &cobra.Command{
29+ Use: "open",
30+ Short: "Open config file in your editor",
31+ Long: `Opens the config file using your preferred editor.
32+
33+Editor selection order:
34+ 1. $EDITOR environment variable
35+ 2. $VISUAL environment variable
36+ 3. Fallback to: nano, vim, vi (first found)`,
37+ RunE: runConfigOpen,
38+}
39+
40+var configStatusCmd = &cobra.Command{
41+ Use: "status",
42+ Short: "Show config file status and migration availability",
43+ Long: `Display the config file path and check if any new settings are available.`,
44+ RunE: runConfigStatus,
45+}
46+
47+var configMigrateCmd = &cobra.Command{
48+ Use: "migrate",
49+ Short: "Add missing default settings to config file",
50+ Long: `Compares your config file with available defaults and adds any missing settings.
51+
52+Existing settings are never modified - only missing keys are added with default values.`,
53+ RunE: runConfigMigrate,
54+}
55+
56+var configSchemaJSON bool
57+
58+var configSchemaCmd = &cobra.Command{
59+ Use: "schema",
60+ Short: "Show all config keys with types and valid values",
61+ Long: `Display a reference of all configuration keys with their types, defaults,
62+descriptions, and valid values.
63+
64+Use --json for machine-readable output.`,
65+ RunE: runConfigSchema,
66+}
67+
68+func init() {
69+ rootCmd.AddCommand(configCmd)
70+ configCmd.AddCommand(configOpenCmd)
71+ configCmd.AddCommand(configStatusCmd)
72+ configCmd.AddCommand(configMigrateCmd)
73+ configCmd.AddCommand(configSchemaCmd)
74+ configMigrateCmd.Flags().BoolVarP(&configYes, "yes", "y", false, "skip confirmation prompt")
75+ configSchemaCmd.Flags().BoolVar(&configSchemaJSON, "json", false, "output as JSON")
76+}
77+
78+// runConfigSchema displays all configuration keys with their metadata.
79+func runConfigSchema(_ *cobra.Command, _ []string) error {
80+ app := GetApp()
81+ if app == nil {
82+ return fmt.Errorf("app not initialized")
83+ }
84+
85+ provider := config.NewSchemaProvider()
86+ uc := usecase.NewGetConfigSchemaUseCase(provider)
87+
88+ ctx := context.Background()
89+ result, err := uc.Execute(ctx, usecase.GetConfigSchemaInput{})
90+ if err != nil {
91+ return fmt.Errorf("get config schema: %w", err)
92+ }
93+
94+ renderer := styles.NewConfigSchemaRenderer(app.Theme)
95+
96+ if configSchemaJSON {
97+ output, err := renderer.RenderJSON(result.Keys)
98+ if err != nil {
99+ return err
100+ }
101+ fmt.Println(output)
102+ return nil
103+ }
104+
105+ fmt.Println(renderer.Render(result.Keys))
106+ return nil
107+}
108+
109+// runConfigOpen opens the config file in the user's preferred editor.
110+func runConfigOpen(_ *cobra.Command, _ []string) error {
111+ configFile, err := config.GetConfigFile()
112+ if err != nil {
113+ return fmt.Errorf("get config file: %w", err)
114+ }
115+
116+ // Check if config file exists
117+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
118+ return fmt.Errorf("config file does not exist: %s", configFile)
119+ }
120+
121+ editor := getEditor()
122+ if editor == "" {
123+ return fmt.Errorf("no editor found: set $EDITOR or $VISUAL environment variable")
124+ }
125+
126+ cmd := exec.Command(editor, configFile)
127+ cmd.Stdin = os.Stdin
128+ cmd.Stdout = os.Stdout
129+ cmd.Stderr = os.Stderr
130+
131+ return cmd.Run()
132+}
133+
134+// getEditor returns the user's preferred editor.
135+func getEditor() string {
136+ // Check $EDITOR first
137+ if editor := os.Getenv("EDITOR"); editor != "" {
138+ return editor
139+ }
140+
141+ // Then $VISUAL
142+ if editor := os.Getenv("VISUAL"); editor != "" {
143+ return editor
144+ }
145+
146+ // Fallback to common editors
147+ fallbacks := []string{"nano", "vim", "vi"}
148+ for _, editor := range fallbacks {
149+ if path, err := exec.LookPath(editor); err == nil {
150+ return path
151+ }
152+ }
153+
154+ return ""
155+}
156+
157+// runConfigStatus shows config file path and migration status.
158+func runConfigStatus(_ *cobra.Command, _ []string) error {
159+ app := GetApp()
160+ if app == nil {
161+ return fmt.Errorf("app not initialized")
162+ }
163+
164+ renderer := styles.NewConfigRenderer(app.Theme)
165+ migrator := config.NewMigrator()
166+ diffFormatter := config.NewDiffFormatter()
167+ uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter)
168+
169+ // Get config file path
170+ configFile, err := config.GetConfigFile()
171+ if err != nil {
172+ fmt.Println(renderer.RenderError(err))
173+ return nil
174+ }
175+
176+ // Check if config file exists
177+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
178+ fmt.Println(renderer.RenderNoConfigFile(configFile))
179+ return nil
180+ }
181+
182+ // Check for migration
183+ ctx := context.Background()
184+ result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{})
185+ if err != nil {
186+ fmt.Println(renderer.RenderError(err))
187+ return nil
188+ }
189+
190+ if !result.NeedsMigration {
191+ fmt.Println(renderer.RenderUpToDate(configFile))
192+ return nil
193+ }
194+
195+ // Show status with missing count
196+ fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys)))
197+ fmt.Println(renderer.RenderMigrateHint())
198+
199+ return nil
200+}
201+
202+// runConfigMigrate runs the migration with optional confirmation.
203+func runConfigMigrate(_ *cobra.Command, _ []string) error {
204+ app := GetApp()
205+ if app == nil {
206+ return fmt.Errorf("app not initialized")
207+ }
208+
209+ renderer := styles.NewConfigRenderer(app.Theme)
210+ migrator := config.NewMigrator()
211+ diffFormatter := config.NewDiffFormatter()
212+ uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter)
213+
214+ // Get config file path
215+ configFile, err := config.GetConfigFile()
216+ if err != nil {
217+ fmt.Println(renderer.RenderError(err))
218+ return nil
219+ }
220+
221+ // Check if config file exists
222+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
223+ fmt.Println(renderer.RenderNoConfigFile(configFile))
224+ return nil
225+ }
226+
227+ // Detect changes (includes renames, additions, and removals)
228+ ctx := context.Background()
229+ detectResult, err := uc.DetectChanges(ctx, usecase.DetectChangesInput{})
230+ if err != nil {
231+ fmt.Println(renderer.RenderError(err))
232+ return nil
233+ }
234+
235+ if !detectResult.HasChanges {
236+ fmt.Println(renderer.RenderUpToDate(configFile))
237+ return nil
238+ }
239+
240+ // Show config info with change summary
241+ fmt.Println(renderer.RenderConfigInfo(configFile, len(detectResult.Changes)))
242+ fmt.Println(renderer.RenderChangesSummary(detectResult.Changes))
243+ fmt.Println(renderer.RenderChanges(detectResult.Changes))
244+
245+ // If --yes flag, proceed without confirmation
246+ if configYes {
247+ return executeMigration(ctx, uc, renderer)
248+ }
249+
250+ // Show confirmation dialog
251+ return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, detectResult.Changes)
252+}
253+
254+// executeMigration performs the actual migration.
255+func executeMigration(ctx context.Context, uc *usecase.MigrateConfigUseCase, renderer *styles.ConfigRenderer) error {
256+ result, err := uc.Execute(ctx, usecase.MigrateConfigInput{})
257+ if err != nil {
258+ fmt.Println(renderer.RenderError(err))
259+ return nil
260+ }
261+
262+ if len(result.AddedKeys) > 0 {
263+ fmt.Println(renderer.RenderMigrationSuccess(len(result.AddedKeys), result.ConfigFile))
264+ }
265+
266+ return nil
267+}
268+
269+// migrateState represents the current state of the migrate confirmation.
270+type migrateState int
271+
272+const (
273+ migrateStateConfirm migrateState = iota
274+ migrateStateDone
275+)
276+
277+// migrateModel is the bubbletea model for the migrate confirmation.
278+type migrateModel struct {
279+ spinner spinner.Model
280+ renderer *styles.ConfigRenderer
281+ confirm styles.ConfirmModel
282+ state migrateState
283+ uc *usecase.MigrateConfigUseCase
284+ changes []port.KeyChange
285+
286+ result string
287+ err error
288+ quitting bool
289+}
290+
291+// migrateResultMsg is sent when the migration completes.
292+type migrateResultMsg struct {
293+ output *usecase.MigrateConfigOutput
294+ err error
295+}
296+
297+func newMigrateModel(
298+ renderer *styles.ConfigRenderer,
299+ theme *styles.Theme,
300+ uc *usecase.MigrateConfigUseCase,
301+ changes []port.KeyChange,
302+) migrateModel {
303+ s := spinner.New()
304+ s.Spinner = spinner.Dot
305+ s.Style = lipgloss.NewStyle().Foreground(theme.Accent)
306+
307+ confirm := styles.NewConfirm(theme, "Apply these changes?")
308+
309+ return migrateModel{
310+ spinner: s,
311+ renderer: renderer,
312+ confirm: confirm,
313+ state: migrateStateConfirm,
314+ uc: uc,
315+ changes: changes,
316+ }
317+}
318+
319+func (m migrateModel) Init() tea.Cmd {
320+ return m.spinner.Tick
321+}
322+
323+func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
324+ switch msg := msg.(type) {
325+ case tea.KeyMsg:
326+ switch msg.String() {
327+ case "ctrl+c":
328+ m.quitting = true
329+ return m, tea.Quit
330+ }
331+
332+ case spinner.TickMsg:
333+ var cmd tea.Cmd
334+ m.spinner, cmd = m.spinner.Update(msg)
335+ return m, cmd
336+
337+ case migrateResultMsg:
338+ m.state = migrateStateDone
339+ if msg.err != nil {
340+ m.err = msg.err
341+ return m, tea.Quit
342+ }
343+
344+ if len(msg.output.AddedKeys) > 0 {
345+ m.result = m.renderer.RenderMigrationSuccess(len(msg.output.AddedKeys), msg.output.ConfigFile)
346+ }
347+ return m, tea.Quit
348+ }
349+
350+ // Handle confirm dialog
351+ if m.state == migrateStateConfirm {
352+ var cmd tea.Cmd
353+ m.confirm, cmd = m.confirm.Update(msg)
354+
355+ if m.confirm.Done() {
356+ if m.confirm.Result() {
357+ // User confirmed, run migration
358+ return m, m.runMigration()
359+ }
360+ // User canceled
361+ m.quitting = true
362+ return m, tea.Quit
363+ }
364+
365+ return m, cmd
366+ }
367+
368+ return m, nil
369+}
370+
371+func (m migrateModel) View() string {
372+ if m.quitting {
373+ return ""
374+ }
375+
376+ if m.err != nil {
377+ return m.renderer.RenderError(m.err)
378+ }
379+
380+ if m.state == migrateStateDone {
381+ return m.result
382+ }
383+
384+ // Show confirmation dialog
385+ return m.confirm.View()
386+}
387+
388+func (m migrateModel) runMigration() tea.Cmd {
389+ return func() tea.Msg {
390+ ctx := context.Background()
391+ result, err := m.uc.Execute(ctx, usecase.MigrateConfigInput{})
392+ return migrateResultMsg{output: result, err: err}
393+ }
394+}
395+
396+// runMigrateWithConfirmation runs the migrate with an interactive confirmation dialog.
397+func runMigrateWithConfirmation(
398+ ctx context.Context,
399+ uc *usecase.MigrateConfigUseCase,
400+ renderer *styles.ConfigRenderer,
401+ theme *styles.Theme,
402+ changes []port.KeyChange,
403+) error {
404+ // Suppress unused ctx warning - kept for consistency with other run functions
405+ _ = ctx
406+
407+ m := newMigrateModel(renderer, theme, uc, changes)
408+ p := tea.NewProgram(m)
409+
410+ _, err := p.Run()
411+ if err != nil {
412+ return fmt.Errorf("migration failed: %w", err)
413+ }
414+
415+ return nil
416+}
M internal/cli/cmd/dmenu.go +9 -1
@@ -11,9 +11,11 @@ import (
1111 tea "github.com/charmbracelet/bubbletea"
1212 "github.com/spf13/cobra"
1313
14+ "github.com/bnema/dumber/assets"
1415 "github.com/bnema/dumber/internal/cli/model"
1516 domainurl "github.com/bnema/dumber/internal/domain/url"
1617 "github.com/bnema/dumber/internal/infrastructure/config"
18+ "github.com/bnema/dumber/internal/infrastructure/favicon"
1719 "github.com/bnema/dumber/internal/logging"
1820 )
1921
@@ -87,6 +89,13 @@ func getFaviconPath(rawURL string) string {
8789 return ""
8890 }
8991
92+ const normalizedSize = 32
93+
94+ // Handle internal dumb:// URLs - use embedded app logo
95+ if favicon.IsInternalURL(rawURL) {
96+ return app.FaviconService.EnsureInternalFaviconPNG(assets.LogoPNG32, normalizedSize)
97+ }
98+
9099 domain := domainurl.ExtractDomain(rawURL)
91100 if domain == "" {
92101 return ""
@@ -94,7 +103,6 @@ func getFaviconPath(rawURL string) string {
94103
95104 ctx := app.Ctx()
96105 log := logging.FromContext(ctx)
97- const normalizedSize = 32
98106
99107 // Check if sized PNG already exists
100108 if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) {
A internal/cli/cmd/gen_docs.go +141 -0
@@ -0,0 +1,141 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "time"
8+
9+ "github.com/spf13/cobra"
10+ "github.com/spf13/cobra/doc"
11+
12+ xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg"
13+)
14+
15+const dirPerm = 0o755
16+
17+var (
18+ genDocsOutputDir string
19+ genDocsFormat string
20+)
21+
22+var genDocsCmd = &cobra.Command{
23+ Use: "gen-docs",
24+ Short: "Generate documentation from CLI commands",
25+ Long: `Generate documentation (man pages or markdown) from CLI command definitions.
26+
27+The documentation is auto-generated from the command structure, including:
28+- Command names and aliases
29+- Short and long descriptions
30+- Flags and their descriptions
31+- Usage examples
32+
33+Supported formats:
34+ man Unix manual pages (groff format)
35+ markdown Markdown files (for websites/wikis)
36+
37+By default, man pages are installed to ~/.local/share/man/man1/ so they
38+are immediately available via 'man dumber'. You may need to run 'mandb'
39+to update the man page index.
40+
41+Examples:
42+ dumber gen-docs # Install man pages to ~/.local/share/man/man1/
43+ dumber gen-docs --format markdown # Generate markdown docs
44+ dumber gen-docs --output ./man # Generate to local directory`,
45+ RunE: runGenDocs,
46+}
47+
48+func init() {
49+ rootCmd.AddCommand(genDocsCmd)
50+ genDocsCmd.Flags().StringVarP(&genDocsOutputDir, "output", "o", "", "Output directory for generated docs")
51+ genDocsCmd.Flags().StringVarP(&genDocsFormat, "format", "f", "man", "Output format: man, markdown")
52+}
53+
54+func runGenDocs(_ *cobra.Command, _ []string) error {
55+ // Resolve output directory
56+ outputDir := genDocsOutputDir
57+ if outputDir == "" {
58+ switch genDocsFormat {
59+ case "man":
60+ xdg := xdgadapter.New()
61+ manDir, err := xdg.ManDir()
62+ if err != nil {
63+ return fmt.Errorf("resolve man directory: %w", err)
64+ }
65+ outputDir = manDir
66+ case "markdown":
67+ outputDir = "./docs"
68+ }
69+ }
70+
71+ // Create output directory if it doesn't exist
72+ if err := os.MkdirAll(outputDir, dirPerm); err != nil {
73+ return fmt.Errorf("create output directory: %w", err)
74+ }
75+
76+ switch genDocsFormat {
77+ case "man":
78+ return generateManPages(outputDir)
79+ case "markdown":
80+ return generateMarkdown(outputDir)
81+ default:
82+ return fmt.Errorf("unsupported format %q (use: man, markdown)", genDocsFormat)
83+ }
84+}
85+
86+func generateManPages(outputDir string) error {
87+ header := &doc.GenManHeader{
88+ Title: "DUMBER",
89+ Section: "1",
90+ Source: "dumber " + buildInfo.Version,
91+ Manual: "Dumber Manual",
92+ Date: func() *time.Time { t := time.Now(); return &t }(),
93+ }
94+
95+ // Disable auto-generation timestamp in the footer for reproducible builds
96+ rootCmd.DisableAutoGenTag = true
97+
98+ if err := doc.GenManTree(rootCmd, header, outputDir); err != nil {
99+ return fmt.Errorf("generate man pages: %w", err)
100+ }
101+
102+ fmt.Printf("Installed man pages to %s\n", outputDir)
103+ fmt.Println("Run 'mandb' if 'man dumber' doesn't work immediately.")
104+
105+ // List generated files
106+ entries, err := os.ReadDir(outputDir)
107+ if err != nil {
108+ return nil // Non-fatal
109+ }
110+ for _, e := range entries {
111+ if filepath.Ext(e.Name()) == ".1" {
112+ fmt.Printf(" - %s\n", e.Name())
113+ }
114+ }
115+
116+ return nil
117+}
118+
119+func generateMarkdown(outputDir string) error {
120+ // Disable auto-generation timestamp for reproducible builds
121+ rootCmd.DisableAutoGenTag = true
122+
123+ if err := doc.GenMarkdownTree(rootCmd, outputDir); err != nil {
124+ return fmt.Errorf("generate markdown docs: %w", err)
125+ }
126+
127+ fmt.Printf("Generated markdown docs in %s\n", outputDir)
128+
129+ // List generated files
130+ entries, err := os.ReadDir(outputDir)
131+ if err != nil {
132+ return nil // Non-fatal
133+ }
134+ for _, e := range entries {
135+ if filepath.Ext(e.Name()) == ".md" {
136+ fmt.Printf(" - %s\n", e.Name())
137+ }
138+ }
139+
140+ return nil
141+}
M internal/cli/cmd/root.go +21 -4
@@ -16,11 +16,28 @@ var (
1616 buildInfo build.Info
1717 rootCmd = &cobra.Command{
1818 Use: "dumber",
19- Short: "A minimal web browser",
20- Long: `Dumber is a minimal, keyboard-driven web browser built with GTK4 and WebKitGTK.`,
19+ Short: "A fully unfeatured unbloated browser for tiling WMs",
20+ Long: `Dumber - a dumb browser that works like your favorite terminal multiplexer.
21+
22+A fully unfeatured unbloated browser for tiling WMs, built with GTK4 and WebKitGTK.
23+
24+Features:
25+ - Wayland native (Sway, Hyprland, River, Niri, etc.)
26+ - Tabs and workspaces with split or stacked panes
27+ - Keyboard-driven workflow inspired by Zellij
28+ - GPU rendering with automatic VA-API/VDPAU detection
29+ - Built-in ad blocking (UBlock-based network + cosmetic filtering)
30+ - Launcher integration (rofi/fuzzel) with favicons
31+ - Search shortcuts via bangs (!g, !gi, etc.)
32+ - Session management with auto-save and restore
33+
34+Use 'dumber browse' to launch the graphical browser, or explore the
35+subcommands for CLI-based operations like history search and session
36+management.`,
2137 PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
22- // Skip initialization for help commands
23- if cmd.Name() == "help" || cmd.Name() == "completion" {
38+ // Skip initialization for commands that don't need app context
39+ switch cmd.Name() {
40+ case "help", "completion", "gen-docs":
2441 return nil
2542 }
2643
A internal/cli/cmd/update.go +289 -0
@@ -0,0 +1,289 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/charmbracelet/bubbles/spinner"
8+ tea "github.com/charmbracelet/bubbletea"
9+ "github.com/charmbracelet/lipgloss"
10+ "github.com/spf13/cobra"
11+
12+ "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/cli/styles"
14+ "github.com/bnema/dumber/internal/domain/entity"
15+ "github.com/bnema/dumber/internal/infrastructure/config"
16+ "github.com/bnema/dumber/internal/infrastructure/updater"
17+)
18+
19+var updateForce bool
20+
21+var updateCmd = &cobra.Command{
22+ Use: "update",
23+ Short: "Check for and install updates",
24+ Long: `Check for available updates and download/install them.
25+
26+Use --force to reinstall even if already up-to-date (skips version check).`,
27+ RunE: runUpdate,
28+}
29+
30+func init() {
31+ rootCmd.AddCommand(updateCmd)
32+ updateCmd.Flags().BoolVarP(&updateForce, "force", "f", false, "force reinstall (skips version check)")
33+}
34+
35+// updateState represents the current state of the update process.
36+type updateState int
37+
38+const (
39+ stateChecking updateState = iota
40+ stateDownloading
41+ stateDone
42+)
43+
44+// updateModel is the bubbletea model for the update command.
45+type updateModel struct {
46+ spinner spinner.Model
47+ renderer *styles.UpdateRenderer
48+ state updateState
49+ force bool
50+
51+ // Results from check.
52+ updateAvailable bool
53+ canAutoUpdate bool
54+ currentVersion string
55+ latestVersion string
56+ releaseURL string
57+ downloadURL string
58+
59+ // Final result.
60+ result string
61+ err error
62+ quitting bool
63+ updateStaged bool // tracks if an update was actually downloaded and staged
64+}
65+
66+// checkResultMsg is sent when the update check completes.
67+type checkResultMsg struct {
68+ output *usecase.CheckUpdateOutput
69+ err error
70+}
71+
72+// downloadResultMsg is sent when the download completes.
73+type downloadResultMsg struct {
74+ output *usecase.ApplyUpdateOutput
75+ err error
76+}
77+
78+func newUpdateModel(renderer *styles.UpdateRenderer, accentColor lipgloss.Color, force bool) updateModel {
79+ s := spinner.New()
80+ s.Spinner = spinner.Dot
81+ s.Style = lipgloss.NewStyle().Foreground(accentColor)
82+
83+ return updateModel{
84+ spinner: s,
85+ renderer: renderer,
86+ state: stateChecking,
87+ force: force,
88+ }
89+}
90+
91+func (m updateModel) Init() tea.Cmd {
92+ return tea.Batch(m.spinner.Tick, m.checkForUpdates())
93+}
94+
95+func (m updateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
96+ switch msg := msg.(type) {
97+ case tea.KeyMsg:
98+ switch msg.String() {
99+ case "q", "ctrl+c":
100+ m.quitting = true
101+ return m, tea.Quit
102+ }
103+
104+ case spinner.TickMsg:
105+ var cmd tea.Cmd
106+ m.spinner, cmd = m.spinner.Update(msg)
107+ return m, cmd
108+
109+ case checkResultMsg:
110+ if msg.err != nil {
111+ m.err = msg.err
112+ m.state = stateDone
113+ return m, tea.Quit
114+ }
115+
116+ m.updateAvailable = msg.output.UpdateAvailable
117+ m.canAutoUpdate = msg.output.CanAutoUpdate
118+ m.currentVersion = msg.output.CurrentVersion
119+ m.latestVersion = msg.output.LatestVersion
120+ m.releaseURL = msg.output.ReleaseURL
121+ m.downloadURL = msg.output.DownloadURL
122+
123+ // Handle dev builds when force is used.
124+ if m.force && (m.currentVersion == "" || m.currentVersion == "dev") {
125+ m.result = m.renderer.RenderDevBuild()
126+ m.state = stateDone
127+ return m, tea.Quit
128+ }
129+
130+ // Decide next action.
131+ switch {
132+ case !m.updateAvailable && !m.force:
133+ // Already up to date.
134+ m.result = m.renderer.RenderUpToDate(m.currentVersion)
135+ m.state = stateDone
136+ return m, tea.Quit
137+
138+ case m.updateAvailable && !m.canAutoUpdate:
139+ // Update available but can't auto-update.
140+ m.result = m.renderer.RenderCannotAutoUpdate(m.currentVersion, m.latestVersion, m.releaseURL)
141+ m.state = stateDone
142+ return m, tea.Quit
143+
144+ default:
145+ // Proceed with download (either update available or force mode).
146+ m.state = stateDownloading
147+ return m, m.downloadUpdate()
148+ }
149+
150+ case downloadResultMsg:
151+ m.state = stateDone
152+ if msg.err != nil {
153+ m.err = msg.err
154+ return m, tea.Quit
155+ }
156+
157+ if msg.output.Status == entity.UpdateStatusFailed {
158+ m.result = m.renderer.RenderError(fmt.Errorf("%s", msg.output.Message))
159+ } else {
160+ m.result = m.renderer.RenderStaged(m.latestVersion)
161+ m.updateStaged = true
162+ }
163+ return m, tea.Quit
164+ }
165+
166+ return m, nil
167+}
168+
169+func (m updateModel) View() string {
170+ if m.quitting {
171+ return ""
172+ }
173+
174+ if m.err != nil {
175+ return m.renderer.RenderError(m.err)
176+ }
177+
178+ if m.state == stateDone {
179+ return m.result
180+ }
181+
182+ switch m.state {
183+ case stateChecking:
184+ return m.renderer.RenderChecking(m.spinner.View())
185+ case stateDownloading:
186+ version := m.latestVersion
187+ if m.force && !m.updateAvailable {
188+ version = m.currentVersion
189+ }
190+ return m.renderer.RenderDownloading(m.spinner.View(), version)
191+ default:
192+ return ""
193+ }
194+}
195+
196+func (updateModel) checkForUpdates() tea.Cmd {
197+ return func() tea.Msg {
198+ ctx := context.Background()
199+
200+ checker := updater.NewGitHubChecker()
201+ applier, err := updater.NewApplierFromXDG()
202+ if err != nil {
203+ return checkResultMsg{err: fmt.Errorf("failed to create applier: %w", err)}
204+ }
205+
206+ app := GetApp()
207+ if app == nil {
208+ return checkResultMsg{err: fmt.Errorf("app not initialized")}
209+ }
210+
211+ checkUC := usecase.NewCheckUpdateUseCase(checker, applier, app.BuildInfo)
212+ result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{})
213+
214+ return checkResultMsg{output: result, err: err}
215+ }
216+}
217+
218+func (m updateModel) downloadUpdate() tea.Cmd {
219+ return func() tea.Msg {
220+ ctx := context.Background()
221+
222+ downloader := updater.NewGitHubDownloader()
223+ applier, err := updater.NewApplierFromXDG()
224+ if err != nil {
225+ return downloadResultMsg{err: fmt.Errorf("failed to create applier: %w", err)}
226+ }
227+
228+ dirs, err := config.GetXDGDirs()
229+ if err != nil {
230+ return downloadResultMsg{err: fmt.Errorf("failed to get cache dir: %w", err)}
231+ }
232+
233+ applyUC := usecase.NewApplyUpdateUseCase(downloader, applier, dirs.CacheHome)
234+ result, err := applyUC.Execute(ctx, usecase.ApplyUpdateInput{
235+ DownloadURL: m.downloadURL,
236+ })
237+
238+ return downloadResultMsg{output: result, err: err}
239+ }
240+}
241+
242+func runUpdate(_ *cobra.Command, _ []string) error {
243+ app := GetApp()
244+ if app == nil {
245+ return fmt.Errorf("app not initialized")
246+ }
247+
248+ renderer := styles.NewUpdateRenderer(app.Theme)
249+
250+ // Create and run the bubbletea program.
251+ m := newUpdateModel(renderer, app.Theme.Accent, updateForce)
252+ p := tea.NewProgram(m)
253+
254+ finalModel, err := p.Run()
255+ if err != nil {
256+ return fmt.Errorf("update failed: %w", err)
257+ }
258+
259+ // Check if the update was staged and we need to finalize on exit.
260+ if model, ok := finalModel.(updateModel); ok {
261+ if model.updateStaged {
262+ // If update was successfully staged, finalize it.
263+ if err := finalizeUpdate(); err != nil {
264+ fmt.Println(renderer.RenderError(err))
265+ }
266+ }
267+ }
268+
269+ return nil
270+}
271+
272+// finalizeUpdate applies any staged update.
273+func finalizeUpdate() error {
274+ ctx := context.Background()
275+
276+ downloader := updater.NewGitHubDownloader()
277+ applier, err := updater.NewApplierFromXDG()
278+ if err != nil {
279+ return fmt.Errorf("failed to create applier: %w", err)
280+ }
281+
282+ dirs, err := config.GetXDGDirs()
283+ if err != nil {
284+ return fmt.Errorf("failed to get cache dir: %w", err)
285+ }
286+
287+ applyUC := usecase.NewApplyUpdateUseCase(downloader, applier, dirs.CacheHome)
288+ return applyUC.FinalizeOnExit(ctx)
289+}
A internal/cli/styles/config.go +248 -0
@@ -0,0 +1,248 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+
8+ "github.com/charmbracelet/lipgloss"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+)
12+
13+// ConfigRenderer renders config status messages with styled output.
14+type ConfigRenderer struct {
15+ theme *Theme
16+}
17+
18+// NewConfigRenderer creates a new config renderer with the given theme.
19+func NewConfigRenderer(theme *Theme) *ConfigRenderer {
20+ return &ConfigRenderer{theme: theme}
21+}
22+
23+// RenderConfigInfo renders the config file info with status.
24+func (r *ConfigRenderer) RenderConfigInfo(path string, changeCount int) string {
25+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
26+ pathStyle := r.theme.Subtle
27+
28+ var status string
29+ if changeCount > 0 {
30+ countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
31+ status = fmt.Sprintf("\n %s %s changes detected",
32+ iconStyle.Render(IconInfo),
33+ countStyle.Render(fmt.Sprintf("%d", changeCount)),
34+ )
35+ }
36+
37+ return fmt.Sprintf(
38+ "\n %s Config %s%s\n",
39+ iconStyle.Render(IconConfig),
40+ pathStyle.Render(path),
41+ status,
42+ )
43+}
44+
45+// RenderMissingKeys renders the list of missing keys with their types and default values.
46+func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string {
47+ if len(keys) == 0 {
48+ return ""
49+ }
50+
51+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
52+ keyStyle := r.theme.Highlight
53+ typeStyle := r.theme.Subtle
54+ valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text)
55+
56+ var sb strings.Builder
57+ sb.WriteString(fmt.Sprintf("\n Missing settings (%d):\n", len(keys)))
58+
59+ for _, key := range keys {
60+ sb.WriteString(fmt.Sprintf(
61+ " %s %s\n Type: %s | Default: %s\n",
62+ iconStyle.Render(IconCursor),
63+ keyStyle.Render(key.Key),
64+ typeStyle.Render(key.Type),
65+ valueStyle.Render(key.DefaultValue),
66+ ))
67+ }
68+
69+ return sb.String()
70+}
71+
72+// RenderChanges renders the list of detected changes (added, renamed, removed).
73+func (r *ConfigRenderer) RenderChanges(changes []port.KeyChange) string {
74+ if len(changes) == 0 {
75+ return ""
76+ }
77+
78+ addStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
79+ renameStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
80+ removeStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
81+ keyStyle := r.theme.Highlight
82+ valueStyle := r.theme.Subtle
83+
84+ // Group changes by type
85+ var added, renamed, removed []port.KeyChange
86+ for _, c := range changes {
87+ switch c.Type {
88+ case port.KeyChangeAdded:
89+ added = append(added, c)
90+ case port.KeyChangeRenamed:
91+ renamed = append(renamed, c)
92+ case port.KeyChangeRemoved:
93+ removed = append(removed, c)
94+ }
95+ }
96+
97+ var sb strings.Builder
98+
99+ // Show renamed keys first (most important for user to understand)
100+ if len(renamed) > 0 {
101+ sb.WriteString(fmt.Sprintf("\n Renamed settings (%d):\n", len(renamed)))
102+ for _, c := range renamed {
103+ sb.WriteString(fmt.Sprintf(
104+ " %s %s %s %s\n Value: %s\n",
105+ renameStyle.Render("~"),
106+ valueStyle.Render(c.OldKey),
107+ renameStyle.Render("→"),
108+ keyStyle.Render(c.NewKey),
109+ valueStyle.Render(c.OldValue),
110+ ))
111+ }
112+ }
113+
114+ // Show new settings
115+ if len(added) > 0 {
116+ sb.WriteString(fmt.Sprintf("\n New settings (%d):\n", len(added)))
117+ for _, c := range added {
118+ sb.WriteString(fmt.Sprintf(
119+ " %s %s\n Default: %s\n",
120+ addStyle.Render("+"),
121+ keyStyle.Render(c.NewKey),
122+ valueStyle.Render(c.NewValue),
123+ ))
124+ }
125+ }
126+
127+ // Show deprecated settings (will be removed)
128+ if len(removed) > 0 {
129+ sb.WriteString(fmt.Sprintf("\n Deprecated settings (%d):\n", len(removed)))
130+ for _, c := range removed {
131+ sb.WriteString(fmt.Sprintf(
132+ " %s %s %s\n",
133+ removeStyle.Render("-"),
134+ valueStyle.Render(c.OldKey),
135+ removeStyle.Render("(will be removed)"),
136+ ))
137+ }
138+ }
139+
140+ return sb.String()
141+}
142+
143+// RenderChangesSummary renders a summary of changes for the config info header.
144+func (r *ConfigRenderer) RenderChangesSummary(changes []port.KeyChange) string {
145+ var added, renamed, removed int
146+ for _, c := range changes {
147+ switch c.Type {
148+ case port.KeyChangeAdded:
149+ added++
150+ case port.KeyChangeRenamed:
151+ renamed++
152+ case port.KeyChangeRemoved:
153+ removed++
154+ }
155+ }
156+
157+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
158+ addStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
159+ renameStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
160+ removeStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
161+
162+ var parts []string
163+ if renamed > 0 {
164+ parts = append(parts, renameStyle.Render(fmt.Sprintf("%d renamed", renamed)))
165+ }
166+ if added > 0 {
167+ parts = append(parts, addStyle.Render(fmt.Sprintf("%d new", added)))
168+ }
169+ if removed > 0 {
170+ parts = append(parts, removeStyle.Render(fmt.Sprintf("%d deprecated", removed)))
171+ }
172+
173+ if len(parts) == 0 {
174+ return ""
175+ }
176+
177+ return fmt.Sprintf("\n %s %s", iconStyle.Render(IconInfo), strings.Join(parts, ", "))
178+}
179+
180+// RenderMigrationSuccess renders the success message after migration.
181+func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string {
182+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
183+ countStyle := r.theme.Highlight
184+ pathStyle := r.theme.Subtle
185+
186+ // Get just the filename from the path
187+ filename := filepath.Base(path)
188+
189+ return fmt.Sprintf(
190+ "\n %s Applied %s changes to %s\n",
191+ iconStyle.Render(IconCheck),
192+ countStyle.Render(fmt.Sprintf("%d", count)),
193+ pathStyle.Render(filename),
194+ )
195+}
196+
197+// RenderUpToDate renders the "config is up to date" message.
198+func (r *ConfigRenderer) RenderUpToDate(path string) string {
199+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
200+ pathStyle := r.theme.Subtle
201+
202+ return fmt.Sprintf(
203+ "\n %s Config %s\n %s Config is up to date\n",
204+ iconStyle.Render(IconConfig),
205+ pathStyle.Render(path),
206+ iconStyle.Render(IconCheck),
207+ )
208+}
209+
210+// RenderError renders an error message.
211+func (r *ConfigRenderer) RenderError(err error) string {
212+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
213+
214+ return fmt.Sprintf(
215+ "\n %s Config error: %v\n",
216+ iconStyle.Render(IconX),
217+ err,
218+ )
219+}
220+
221+// RenderChecking renders the "checking config..." message with spinner.
222+func (*ConfigRenderer) RenderChecking(spinner string) string {
223+ return fmt.Sprintf("\n %s Checking config...\n", spinner)
224+}
225+
226+// RenderMigrateHint renders a hint to run the migrate command.
227+func (r *ConfigRenderer) RenderMigrateHint() string {
228+ hintStyle := r.theme.Subtle
229+
230+ return fmt.Sprintf(
231+ "\n %s\n",
232+ hintStyle.Render("Run 'dumber config migrate' to add missing defaults."),
233+ )
234+}
235+
236+// RenderNoConfigFile renders message when config file doesn't exist yet.
237+func (r *ConfigRenderer) RenderNoConfigFile(path string) string {
238+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
239+ pathStyle := r.theme.Subtle
240+ hintStyle := r.theme.Subtle
241+
242+ return fmt.Sprintf(
243+ "\n %s Config %s\n %s\n",
244+ iconStyle.Render(IconConfig),
245+ pathStyle.Render(path),
246+ hintStyle.Render("Config file will be created on first run with all defaults."),
247+ )
248+}
A internal/cli/styles/config_schema.go +144 -0
@@ -0,0 +1,144 @@
1+package styles
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/charmbracelet/lipgloss"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+// ConfigSchemaRenderer renders configuration schema information.
14+type ConfigSchemaRenderer struct {
15+ theme *Theme
16+}
17+
18+// NewConfigSchemaRenderer creates a new ConfigSchemaRenderer.
19+func NewConfigSchemaRenderer(theme *Theme) *ConfigSchemaRenderer {
20+ return &ConfigSchemaRenderer{theme: theme}
21+}
22+
23+// Render renders the configuration schema in styled format.
24+func (r *ConfigSchemaRenderer) Render(keys []entity.ConfigKeyInfo) string {
25+ if len(keys) == 0 {
26+ return r.theme.Subtle.Render("No configuration keys found")
27+ }
28+
29+ // Group keys by section
30+ sections := groupBySection(keys)
31+
32+ // Build output
33+ var parts []string
34+
35+ // Header
36+ header := r.renderHeader()
37+ parts = append(parts, header, "")
38+
39+ // Render each section
40+ sectionOrder := []string{
41+ "Appearance",
42+ "Logging",
43+ "History",
44+ "Search",
45+ "Dmenu",
46+ "Workspace",
47+ "Session",
48+ "Omnibox",
49+ "Content Filtering",
50+ "Rendering",
51+ "Media",
52+ "Update",
53+ "Debug",
54+ "Performance",
55+ "Runtime",
56+ "Database",
57+ }
58+
59+ for _, section := range sectionOrder {
60+ if sectionKeys, ok := sections[section]; ok {
61+ parts = append(parts, r.renderSection(section, sectionKeys), "")
62+ }
63+ }
64+
65+ return strings.Join(parts, "\n")
66+}
67+
68+// RenderJSON renders the configuration schema as JSON.
69+func (*ConfigSchemaRenderer) RenderJSON(keys []entity.ConfigKeyInfo) (string, error) {
70+ data, err := json.MarshalIndent(keys, "", " ")
71+ if err != nil {
72+ return "", fmt.Errorf("marshal schema: %w", err)
73+ }
74+ return string(data), nil
75+}
76+
77+func (r *ConfigSchemaRenderer) renderHeader() string {
78+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
79+ title := fmt.Sprintf("%s %s", iconStyle.Render(IconConfig), r.theme.Title.Render("Config Schema Reference"))
80+ return title
81+}
82+
83+func groupBySection(keys []entity.ConfigKeyInfo) map[string][]entity.ConfigKeyInfo {
84+ sections := make(map[string][]entity.ConfigKeyInfo)
85+ for _, key := range keys {
86+ sections[key.Section] = append(sections[key.Section], key)
87+ }
88+ return sections
89+}
90+
91+func (r *ConfigSchemaRenderer) renderSection(name string, keys []entity.ConfigKeyInfo) string {
92+ var lines []string
93+
94+ for _, key := range keys {
95+ lines = append(lines, r.renderKey(key))
96+ }
97+
98+ body := strings.Join(lines, "\n")
99+
100+ // Create section header (no extra padding/margin)
101+ sectionHeader := r.theme.Highlight.Render(name)
102+
103+ // Build box content: header directly followed by keys
104+ boxContent := sectionHeader + "\n" + body
105+
106+ // Use box style without top padding
107+ boxStyle := r.theme.Box.PaddingTop(0)
108+
109+ return boxStyle.Render(boxContent)
110+}
111+
112+func (r *ConfigSchemaRenderer) renderKey(key entity.ConfigKeyInfo) string {
113+ keyStyle := r.theme.Normal.Bold(true)
114+ typeStyle := r.theme.Subtle
115+ defaultStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
116+ descStyle := r.theme.Subtle
117+ valuesStyle := r.theme.Normal
118+
119+ // Line 1: key name, type, default
120+ line1 := fmt.Sprintf(
121+ "%s %s %s",
122+ keyStyle.Render(key.Key),
123+ typeStyle.Render(key.Type),
124+ defaultStyle.Render(key.Default),
125+ )
126+
127+ // Line 2: description (indented)
128+ line2 := fmt.Sprintf(" %s", descStyle.Render(key.Description))
129+
130+ result := line1 + "\n" + line2
131+
132+ // Line 3 (optional): values or range
133+ if len(key.Values) > 0 {
134+ valuesText := "Values: " + strings.Join(key.Values, ", ")
135+ line3 := fmt.Sprintf(" %s", valuesStyle.Render(valuesText))
136+ result += "\n" + line3
137+ } else if key.Range != "" {
138+ rangeText := "Range: " + key.Range
139+ line3 := fmt.Sprintf(" %s", valuesStyle.Render(rangeText))
140+ result += "\n" + line3
141+ }
142+
143+ return result
144+}
M internal/cli/styles/icons.go +5 -0
@@ -53,4 +53,9 @@ const (
5353 IconRestore = "\uf0e2" // rotate-left (restore)
5454 IconExpand = "\uf065" // expand
5555 IconCollapse = "\uf066" // compress
56+
57+ // Update / download
58+ IconDownload = "\uf019" // download
59+ IconUpdate = "\uf021" // refresh/sync
60+ IconRocket = "\uf135" // rocket (upgrade)
5661 )
A internal/cli/styles/update.go +113 -0
@@ -0,0 +1,113 @@
1+package styles
2+
3+import (
4+ "fmt"
5+
6+ "github.com/charmbracelet/lipgloss"
7+)
8+
9+// UpdateRenderer renders update status messages with styled output.
10+type UpdateRenderer struct {
11+ theme *Theme
12+}
13+
14+// NewUpdateRenderer creates a new update renderer with the given theme.
15+func NewUpdateRenderer(theme *Theme) *UpdateRenderer {
16+ return &UpdateRenderer{theme: theme}
17+}
18+
19+// RenderChecking renders the "checking for updates" message.
20+func (*UpdateRenderer) RenderChecking(spinner string) string {
21+ return fmt.Sprintf("\n %s Checking for updates...\n", spinner)
22+}
23+
24+// RenderUpToDate renders the "already up to date" message.
25+func (r *UpdateRenderer) RenderUpToDate(version string) string {
26+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
27+ versionStyle := r.theme.Highlight
28+
29+ return fmt.Sprintf(
30+ "\n %s Already up to date (%s)\n",
31+ iconStyle.Render(IconCheck),
32+ versionStyle.Render(version),
33+ )
34+}
35+
36+// RenderAvailable renders the "update available" message.
37+func (r *UpdateRenderer) RenderAvailable(current, latest, releaseURL string) string {
38+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
39+ versionStyle := r.theme.Highlight
40+ urlStyle := r.theme.Subtle
41+
42+ return fmt.Sprintf(
43+ "\n %s Update available: %s %s %s\n %s\n",
44+ iconStyle.Render(IconRocket),
45+ versionStyle.Render(current),
46+ iconStyle.Render(IconArrow),
47+ versionStyle.Render(latest),
48+ urlStyle.Render(releaseURL),
49+ )
50+}
51+
52+// RenderDownloading renders the "downloading" message with spinner.
53+func (r *UpdateRenderer) RenderDownloading(spinner, version string) string {
54+ versionStyle := r.theme.Highlight
55+
56+ return fmt.Sprintf(
57+ "\n %s Downloading %s...\n",
58+ spinner,
59+ versionStyle.Render(version),
60+ )
61+}
62+
63+// RenderStaged renders the "update staged" success message.
64+func (r *UpdateRenderer) RenderStaged(version string) string {
65+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
66+ versionStyle := r.theme.Highlight
67+
68+ return fmt.Sprintf(
69+ "\n %s Update %s downloaded and staged\n %s Will be applied when this command exits\n",
70+ iconStyle.Render(IconCheck),
71+ versionStyle.Render(version),
72+ iconStyle.Render(IconCheck),
73+ )
74+}
75+
76+// RenderError renders an error message.
77+func (r *UpdateRenderer) RenderError(err error) string {
78+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
79+
80+ return fmt.Sprintf(
81+ "\n %s Update failed: %v\n",
82+ iconStyle.Render(IconX),
83+ err,
84+ )
85+}
86+
87+// RenderCannotAutoUpdate renders the "cannot auto-update" message.
88+func (r *UpdateRenderer) RenderCannotAutoUpdate(current, latest, releaseURL string) string {
89+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
90+ versionStyle := r.theme.Highlight
91+ urlStyle := r.theme.Subtle
92+
93+ return fmt.Sprintf(
94+ "\n %s Update available: %s %s %s\n"+
95+ " %s Cannot auto-update: binary is not writable\n"+
96+ " Download manually: %s\n",
97+ iconStyle.Render(IconRocket),
98+ versionStyle.Render(current),
99+ iconStyle.Render(IconArrow),
100+ versionStyle.Render(latest),
101+ iconStyle.Render(IconWarning),
102+ urlStyle.Render(releaseURL),
103+ )
104+}
105+
106+// RenderDevBuild renders the "dev build" skip message.
107+func (r *UpdateRenderer) RenderDevBuild() string {
108+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
109+ return fmt.Sprintf(
110+ "\n %s Development build - update check skipped\n",
111+ iconStyle.Render(IconInfo),
112+ )
113+}
A internal/domain/entity/config_key.go +27 -0
@@ -0,0 +1,27 @@
1+package entity
2+
3+// ConfigKeyInfo describes a single configuration key for schema documentation.
4+type ConfigKeyInfo struct {
5+ // Key is the full dotted path to the config key (e.g., "appearance.color_scheme")
6+ Key string `json:"key"`
7+
8+ // Type is the Go type name (e.g., "string", "int", "bool", "float64")
9+ Type string `json:"type"`
10+
11+ // Default is the default value as a string representation
12+ Default string `json:"default"`
13+
14+ // Description explains the purpose of this config key
15+ Description string `json:"description"`
16+
17+ // Values contains valid enum values (for string enums)
18+ // Empty if not an enum type
19+ Values []string `json:"values,omitempty"`
20+
21+ // Range describes numeric constraints (e.g., "1-72", "0.1-5.0")
22+ // Empty if no range constraint
23+ Range string `json:"range,omitempty"`
24+
25+ // Section groups related keys (e.g., "Appearance", "Logging")
26+ Section string `json:"section"`
27+}
M internal/domain/entity/tab.go +9 -4
@@ -79,11 +79,16 @@ func (tl *TabList) Remove(id TabID) bool {
7979 tl.Tabs[j].Position = j
8080 }
8181 // Update active tab if needed
82- if tl.ActiveTabID == id && len(tl.Tabs) > 0 {
83- if i < len(tl.Tabs) {
84- tl.ActiveTabID = tl.Tabs[i].ID
82+ if tl.ActiveTabID == id {
83+ if len(tl.Tabs) > 0 {
84+ if i < len(tl.Tabs) {
85+ tl.ActiveTabID = tl.Tabs[i].ID
86+ } else {
87+ tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID
88+ }
8589 } else {
86- tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID
90+ tl.ActiveTabID = ""
91+ tl.PreviousActiveTabID = ""
8792 }
8893 }
8994 return true
M internal/infrastructure/config/defaults.go +31 -33
@@ -46,21 +46,17 @@ const (
4646 defaultBorderWidth = 1
4747 defaultBorderColor = "@theme_selected_bg_color"
4848
49- // Pane mode border (Ctrl+P N - overlay)
50- defaultPaneModeBorderWidth = 4
51- defaultPaneModeBorderColor = "#4A90E2" // Blue for pane mode indicator
49+ // Mode border width (consolidated for all modes)
50+ defaultModeBorderWidth = 4
5251
53- // Tab mode border (Ctrl+P T - overlay)
54- defaultTabModeBorderWidth = 4
55- defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator
52+ // Mode colors (used for both borders and toaster)
53+ defaultPaneModeColor = "#4A90E2" // Blue for pane mode
54+ defaultTabModeColor = "#FFA500" // Orange for tab mode
55+ defaultSessionModeColor = "#9B59B6" // Purple for session mode
56+ defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode
5657
57- // Session mode border (Ctrl+O - overlay)
58- defaultSessionModeBorderWidth = 4
59- defaultSessionModeBorderColor = "#9B59B6" // Purple for session mode indicator
60-
61- // Resize mode border (Ctrl+N - overlay)
62- defaultResizeModeBorderWidth = 4
63- defaultResizeModeBorderColor = "#00D4AA" // Cyan/teal for resize mode indicator
58+ // Mode indicator toaster
59+ defaultModeIndicatorToasterEnabled = true
6460
6561 // Other styling
6662 defaultTransitionDuration = 120
@@ -155,17 +151,20 @@ func DefaultConfig() *Config {
155151 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
156152 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
157153 Workspace: WorkspaceConfig{
158- NewPaneURL: defaultNewPaneURL,
154+ NewPaneURL: defaultNewPaneURL,
155+ SwitchToTabOnMove: true,
159156 PaneMode: PaneModeConfig{
160157 ActivationShortcut: defaultPaneActivationShortcut,
161158 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
162159 Actions: map[string][]string{
163- "split-right": {"arrowright", "r"},
164- "split-left": {"arrowleft", "l"},
165- "split-up": {"arrowup", "u"},
166- "split-down": {"arrowdown", "d"},
167- "stack-pane": {"s"},
168- "close-pane": {"x"},
160+ "split-right": {"arrowright", "r"},
161+ "split-left": {"arrowleft", "l"},
162+ "split-up": {"arrowup", "u"},
163+ "split-down": {"arrowdown", "d"},
164+ "stack-pane": {"s"},
165+ "close-pane": {"x"},
166+ "move-pane-to-tab": {"m"},
167+ "move-pane-to-next-tab": {"M", "shift+m"},
169168
170169 "consume-or-expel-left": {"bracketleft", "["},
171170 "consume-or-expel-right": {"bracketright", "]"},
@@ -235,17 +234,15 @@ func DefaultConfig() *Config {
235234 OAuthAutoClose: true, // Auto-close OAuth popups on success
236235 },
237236 Styling: WorkspaceStylingConfig{
238- BorderWidth: defaultBorderWidth,
239- BorderColor: defaultBorderColor,
240- PaneModeBorderWidth: defaultPaneModeBorderWidth,
241- PaneModeBorderColor: defaultPaneModeBorderColor,
242- TabModeBorderWidth: defaultTabModeBorderWidth,
243- TabModeBorderColor: defaultTabModeBorderColor,
244- SessionModeBorderWidth: defaultSessionModeBorderWidth,
245- SessionModeBorderColor: defaultSessionModeBorderColor,
246- ResizeModeBorderWidth: defaultResizeModeBorderWidth,
247- ResizeModeBorderColor: defaultResizeModeBorderColor,
248- TransitionDuration: defaultTransitionDuration,
237+ BorderWidth: defaultBorderWidth,
238+ BorderColor: defaultBorderColor,
239+ ModeBorderWidth: defaultModeBorderWidth,
240+ PaneModeColor: defaultPaneModeColor,
241+ TabModeColor: defaultTabModeColor,
242+ SessionModeColor: defaultSessionModeColor,
243+ ResizeModeColor: defaultResizeModeColor,
244+ ModeIndicatorToasterEnabled: defaultModeIndicatorToasterEnabled,
245+ TransitionDuration: defaultTransitionDuration,
249246 },
250247 },
251248 ContentFiltering: ContentFilteringConfig{
@@ -289,8 +286,9 @@ func DefaultConfig() *Config {
289286 WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
290287 },
291288 Update: UpdateConfig{
292- EnableOnStartup: true, // Check for updates on startup by default
293- AutoDownload: false, // Conservative: don't auto-download by default
289+ EnableOnStartup: true, // Check for updates on startup by default
290+ AutoDownload: false, // Conservative: don't auto-download by default
291+ NotifyOnNewSettings: true, // Show toast when new config settings available
294292 },
295293 }
296294 }
A internal/infrastructure/config/diff_formatter.go +40 -0
@@ -0,0 +1,40 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+// ConfigDiffFormatter implements port.DiffFormatter for formatting config changes.
11+type ConfigDiffFormatter struct{}
12+
13+// NewDiffFormatter creates a new ConfigDiffFormatter.
14+func NewDiffFormatter() *ConfigDiffFormatter {
15+ return &ConfigDiffFormatter{}
16+}
17+
18+// FormatChangesAsDiff returns changes formatted as a diff for display.
19+func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string {
20+ if len(changes) == 0 {
21+ return "No changes detected."
22+ }
23+
24+ var sb strings.Builder
25+ sb.WriteString("Config migration changes:\n\n")
26+
27+ for _, change := range changes {
28+ switch change.Type {
29+ case port.KeyChangeAdded:
30+ sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue))
31+ case port.KeyChangeRemoved:
32+ sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue))
33+ case port.KeyChangeRenamed:
34+ sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey))
35+ sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue))
36+ }
37+ }
38+
39+ return sb.String()
40+}
M internal/infrastructure/config/loader.go +8 -8
@@ -401,6 +401,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
401401 m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown)
402402 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
403403 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
404+ m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
404405 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
405406 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
406407 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
@@ -410,14 +411,12 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
410411 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
411412 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
412413 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
413- m.viper.SetDefault("workspace.styling.pane_mode_border_width", defaults.Workspace.Styling.PaneModeBorderWidth)
414- m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
415- m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)
416- m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)
417- m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth)
418- m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor)
419- m.viper.SetDefault("workspace.styling.resize_mode_border_width", defaults.Workspace.Styling.ResizeModeBorderWidth)
420- m.viper.SetDefault("workspace.styling.resize_mode_border_color", defaults.Workspace.Styling.ResizeModeBorderColor)
414+ m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth)
415+ m.viper.SetDefault("workspace.styling.pane_mode_color", defaults.Workspace.Styling.PaneModeColor)
416+ m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor)
417+ m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor)
418+ m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor)
419+ m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled)
421420 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
422421 }
423422
@@ -459,6 +458,7 @@ func (m *Manager) setSessionDefaults(defaults *Config) {
459458 func (m *Manager) setUpdateDefaults(defaults *Config) {
460459 m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup)
461460 m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload)
461+ m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings)
462462 }
463463
464464 // New returns a new default configuration instance.
A internal/infrastructure/config/migrate.go +847 -0
@@ -0,0 +1,847 @@
1+package config
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "sort"
10+ "strings"
11+
12+ "github.com/pelletier/go-toml/v2"
13+ "github.com/spf13/viper"
14+ "gopkg.in/yaml.v3"
15+
16+ "github.com/bnema/dumber/internal/application/port"
17+)
18+
19+// Migrator implements port.ConfigMigrator for comparing and merging config files.
20+type Migrator struct {
21+ // defaultViper holds a Viper instance with all defaults set.
22+ defaultViper *viper.Viper
23+ // defaultConfig holds the default configuration.
24+ defaultConfig *Config
25+}
26+
27+// NewMigrator creates a new Migrator instance.
28+func NewMigrator() *Migrator {
29+ v := viper.New()
30+ v.SetConfigType("toml")
31+
32+ // Create a temporary manager to set defaults
33+ m := &Manager{viper: v}
34+ m.setDefaults()
35+
36+ return &Migrator{
37+ defaultViper: v,
38+ defaultConfig: DefaultConfig(),
39+ }
40+}
41+
42+// CheckMigration checks if user config is missing any default keys.
43+// Returns nil if no migration is needed.
44+func (m *Migrator) CheckMigration() (*port.MigrationResult, error) {
45+ configFile, err := GetConfigFile()
46+ if err != nil {
47+ return nil, fmt.Errorf("failed to get config file path: %w", err)
48+ }
49+
50+ // If config file doesn't exist, no migration needed (will be created with all defaults)
51+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
52+ return nil, nil
53+ }
54+
55+ // Get user-defined keys from the TOML file
56+ userKeys, err := m.getUserConfigKeys(configFile)
57+ if err != nil {
58+ return nil, fmt.Errorf("failed to parse user config: %w", err)
59+ }
60+
61+ // Get all default keys
62+ defaultKeys := m.getAllDefaultKeys()
63+
64+ // Find missing keys (in defaults but not in user config)
65+ missingKeys := m.findMissingKeys(defaultKeys, userKeys)
66+
67+ if len(missingKeys) == 0 {
68+ return nil, nil
69+ }
70+
71+ return &port.MigrationResult{
72+ MissingKeys: missingKeys,
73+ ConfigFile: configFile,
74+ }, nil
75+}
76+
77+// DetectChanges analyzes user config and returns all detected changes.
78+// This provides a detailed diff-like view of what migration would do.
79+func (m *Migrator) DetectChanges() ([]port.KeyChange, error) {
80+ configFile, err := GetConfigFile()
81+ if err != nil {
82+ return nil, fmt.Errorf("failed to get config file path: %w", err)
83+ }
84+
85+ // If config file doesn't exist, no changes to detect
86+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
87+ return nil, nil
88+ }
89+
90+ // Get user-defined keys with values
91+ userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile)
92+ if err != nil {
93+ return nil, fmt.Errorf("failed to parse user config: %w", err)
94+ }
95+
96+ // Get all default keys
97+ defaultKeys := m.getAllDefaultKeys()
98+ defaultKeySet := make(map[string]bool)
99+ for _, k := range defaultKeys {
100+ defaultKeySet[k] = true
101+ }
102+
103+ // Build user key set for lookup
104+ userKeySet := make(map[string]bool)
105+ for k := range userKeysWithValues {
106+ userKeySet[k] = true
107+ }
108+
109+ var changes []port.KeyChange
110+
111+ // Find deprecated keys (in user config but not in defaults)
112+ deprecatedKeys := m.findDeprecatedKeys(userKeysWithValues, defaultKeySet)
113+
114+ // Find missing keys (in defaults but not in user config)
115+ missingKeys := m.findMissingKeys(defaultKeys, userKeySet)
116+
117+ // Try to match deprecated keys with missing keys (detect renames)
118+ renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys)
119+
120+ // Add rename changes
121+ for oldKey, newKey := range renames {
122+ oldValue := m.formatValue(userKeysWithValues[oldKey])
123+ newValue := m.formatValue(m.defaultViper.Get(newKey))
124+ changes = append(changes, port.KeyChange{
125+ Type: port.KeyChangeRenamed,
126+ OldKey: oldKey,
127+ NewKey: newKey,
128+ OldValue: oldValue,
129+ NewValue: newValue,
130+ })
131+ }
132+
133+ // Add removed changes (deprecated keys that couldn't be matched)
134+ for _, oldKey := range unmatchedDeprecated {
135+ changes = append(changes, port.KeyChange{
136+ Type: port.KeyChangeRemoved,
137+ OldKey: oldKey,
138+ OldValue: m.formatValue(userKeysWithValues[oldKey]),
139+ })
140+ }
141+
142+ // Add new key changes (missing keys that couldn't be matched)
143+ for _, newKey := range unmatchedMissing {
144+ changes = append(changes, port.KeyChange{
145+ Type: port.KeyChangeAdded,
146+ NewKey: newKey,
147+ NewValue: m.formatValue(m.defaultViper.Get(newKey)),
148+ })
149+ }
150+
151+ // Sort changes for consistent output
152+ sort.Slice(changes, func(i, j int) bool {
153+ // Sort by type first, then by key
154+ if changes[i].Type != changes[j].Type {
155+ return changes[i].Type < changes[j].Type
156+ }
157+ keyI := changes[i].NewKey
158+ if keyI == "" {
159+ keyI = changes[i].OldKey
160+ }
161+ keyJ := changes[j].NewKey
162+ if keyJ == "" {
163+ keyJ = changes[j].OldKey
164+ }
165+ return keyI < keyJ
166+ })
167+
168+ return changes, nil
169+}
170+
171+// findDeprecatedKeys returns user keys that don't exist in defaults.
172+func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string {
173+ var deprecated []string
174+ for userKey := range userKeys {
175+ if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) {
176+ deprecated = append(deprecated, userKey)
177+ }
178+ }
179+ sort.Strings(deprecated)
180+ return deprecated
181+}
182+
183+// keyOrRelatedExistsInDefaults checks if a user key exists in defaults.
184+func (*Migrator) keyOrRelatedExistsInDefaults(userKey string, defaultKeys map[string]bool) bool {
185+ if defaultKeys[userKey] {
186+ return true
187+ }
188+
189+ // Check if this is a parent of any default key
190+ userKeyPrefix := userKey + "."
191+ for defaultKey := range defaultKeys {
192+ if strings.HasPrefix(defaultKey, userKeyPrefix) {
193+ return true
194+ }
195+ }
196+
197+ // Check if any parent of this user key exists
198+ parts := strings.Split(userKey, ".")
199+ for i := len(parts) - 1; i > 0; i-- {
200+ parentKey := strings.Join(parts[:i], ".")
201+ if defaultKeys[parentKey] {
202+ return true
203+ }
204+ }
205+
206+ return false
207+}
208+
209+// matchRenamedKeys attempts to match deprecated keys with missing keys.
210+// Returns: renames map, unmatched deprecated, unmatched missing.
211+func (m *Migrator) matchRenamedKeys(deprecated, missing []string) (map[string]string, []string, []string) {
212+ renames := make(map[string]string)
213+ usedDeprecated := make(map[string]bool)
214+ usedMissing := make(map[string]bool)
215+
216+ // First pass: exact suffix matching (e.g., "foo_border_color" -> "foo_color")
217+ for _, oldKey := range deprecated {
218+ for _, newKey := range missing {
219+ if usedMissing[newKey] {
220+ continue
221+ }
222+
223+ // Check if keys are similar (same parent path + related leaf names)
224+ // AND have compatible types (don't match int keys with string keys)
225+ if keysAreSimilar(oldKey, newKey) && m.typesAreCompatible(oldKey, newKey) {
226+ renames[oldKey] = newKey
227+ usedDeprecated[oldKey] = true
228+ usedMissing[newKey] = true
229+ break
230+ }
231+ }
232+ }
233+
234+ // Collect unmatched
235+ var unmatchedDeprecated []string
236+ for _, k := range deprecated {
237+ if !usedDeprecated[k] {
238+ unmatchedDeprecated = append(unmatchedDeprecated, k)
239+ }
240+ }
241+
242+ var unmatchedMissing []string
243+ for _, k := range missing {
244+ if !usedMissing[k] {
245+ unmatchedMissing = append(unmatchedMissing, k)
246+ }
247+ }
248+
249+ return renames, unmatchedDeprecated, unmatchedMissing
250+}
251+
252+// Type name constants for migration compatibility checks.
253+const (
254+ typeNameInt = "int"
255+ typeNameString = "string"
256+)
257+
258+// Config format constants.
259+const (
260+ configFormatTOML = "toml"
261+ configFormatYAML = "yaml"
262+ configFormatJSON = "json"
263+)
264+
265+// typesAreCompatible checks if the default types for two keys are compatible.
266+// This prevents matching int keys with string keys during rename detection.
267+func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool {
268+ // Get the type of the new key from defaults
269+ newValue := m.defaultViper.Get(newKey)
270+ if newValue == nil {
271+ return false
272+ }
273+
274+ newType := m.getTypeName(newValue)
275+
276+ // Infer expected old type from key name patterns
277+ // Keys ending in "_width" are typically int
278+ // Keys ending in "_color" are typically string
279+ if strings.HasSuffix(oldKey, "_width") && newType == typeNameString {
280+ return false
281+ }
282+ if strings.HasSuffix(oldKey, "_color") && newType == typeNameInt {
283+ return false
284+ }
285+
286+ // Additional checks based on new key patterns
287+ if strings.HasSuffix(newKey, "_width") && newType != typeNameInt {
288+ return false
289+ }
290+ if strings.HasSuffix(newKey, "_color") && newType != typeNameString {
291+ return false
292+ }
293+
294+ return true
295+}
296+
297+// keysAreSimilar checks if two keys are likely renames of each other.
298+func keysAreSimilar(oldKey, newKey string) bool {
299+ oldParts := strings.Split(oldKey, ".")
300+ newParts := strings.Split(newKey, ".")
301+
302+ // Must have same parent path (all but last part)
303+ if len(oldParts) != len(newParts) {
304+ return false
305+ }
306+ if len(oldParts) < 2 {
307+ return false
308+ }
309+
310+ // Check parent path matches
311+ for i := 0; i < len(oldParts)-1; i++ {
312+ if oldParts[i] != newParts[i] {
313+ return false
314+ }
315+ }
316+
317+ // Check if leaf names are similar (one is substring of the other, or share a common base)
318+ oldLeaf := oldParts[len(oldParts)-1]
319+ newLeaf := newParts[len(newParts)-1]
320+
321+ // Direct substring check
322+ if strings.Contains(oldLeaf, newLeaf) || strings.Contains(newLeaf, oldLeaf) {
323+ return true
324+ }
325+
326+ // Common prefix/suffix matching (e.g., "pane_mode_border_color" vs "pane_mode_color")
327+ // Extract common parts by splitting on underscore
328+ oldTokens := strings.Split(oldLeaf, "_")
329+ newTokens := strings.Split(newLeaf, "_")
330+
331+ // Count matching tokens, ensuring each new token is matched at most once
332+ matches := 0
333+ matchedNew := make([]bool, len(newTokens))
334+ for _, ot := range oldTokens {
335+ for j, nt := range newTokens {
336+ if ot == nt && !matchedNew[j] {
337+ matches++
338+ matchedNew[j] = true
339+ break
340+ }
341+ }
342+ }
343+
344+ // If most tokens match, consider similar
345+ minTokens := len(oldTokens)
346+ if len(newTokens) < minTokens {
347+ minTokens = len(newTokens)
348+ }
349+ return matches >= minTokens-1 && matches > 0
350+}
351+
352+// Migrate adds missing default keys to the user's config file and removes deprecated keys.
353+func (m *Migrator) Migrate() ([]string, error) {
354+ changes, err := m.DetectChanges()
355+ if err != nil {
356+ return nil, err
357+ }
358+
359+ if len(changes) == 0 {
360+ return nil, nil
361+ }
362+
363+ configFile, err := GetConfigFile()
364+ if err != nil {
365+ return nil, fmt.Errorf("failed to get config file path: %w", err)
366+ }
367+
368+ // Read the raw config file into a nested map
369+ rawConfig, err := m.readRawConfig(configFile)
370+ if err != nil {
371+ return nil, fmt.Errorf("failed to read config file: %w", err)
372+ }
373+
374+ // Build sets of keys to remove and renames to apply
375+ keysToRemove := make(map[string]bool)
376+ renames := make(map[string]string) // oldKey -> newKey
377+
378+ var appliedKeys []string
379+ for _, change := range changes {
380+ switch change.Type {
381+ case port.KeyChangeRenamed:
382+ renames[change.OldKey] = change.NewKey
383+ keysToRemove[change.OldKey] = true
384+ appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey))
385+ case port.KeyChangeAdded:
386+ appliedKeys = append(appliedKeys, change.NewKey)
387+ case port.KeyChangeRemoved:
388+ keysToRemove[change.OldKey] = true
389+ appliedKeys = append(appliedKeys, fmt.Sprintf("(removed: %s)", change.OldKey))
390+ }
391+ }
392+
393+ // Apply renames: copy value from old key to new key before removing old key
394+ for oldKey, newKey := range renames {
395+ if value := m.getNestedValue(rawConfig, oldKey); value != nil {
396+ m.setNestedValue(rawConfig, newKey, value)
397+ }
398+ }
399+
400+ // Remove deprecated and renamed keys from the raw config
401+ for key := range keysToRemove {
402+ m.deleteNestedKey(rawConfig, key)
403+ }
404+
405+ // Create a new Viper instance with defaults for added keys
406+ userViper := viper.New()
407+ userViper.SetConfigFile(configFile)
408+ userViper.SetConfigType(m.getConfigType(configFile))
409+
410+ // Set all defaults first
411+ mgr := &Manager{viper: userViper}
412+ mgr.setDefaults()
413+
414+ // Merge the cleaned raw config on top of defaults
415+ if err := userViper.MergeConfigMap(rawConfig); err != nil {
416+ return nil, fmt.Errorf("failed to merge config: %w", err)
417+ }
418+
419+ // Write the merged config back
420+ if err := userViper.WriteConfig(); err != nil {
421+ return nil, fmt.Errorf("failed to write config file: %w", err)
422+ }
423+
424+ return appliedKeys, nil
425+}
426+
427+// getConfigType returns the config type based on file extension.
428+func (*Migrator) getConfigType(configFile string) string {
429+ ext := strings.TrimPrefix(filepath.Ext(configFile), ".")
430+ switch ext {
431+ case configFormatYAML, "yml":
432+ return configFormatYAML
433+ case configFormatJSON:
434+ return configFormatJSON
435+ case configFormatTOML:
436+ return configFormatTOML
437+ default:
438+ return configFormatTOML // default to TOML
439+ }
440+}
441+
442+// readRawConfig reads the config file into a nested map without applying defaults.
443+func (m *Migrator) readRawConfig(configFile string) (map[string]any, error) {
444+ data, err := os.ReadFile(configFile)
445+ if err != nil {
446+ return nil, err
447+ }
448+
449+ var rawConfig map[string]any
450+ configType := m.getConfigType(configFile)
451+
452+ switch configType {
453+ case configFormatTOML:
454+ if err := toml.Unmarshal(data, &rawConfig); err != nil {
455+ return nil, fmt.Errorf("failed to parse TOML: %w", err)
456+ }
457+ case configFormatYAML:
458+ if err := yaml.Unmarshal(data, &rawConfig); err != nil {
459+ return nil, fmt.Errorf("failed to parse YAML: %w", err)
460+ }
461+ case configFormatJSON:
462+ if err := json.Unmarshal(data, &rawConfig); err != nil {
463+ return nil, fmt.Errorf("failed to parse JSON: %w", err)
464+ }
465+ default:
466+ return nil, fmt.Errorf("unsupported config type: %s", configType)
467+ }
468+
469+ return rawConfig, nil
470+}
471+
472+// getNestedValue retrieves a value from a nested map using dot-notation key.
473+func (*Migrator) getNestedValue(m map[string]any, key string) any {
474+ parts := strings.Split(key, ".")
475+ current := m
476+
477+ for i, part := range parts {
478+ val, ok := current[part]
479+ if !ok {
480+ return nil
481+ }
482+
483+ if i == len(parts)-1 {
484+ return val
485+ }
486+
487+ nested, ok := val.(map[string]any)
488+ if !ok {
489+ return nil
490+ }
491+ current = nested
492+ }
493+ return nil
494+}
495+
496+// setNestedValue sets a value in a nested map using dot-notation key.
497+func (*Migrator) setNestedValue(m map[string]any, key string, value any) {
498+ parts := strings.Split(key, ".")
499+ current := m
500+
501+ for i, part := range parts {
502+ if i == len(parts)-1 {
503+ current[part] = value
504+ return
505+ }
506+
507+ val, ok := current[part]
508+ if !ok {
509+ // Create intermediate map
510+ newMap := make(map[string]any)
511+ current[part] = newMap
512+ current = newMap
513+ continue
514+ }
515+
516+ nested, ok := val.(map[string]any)
517+ if !ok {
518+ // Overwrite non-map value with a map
519+ newMap := make(map[string]any)
520+ current[part] = newMap
521+ current = newMap
522+ continue
523+ }
524+ current = nested
525+ }
526+}
527+
528+// deleteNestedKey removes a key from a nested map using dot-notation key.
529+// It also cleans up empty parent maps.
530+func (m *Migrator) deleteNestedKey(config map[string]any, key string) {
531+ parts := strings.Split(key, ".")
532+ m.deleteNestedKeyRecursive(config, parts)
533+}
534+
535+// deleteNestedKeyRecursive recursively deletes a key and cleans up empty parents.
536+func (m *Migrator) deleteNestedKeyRecursive(current map[string]any, parts []string) bool {
537+ if len(parts) == 0 {
538+ return false
539+ }
540+
541+ part := parts[0]
542+ val, ok := current[part]
543+ if !ok {
544+ return false
545+ }
546+
547+ if len(parts) == 1 {
548+ // This is the leaf key, delete it
549+ delete(current, part)
550+ return true
551+ }
552+
553+ // Navigate deeper
554+ nested, ok := val.(map[string]any)
555+ if !ok {
556+ return false
557+ }
558+
559+ // Recursively delete in the nested map
560+ deleted := m.deleteNestedKeyRecursive(nested, parts[1:])
561+
562+ // If the nested map is now empty, remove it too
563+ if deleted && len(nested) == 0 {
564+ delete(current, part)
565+ }
566+
567+ return deleted
568+}
569+
570+// GetKeyInfo returns detailed information about a config key.
571+func (m *Migrator) GetKeyInfo(key string) port.KeyInfo {
572+ value := m.defaultViper.Get(key)
573+ if value == nil {
574+ return port.KeyInfo{
575+ Key: key,
576+ Type: "unknown",
577+ DefaultValue: "unknown",
578+ }
579+ }
580+
581+ return port.KeyInfo{
582+ Key: key,
583+ Type: m.getTypeName(value),
584+ DefaultValue: m.formatValue(value),
585+ }
586+}
587+
588+// GetConfigFile returns the path to the user's config file.
589+func (*Migrator) GetConfigFile() (string, error) {
590+ return GetConfigFile()
591+}
592+
593+// getAllDefaultKeys returns all keys from the default configuration.
594+func (m *Migrator) getAllDefaultKeys() []string {
595+ keys := m.defaultViper.AllKeys()
596+
597+ // Filter out keys that should not be migrated
598+ filtered := make([]string, 0, len(keys))
599+ for _, key := range keys {
600+ // Skip database.path as it's set dynamically
601+ if key == "database.path" {
602+ continue
603+ }
604+ filtered = append(filtered, key)
605+ }
606+
607+ sort.Strings(filtered)
608+ return filtered
609+}
610+
611+// getUserConfigKeys parses the user's TOML file and returns all defined keys.
612+func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) {
613+ keysWithValues, err := m.getUserConfigKeysWithValues(configFile)
614+ if err != nil {
615+ return nil, err
616+ }
617+
618+ keys := make(map[string]bool)
619+ for k := range keysWithValues {
620+ keys[k] = true
621+ }
622+ return keys, nil
623+}
624+
625+// getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values.
626+func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) {
627+ data, err := os.ReadFile(configFile)
628+ if err != nil {
629+ return nil, fmt.Errorf("failed to read config file: %w", err)
630+ }
631+
632+ // Parse TOML into a generic map
633+ var rawConfig map[string]any
634+ if err := toml.Unmarshal(data, &rawConfig); err != nil {
635+ return nil, fmt.Errorf("failed to parse TOML: %w", err)
636+ }
637+
638+ // Flatten the map to dot-notation keys with values
639+ result := make(map[string]any)
640+ m.flattenMapWithValues(rawConfig, "", result)
641+
642+ return result, nil
643+}
644+
645+// flattenMapWithValues recursively flattens a nested map to dot-notation keys with values.
646+func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) {
647+ for k, v := range data {
648+ key := k
649+ if prefix != "" {
650+ key = prefix + "." + k
651+ }
652+
653+ switch val := v.(type) {
654+ case map[string]any:
655+ // Check if this key represents user data (like search_shortcuts, actions)
656+ // that should be treated as a single key rather than recursed into.
657+ if m.isUserDataSection(key) {
658+ result[key] = v
659+ } else {
660+ // Recurse into nested config sections
661+ m.flattenMapWithValues(val, key, result)
662+ }
663+ default:
664+ result[key] = v
665+ }
666+ }
667+}
668+
669+// flattenMap recursively flattens a nested map to dot-notation keys.
670+func (m *Migrator) flattenMap(data map[string]any, prefix string, keys map[string]bool) {
671+ for k, v := range data {
672+ key := k
673+ if prefix != "" {
674+ key = prefix + "." + k
675+ }
676+
677+ switch val := v.(type) {
678+ case map[string]any:
679+ // Check if this key represents user data (like search_shortcuts, actions)
680+ // that should be treated as a single key rather than recursed into.
681+ if m.isUserDataSection(key) {
682+ keys[key] = true
683+ } else {
684+ // Recurse into nested config sections
685+ m.flattenMap(val, key, keys)
686+ }
687+ default:
688+ keys[key] = true
689+ }
690+ }
691+}
692+
693+// isUserDataSection checks if a key path represents user-defined data
694+// that should not be compared key-by-key (e.g., search shortcuts, action bindings).
695+func (*Migrator) isUserDataSection(keyPath string) bool {
696+ // User data sections - treat entire section as one key
697+ switch keyPath {
698+ case "search_shortcuts":
699+ return true
700+ }
701+
702+ // Action maps are user-customizable
703+ userDataSuffixes := []string{
704+ ".actions", // pane_mode.actions, tab_mode.actions, etc.
705+ }
706+
707+ for _, suffix := range userDataSuffixes {
708+ if strings.HasSuffix(keyPath, suffix) {
709+ return true
710+ }
711+ }
712+
713+ return false
714+}
715+
716+// findMissingKeys returns keys that are in defaults but not in user config.
717+func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string {
718+ missing := make([]string, 0)
719+
720+ for _, key := range defaultKeys {
721+ // Check if key or any related key exists in user config
722+ if m.keyOrRelatedExists(key, userKeys) {
723+ continue
724+ }
725+ missing = append(missing, key)
726+ }
727+
728+ // Sort for consistent output
729+ sort.Strings(missing)
730+ return missing
731+}
732+
733+// keyOrRelatedExists checks if a key, any parent, or any child keys exist in the map.
734+// This handles cases where:
735+// - User has defined a parent section but not all children
736+// - Default is a struct key but user has individual sub-keys (e.g., palettes)
737+func (*Migrator) keyOrRelatedExists(key string, keys map[string]bool) bool {
738+ if keys[key] {
739+ return true
740+ }
741+
742+ // Check if this is a child of a leaf map (like search_shortcuts.ddg)
743+ // In that case, we should check if the parent exists
744+ parts := strings.Split(key, ".")
745+ for i := len(parts) - 1; i > 0; i-- {
746+ parentKey := strings.Join(parts[:i], ".")
747+ if keys[parentKey] {
748+ // Parent exists as a leaf map, so child is implicitly defined
749+ return true
750+ }
751+ }
752+
753+ // Check if any child keys exist (e.g., default is "appearance.dark_palette"
754+ // but user has "appearance.dark_palette.background")
755+ keyPrefix := key + "."
756+ for userKey := range keys {
757+ if strings.HasPrefix(userKey, keyPrefix) {
758+ return true
759+ }
760+ }
761+
762+ return false
763+}
764+
765+// getTypeName returns a human-readable type name for a value.
766+func (*Migrator) getTypeName(value any) string {
767+ if value == nil {
768+ return "unknown"
769+ }
770+
771+ t := reflect.TypeOf(value)
772+ switch t.Kind() {
773+ case reflect.Bool:
774+ return "bool"
775+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
776+ return "int"
777+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
778+ return "int"
779+ case reflect.Float32, reflect.Float64:
780+ return "float"
781+ case reflect.String:
782+ return "string"
783+ case reflect.Slice:
784+ return "list"
785+ case reflect.Map:
786+ return "map"
787+ default:
788+ return t.String()
789+ }
790+}
791+
792+// formatValue returns a human-readable string representation of a value.
793+func (*Migrator) formatValue(value any) string {
794+ if value == nil {
795+ return "null"
796+ }
797+
798+ switch v := value.(type) {
799+ case bool:
800+ if v {
801+ return "true"
802+ }
803+ return "false"
804+ case int, int8, int16, int32, int64:
805+ return fmt.Sprintf("%d", v)
806+ case uint, uint8, uint16, uint32, uint64:
807+ return fmt.Sprintf("%d", v)
808+ case float32, float64:
809+ return fmt.Sprintf("%v", v)
810+ case string:
811+ if v == "" {
812+ return `""`
813+ }
814+ // Truncate long strings
815+ const maxStringLen = 50
816+ if len(v) > maxStringLen {
817+ return fmt.Sprintf("%q...", v[:maxStringLen-3])
818+ }
819+ return fmt.Sprintf("%q", v)
820+ case []any:
821+ if len(v) == 0 {
822+ return "[]"
823+ }
824+ return fmt.Sprintf("[%d items]", len(v))
825+ case map[string]any:
826+ if len(v) == 0 {
827+ return "{}"
828+ }
829+ return fmt.Sprintf("{%d entries}", len(v))
830+ default:
831+ // Handle slices of strings (common for action bindings)
832+ rv := reflect.ValueOf(value)
833+ if rv.Kind() == reflect.Slice {
834+ if rv.Len() == 0 {
835+ return "[]"
836+ }
837+ return fmt.Sprintf("[%d items]", rv.Len())
838+ }
839+ if rv.Kind() == reflect.Map {
840+ if rv.Len() == 0 {
841+ return "{}"
842+ }
843+ return fmt.Sprintf("{%d entries}", rv.Len())
844+ }
845+ return fmt.Sprintf("%v", v)
846+ }
847+}
A internal/infrastructure/config/migrate_test.go +875 -0
@@ -0,0 +1,875 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestMigrator_NewMigrator(t *testing.T) {
13+ m := NewMigrator()
14+ require.NotNil(t, m)
15+ require.NotNil(t, m.defaultViper)
16+ require.NotNil(t, m.defaultConfig)
17+}
18+
19+func TestMigrator_GetAllDefaultKeys(t *testing.T) {
20+ m := NewMigrator()
21+ keys := m.getAllDefaultKeys()
22+
23+ // Should have many default keys
24+ assert.Greater(t, len(keys), 50, "should have many default keys")
25+
26+ // Should not include database.path (it's dynamically set)
27+ for _, key := range keys {
28+ assert.NotEqual(t, "database.path", key, "should not include database.path")
29+ }
30+
31+ // Should include some known keys
32+ keySet := make(map[string]bool)
33+ for _, k := range keys {
34+ keySet[k] = true
35+ }
36+
37+ assert.True(t, keySet["history.max_entries"], "should include history.max_entries")
38+ assert.True(t, keySet["logging.level"], "should include logging.level")
39+ assert.True(t, keySet["appearance.sans_font"], "should include appearance.sans_font")
40+}
41+
42+func TestMigrator_GetUserConfigKeys(t *testing.T) {
43+ m := NewMigrator()
44+
45+ // Create a temp config file
46+ tmpDir := t.TempDir()
47+ configFile := filepath.Join(tmpDir, "config.toml")
48+
49+ content := `
50+[history]
51+max_entries = 5000
52+retention_period_days = 180
53+
54+[logging]
55+level = "debug"
56+
57+[appearance]
58+sans_font = "Arial"
59+`
60+ err := os.WriteFile(configFile, []byte(content), 0o644)
61+ require.NoError(t, err)
62+
63+ keys, err := m.getUserConfigKeys(configFile)
64+ require.NoError(t, err)
65+
66+ // Should have the keys we defined
67+ assert.True(t, keys["history.max_entries"])
68+ assert.True(t, keys["history.retention_period_days"])
69+ assert.True(t, keys["logging.level"])
70+ assert.True(t, keys["appearance.sans_font"])
71+
72+ // Should not have keys we didn't define
73+ assert.False(t, keys["history.cleanup_interval_days"])
74+ assert.False(t, keys["logging.format"])
75+}
76+
77+func TestMigrator_FlattenMap(t *testing.T) {
78+ m := NewMigrator()
79+
80+ tests := []struct {
81+ name string
82+ input map[string]any
83+ prefix string
84+ expected []string
85+ }{
86+ {
87+ name: "simple flat map",
88+ input: map[string]any{
89+ "key1": "value1",
90+ "key2": 123,
91+ },
92+ prefix: "",
93+ expected: []string{"key1", "key2"},
94+ },
95+ {
96+ name: "nested map",
97+ input: map[string]any{
98+ "parent": map[string]any{
99+ "child1": "value1",
100+ "child2": "value2",
101+ },
102+ },
103+ prefix: "",
104+ expected: []string{"parent.child1", "parent.child2"},
105+ },
106+ {
107+ name: "with prefix",
108+ input: map[string]any{
109+ "key": "value",
110+ },
111+ prefix: "section",
112+ expected: []string{"section.key"},
113+ },
114+ {
115+ name: "search_shortcuts treated as single key",
116+ input: map[string]any{
117+ "search_shortcuts": map[string]any{
118+ "g": "https://google.com/?q=%s",
119+ "ddg": "https://duckduckgo.com/?q=%s",
120+ },
121+ },
122+ prefix: "",
123+ expected: []string{"search_shortcuts"}, // Should be treated as user data
124+ },
125+ {
126+ name: "actions map treated as single key",
127+ input: map[string]any{
128+ "pane_mode": map[string]any{
129+ "actions": map[string]any{
130+ "split-right": []string{"r"},
131+ },
132+ },
133+ },
134+ prefix: "",
135+ expected: []string{"pane_mode.actions"}, // Actions treated as user data
136+ },
137+ }
138+
139+ for _, tt := range tests {
140+ t.Run(tt.name, func(t *testing.T) {
141+ keys := make(map[string]bool)
142+ m.flattenMap(tt.input, tt.prefix, keys)
143+
144+ for _, expectedKey := range tt.expected {
145+ assert.True(t, keys[expectedKey], "should contain key: %s", expectedKey)
146+ }
147+ })
148+ }
149+}
150+
151+func TestMigrator_IsUserDataSection(t *testing.T) {
152+ m := NewMigrator()
153+
154+ tests := []struct {
155+ name string
156+ keyPath string
157+ expected bool
158+ }{
159+ {
160+ name: "search_shortcuts is user data",
161+ keyPath: "search_shortcuts",
162+ expected: true,
163+ },
164+ {
165+ name: "pane_mode.actions is user data",
166+ keyPath: "pane_mode.actions",
167+ expected: true,
168+ },
169+ {
170+ name: "tab_mode.actions is user data",
171+ keyPath: "tab_mode.actions",
172+ expected: true,
173+ },
174+ {
175+ name: "history is not user data",
176+ keyPath: "history",
177+ expected: false,
178+ },
179+ {
180+ name: "history.max_entries is not user data",
181+ keyPath: "history.max_entries",
182+ expected: false,
183+ },
184+ {
185+ name: "appearance.sans_font is not user data",
186+ keyPath: "appearance.sans_font",
187+ expected: false,
188+ },
189+ }
190+
191+ for _, tt := range tests {
192+ t.Run(tt.name, func(t *testing.T) {
193+ result := m.isUserDataSection(tt.keyPath)
194+ assert.Equal(t, tt.expected, result)
195+ })
196+ }
197+}
198+
199+func TestMigrator_FindMissingKeys(t *testing.T) {
200+ m := NewMigrator()
201+
202+ tests := []struct {
203+ name string
204+ defaultKeys []string
205+ userKeys map[string]bool
206+ expected []string
207+ }{
208+ {
209+ name: "all keys present",
210+ defaultKeys: []string{"key1", "key2"},
211+ userKeys: map[string]bool{"key1": true, "key2": true},
212+ expected: []string{},
213+ },
214+ {
215+ name: "some keys missing",
216+ defaultKeys: []string{"key1", "key2", "key3"},
217+ userKeys: map[string]bool{"key1": true},
218+ expected: []string{"key2", "key3"},
219+ },
220+ {
221+ name: "all keys missing",
222+ defaultKeys: []string{"key1", "key2"},
223+ userKeys: map[string]bool{},
224+ expected: []string{"key1", "key2"},
225+ },
226+ {
227+ name: "parent key covers children",
228+ defaultKeys: []string{"parent.child1", "parent.child2"},
229+ userKeys: map[string]bool{"parent": true},
230+ expected: []string{}, // Parent covers children
231+ },
232+ {
233+ name: "child keys cover parent (struct defaults like palettes)",
234+ defaultKeys: []string{"appearance.dark_palette"},
235+ userKeys: map[string]bool{"appearance.dark_palette.background": true, "appearance.dark_palette.accent": true},
236+ expected: []string{}, // Child keys mean parent is defined
237+ },
238+ }
239+
240+ for _, tt := range tests {
241+ t.Run(tt.name, func(t *testing.T) {
242+ result := m.findMissingKeys(tt.defaultKeys, tt.userKeys)
243+ assert.ElementsMatch(t, tt.expected, result)
244+ })
245+ }
246+}
247+
248+func TestMigrator_GetKeyInfo(t *testing.T) {
249+ m := NewMigrator()
250+
251+ tests := []struct {
252+ name string
253+ key string
254+ expectedType string
255+ }{
256+ {
257+ name: "bool key",
258+ key: "debug.enable_devtools",
259+ expectedType: "bool",
260+ },
261+ {
262+ name: "int key",
263+ key: "history.max_entries",
264+ expectedType: "int",
265+ },
266+ {
267+ name: "string key",
268+ key: "logging.level",
269+ expectedType: "string",
270+ },
271+ {
272+ name: "float key",
273+ key: "default_webpage_zoom",
274+ expectedType: "float",
275+ },
276+ }
277+
278+ for _, tt := range tests {
279+ t.Run(tt.name, func(t *testing.T) {
280+ info := m.GetKeyInfo(tt.key)
281+ assert.Equal(t, tt.key, info.Key)
282+ assert.Equal(t, tt.expectedType, info.Type)
283+ assert.NotEmpty(t, info.DefaultValue)
284+ })
285+ }
286+}
287+
288+func TestMigrator_GetTypeName(t *testing.T) {
289+ m := NewMigrator()
290+
291+ tests := []struct {
292+ name string
293+ value any
294+ expected string
295+ }{
296+ {name: "bool", value: true, expected: "bool"},
297+ {name: "int", value: 42, expected: "int"},
298+ {name: "int64", value: int64(42), expected: "int"},
299+ {name: "float64", value: 3.14, expected: "float"},
300+ {name: "string", value: "hello", expected: "string"},
301+ {name: "slice", value: []any{"a", "b"}, expected: "list"},
302+ {name: "map", value: map[string]any{"a": 1}, expected: "map"},
303+ {name: "nil", value: nil, expected: "unknown"},
304+ }
305+
306+ for _, tt := range tests {
307+ t.Run(tt.name, func(t *testing.T) {
308+ result := m.getTypeName(tt.value)
309+ assert.Equal(t, tt.expected, result)
310+ })
311+ }
312+}
313+
314+func TestMigrator_FormatValue(t *testing.T) {
315+ m := NewMigrator()
316+
317+ tests := []struct {
318+ name string
319+ value any
320+ expected string
321+ }{
322+ {name: "true", value: true, expected: "true"},
323+ {name: "false", value: false, expected: "false"},
324+ {name: "int", value: 42, expected: "42"},
325+ {name: "float", value: 3.14, expected: "3.14"},
326+ {name: "string", value: "hello", expected: `"hello"`},
327+ {name: "empty string", value: "", expected: `""`},
328+ {name: "empty slice", value: []any{}, expected: "[]"},
329+ {name: "slice with items", value: []any{"a", "b", "c"}, expected: "[3 items]"},
330+ {name: "empty map", value: map[string]any{}, expected: "{}"},
331+ {name: "map with entries", value: map[string]any{"a": 1, "b": 2}, expected: "{2 entries}"},
332+ {name: "nil", value: nil, expected: "null"},
333+ }
334+
335+ for _, tt := range tests {
336+ t.Run(tt.name, func(t *testing.T) {
337+ result := m.formatValue(tt.value)
338+ assert.Equal(t, tt.expected, result)
339+ })
340+ }
341+}
342+
343+func TestKeysAreSimilar(t *testing.T) {
344+ tests := []struct {
345+ name string
346+ oldKey string
347+ newKey string
348+ expected bool
349+ }{
350+ {
351+ name: "identical keys",
352+ oldKey: "section.pane_mode_color",
353+ newKey: "section.pane_mode_color",
354+ expected: true,
355+ },
356+ {
357+ name: "substring match - old contains new",
358+ oldKey: "section.pane_mode_border_color",
359+ newKey: "section.pane_mode_color",
360+ expected: true,
361+ },
362+ {
363+ name: "substring match - new contains old",
364+ oldKey: "section.mode_color",
365+ newKey: "section.pane_mode_color",
366+ expected: true,
367+ },
368+ {
369+ name: "token matching - border removed",
370+ oldKey: "section.tab_mode_border_color",
371+ newKey: "section.tab_mode_color",
372+ expected: true,
373+ },
374+ {
375+ name: "different parent paths",
376+ oldKey: "section_a.pane_mode_color",
377+ newKey: "section_b.pane_mode_color",
378+ expected: false,
379+ },
380+ {
381+ name: "different depth",
382+ oldKey: "pane_mode_color",
383+ newKey: "section.pane_mode_color",
384+ expected: false,
385+ },
386+ {
387+ name: "completely different keys",
388+ oldKey: "section.foo_bar",
389+ newKey: "section.baz_qux",
390+ expected: false,
391+ },
392+ {
393+ name: "top level keys rejected",
394+ oldKey: "foo",
395+ newKey: "bar",
396+ expected: false,
397+ },
398+ {
399+ name: "no common tokens",
400+ oldKey: "section.aaa_bbb",
401+ newKey: "section.ccc_ddd",
402+ expected: false,
403+ },
404+ {
405+ name: "single token difference allowed",
406+ oldKey: "section.pane_mode_border_width",
407+ newKey: "section.mode_border_width",
408+ expected: true,
409+ },
410+ {
411+ name: "duplicate tokens in old key - no double counting",
412+ oldKey: "section.mode_mode_mode",
413+ newKey: "section.mode_other_another",
414+ expected: false, // oldTokens=[mode,mode,mode], newTokens=[mode,other,another], matches=1 (only one mode can match), minTokens=3, needs 1>=2 which is false
415+ },
416+ {
417+ name: "enough common tokens with duplicates",
418+ oldKey: "section.pane_mode_border",
419+ newKey: "section.pane_mode_color",
420+ expected: true, // oldTokens=[pane,mode,border], newTokens=[pane,mode,color], matches=2, minTokens=3, 2>=2 && 2>0 = true
421+ },
422+ }
423+
424+ for _, tt := range tests {
425+ t.Run(tt.name, func(t *testing.T) {
426+ result := keysAreSimilar(tt.oldKey, tt.newKey)
427+ assert.Equal(t, tt.expected, result, "keysAreSimilar(%q, %q)", tt.oldKey, tt.newKey)
428+ })
429+ }
430+}
431+
432+func TestMigrator_TypesAreCompatible(t *testing.T) {
433+ m := NewMigrator()
434+
435+ tests := []struct {
436+ name string
437+ oldKey string
438+ newKey string
439+ expected bool
440+ }{
441+ {
442+ name: "color to color - compatible (using real key)",
443+ oldKey: "workspace.styling.pane_mode_border_color",
444+ newKey: "workspace.styling.pane_mode_color",
445+ expected: true,
446+ },
447+ {
448+ name: "width to width - compatible (using real key)",
449+ oldKey: "workspace.styling.pane_mode_border_width",
450+ newKey: "workspace.styling.mode_border_width",
451+ expected: true,
452+ },
453+ {
454+ name: "width suffix to color type - incompatible",
455+ oldKey: "workspace.styling.some_width",
456+ newKey: "workspace.styling.pane_mode_color", // real key with string type
457+ expected: false,
458+ },
459+ {
460+ name: "color suffix to int type - incompatible",
461+ oldKey: "workspace.styling.some_color",
462+ newKey: "workspace.styling.mode_border_width", // real key with int type
463+ expected: false,
464+ },
465+ {
466+ name: "generic key to string type - compatible",
467+ oldKey: "workspace.styling.some_setting",
468+ newKey: "workspace.styling.pane_mode_color", // real key with string type
469+ expected: true,
470+ },
471+ {
472+ name: "non-existent new key - incompatible",
473+ oldKey: "section.old_key",
474+ newKey: "section.nonexistent_key_xyz",
475+ expected: false,
476+ },
477+ }
478+
479+ for _, tt := range tests {
480+ t.Run(tt.name, func(t *testing.T) {
481+ result := m.typesAreCompatible(tt.oldKey, tt.newKey)
482+ assert.Equal(t, tt.expected, result, "typesAreCompatible(%q, %q)", tt.oldKey, tt.newKey)
483+ })
484+ }
485+}
486+
487+func TestMigrator_FindDeprecatedKeys(t *testing.T) {
488+ m := NewMigrator()
489+
490+ tests := []struct {
491+ name string
492+ userKeys map[string]any
493+ defaultKeys map[string]bool
494+ expected []string
495+ }{
496+ {
497+ name: "no deprecated keys",
498+ userKeys: map[string]any{
499+ "history.max_entries": 1000,
500+ "logging.level": "info",
501+ },
502+ defaultKeys: map[string]bool{
503+ "history.max_entries": true,
504+ "logging.level": true,
505+ },
506+ expected: []string{},
507+ },
508+ {
509+ name: "one deprecated key",
510+ userKeys: map[string]any{
511+ "history.max_entries": 1000,
512+ "old.deprecated_key": "value",
513+ },
514+ defaultKeys: map[string]bool{
515+ "history.max_entries": true,
516+ },
517+ expected: []string{"old.deprecated_key"},
518+ },
519+ {
520+ name: "multiple deprecated keys",
521+ userKeys: map[string]any{
522+ "old.key1": "a",
523+ "old.key2": "b",
524+ "old.key3": "c",
525+ },
526+ defaultKeys: map[string]bool{
527+ "new.key1": true,
528+ },
529+ expected: []string{"old.key1", "old.key2", "old.key3"},
530+ },
531+ {
532+ name: "parent key exists in defaults",
533+ userKeys: map[string]any{
534+ "parent.child": "value",
535+ },
536+ defaultKeys: map[string]bool{
537+ "parent.child": true,
538+ "parent.other_child": true,
539+ },
540+ expected: []string{},
541+ },
542+ }
543+
544+ for _, tt := range tests {
545+ t.Run(tt.name, func(t *testing.T) {
546+ result := m.findDeprecatedKeys(tt.userKeys, tt.defaultKeys)
547+ assert.ElementsMatch(t, tt.expected, result)
548+ })
549+ }
550+}
551+
552+func TestMigrator_MatchRenamedKeys(t *testing.T) {
553+ m := NewMigrator()
554+
555+ tests := []struct {
556+ name string
557+ deprecated []string
558+ missing []string
559+ expectedRenames map[string]string
560+ expectedUnmatchedOld []string
561+ expectedUnmatchedNew []string
562+ }{
563+ {
564+ name: "no keys to match",
565+ deprecated: []string{},
566+ missing: []string{},
567+ expectedRenames: map[string]string{},
568+ expectedUnmatchedOld: nil,
569+ expectedUnmatchedNew: nil,
570+ },
571+ {
572+ name: "simple rename match (using real keys)",
573+ deprecated: []string{"workspace.styling.pane_mode_border_color"},
574+ missing: []string{"workspace.styling.pane_mode_color"},
575+ expectedRenames: map[string]string{
576+ "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
577+ },
578+ expectedUnmatchedOld: nil,
579+ expectedUnmatchedNew: nil,
580+ },
581+ {
582+ name: "no match possible - different types (width vs color)",
583+ deprecated: []string{"workspace.styling.pane_mode_border_width"},
584+ missing: []string{"workspace.styling.pane_mode_color"},
585+ expectedRenames: map[string]string{},
586+ expectedUnmatchedOld: []string{"workspace.styling.pane_mode_border_width"},
587+ expectedUnmatchedNew: []string{"workspace.styling.pane_mode_color"},
588+ },
589+ {
590+ name: "no match possible - different parents",
591+ deprecated: []string{"old_section.some_key"},
592+ missing: []string{"new_section.some_key"},
593+ expectedRenames: map[string]string{},
594+ expectedUnmatchedOld: []string{"old_section.some_key"},
595+ expectedUnmatchedNew: []string{"new_section.some_key"},
596+ },
597+ {
598+ name: "multiple renames (using real keys)",
599+ deprecated: []string{"workspace.styling.pane_mode_border_color", "workspace.styling.tab_mode_border_color"},
600+ missing: []string{"workspace.styling.pane_mode_color", "workspace.styling.tab_mode_color"},
601+ expectedRenames: map[string]string{
602+ "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color",
603+ "workspace.styling.tab_mode_border_color": "workspace.styling.tab_mode_color",
604+ },
605+ expectedUnmatchedOld: nil,
606+ expectedUnmatchedNew: nil,
607+ },
608+ }
609+
610+ for _, tt := range tests {
611+ t.Run(tt.name, func(t *testing.T) {
612+ renames, unmatchedOld, unmatchedNew := m.matchRenamedKeys(tt.deprecated, tt.missing)
613+ assert.Equal(t, tt.expectedRenames, renames, "renames mismatch")
614+ assert.ElementsMatch(t, tt.expectedUnmatchedOld, unmatchedOld, "unmatched old mismatch")
615+ assert.ElementsMatch(t, tt.expectedUnmatchedNew, unmatchedNew, "unmatched new mismatch")
616+ })
617+ }
618+}
619+
620+func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) {
621+ // This test would need to temporarily modify the config path
622+ // which is difficult without refactoring. Skip for now.
623+ t.Skip("requires config path mocking")
624+}
625+
626+func TestMigrator_Migrate_NoConfigFile(t *testing.T) {
627+ // This test would need to temporarily modify the config path
628+ // which is difficult without refactoring. Skip for now.
629+ t.Skip("requires config path mocking")
630+}
631+
632+func TestMigrator_GetConfigType(t *testing.T) {
633+ m := NewMigrator()
634+
635+ tests := []struct {
636+ name string
637+ configFile string
638+ expected string
639+ }{
640+ {
641+ name: "toml file",
642+ configFile: "/path/to/config.toml",
643+ expected: "toml",
644+ },
645+ {
646+ name: "yaml file",
647+ configFile: "/path/to/config.yaml",
648+ expected: "yaml",
649+ },
650+ {
651+ name: "yml file",
652+ configFile: "/path/to/config.yml",
653+ expected: "yaml",
654+ },
655+ {
656+ name: "json file",
657+ configFile: "/path/to/config.json",
658+ expected: "json",
659+ },
660+ {
661+ name: "unknown extension defaults to toml",
662+ configFile: "/path/to/config.unknown",
663+ expected: "toml",
664+ },
665+ {
666+ name: "no extension defaults to toml",
667+ configFile: "/path/to/config",
668+ expected: "toml",
669+ },
670+ }
671+
672+ for _, tt := range tests {
673+ t.Run(tt.name, func(t *testing.T) {
674+ result := m.getConfigType(tt.configFile)
675+ assert.Equal(t, tt.expected, result)
676+ })
677+ }
678+}
679+
680+func TestMigrator_GetNestedValue(t *testing.T) {
681+ m := NewMigrator()
682+
683+ config := map[string]any{
684+ "level1": map[string]any{
685+ "level2": map[string]any{
686+ "level3": "deep_value",
687+ },
688+ "simple": "simple_value",
689+ },
690+ "top": "top_value",
691+ }
692+
693+ tests := []struct {
694+ name string
695+ key string
696+ expected any
697+ }{
698+ {
699+ name: "top level key",
700+ key: "top",
701+ expected: "top_value",
702+ },
703+ {
704+ name: "nested key",
705+ key: "level1.simple",
706+ expected: "simple_value",
707+ },
708+ {
709+ name: "deeply nested key",
710+ key: "level1.level2.level3",
711+ expected: "deep_value",
712+ },
713+ {
714+ name: "non-existent key",
715+ key: "nonexistent",
716+ expected: nil,
717+ },
718+ {
719+ name: "non-existent nested key",
720+ key: "level1.nonexistent",
721+ expected: nil,
722+ },
723+ }
724+
725+ for _, tt := range tests {
726+ t.Run(tt.name, func(t *testing.T) {
727+ result := m.getNestedValue(config, tt.key)
728+ assert.Equal(t, tt.expected, result)
729+ })
730+ }
731+}
732+
733+func TestMigrator_SetNestedValue(t *testing.T) {
734+ m := NewMigrator()
735+
736+ t.Run("set top level value", func(t *testing.T) {
737+ config := make(map[string]any)
738+ m.setNestedValue(config, "top", "value")
739+ assert.Equal(t, "value", config["top"])
740+ })
741+
742+ t.Run("set nested value with existing parent", func(t *testing.T) {
743+ config := map[string]any{
744+ "parent": map[string]any{},
745+ }
746+ m.setNestedValue(config, "parent.child", "value")
747+ parent := config["parent"].(map[string]any)
748+ assert.Equal(t, "value", parent["child"])
749+ })
750+
751+ t.Run("set nested value creates intermediate maps", func(t *testing.T) {
752+ config := make(map[string]any)
753+ m.setNestedValue(config, "a.b.c", "deep_value")
754+
755+ a := config["a"].(map[string]any)
756+ b := a["b"].(map[string]any)
757+ assert.Equal(t, "deep_value", b["c"])
758+ })
759+}
760+
761+func TestMigrator_DeleteNestedKey(t *testing.T) {
762+ m := NewMigrator()
763+
764+ t.Run("delete top level key", func(t *testing.T) {
765+ config := map[string]any{
766+ "keep": "value",
767+ "delete": "value",
768+ }
769+ m.deleteNestedKey(config, "delete")
770+ assert.Contains(t, config, "keep")
771+ assert.NotContains(t, config, "delete")
772+ })
773+
774+ t.Run("delete nested key", func(t *testing.T) {
775+ config := map[string]any{
776+ "parent": map[string]any{
777+ "keep": "value",
778+ "delete": "value",
779+ },
780+ }
781+ m.deleteNestedKey(config, "parent.delete")
782+ parent := config["parent"].(map[string]any)
783+ assert.Contains(t, parent, "keep")
784+ assert.NotContains(t, parent, "delete")
785+ })
786+
787+ t.Run("delete nested key cleans up empty parents", func(t *testing.T) {
788+ config := map[string]any{
789+ "keep": "value",
790+ "parent": map[string]any{
791+ "child": map[string]any{
792+ "leaf": "value",
793+ },
794+ },
795+ }
796+ m.deleteNestedKey(config, "parent.child.leaf")
797+ // Both parent and child should be removed since they're empty
798+ assert.NotContains(t, config, "parent")
799+ assert.Contains(t, config, "keep")
800+ })
801+
802+ t.Run("delete non-existent key is no-op", func(t *testing.T) {
803+ config := map[string]any{
804+ "keep": "value",
805+ }
806+ m.deleteNestedKey(config, "nonexistent")
807+ assert.Contains(t, config, "keep")
808+ })
809+}
810+
811+func TestMigrator_ReadRawConfig(t *testing.T) {
812+ m := NewMigrator()
813+
814+ t.Run("read TOML config", func(t *testing.T) {
815+ tmpDir := t.TempDir()
816+ configFile := filepath.Join(tmpDir, "config.toml")
817+
818+ content := `
819+[section]
820+key = "value"
821+number = 42
822+`
823+ err := os.WriteFile(configFile, []byte(content), 0o644)
824+ require.NoError(t, err)
825+
826+ config, err := m.readRawConfig(configFile)
827+ require.NoError(t, err)
828+
829+ section := config["section"].(map[string]any)
830+ assert.Equal(t, "value", section["key"])
831+ assert.Equal(t, int64(42), section["number"])
832+ })
833+
834+ t.Run("read YAML config", func(t *testing.T) {
835+ tmpDir := t.TempDir()
836+ configFile := filepath.Join(tmpDir, "config.yaml")
837+
838+ content := `
839+section:
840+ key: value
841+ number: 42
842+`
843+ err := os.WriteFile(configFile, []byte(content), 0o644)
844+ require.NoError(t, err)
845+
846+ config, err := m.readRawConfig(configFile)
847+ require.NoError(t, err)
848+
849+ section := config["section"].(map[string]any)
850+ assert.Equal(t, "value", section["key"])
851+ assert.Equal(t, 42, section["number"])
852+ })
853+
854+ t.Run("read JSON config", func(t *testing.T) {
855+ tmpDir := t.TempDir()
856+ configFile := filepath.Join(tmpDir, "config.json")
857+
858+ content := `{
859+ "section": {
860+ "key": "value",
861+ "number": 42
862+ }
863+}`
864+ err := os.WriteFile(configFile, []byte(content), 0o644)
865+ require.NoError(t, err)
866+
867+ config, err := m.readRawConfig(configFile)
868+ require.NoError(t, err)
869+
870+ section := config["section"].(map[string]any)
871+ assert.Equal(t, "value", section["key"])
872+ // JSON numbers are float64
873+ assert.InDelta(t, 42.0, section["number"].(float64), 0.001)
874+ })
875+}
M internal/infrastructure/config/schema.go +21 -23
@@ -294,6 +294,9 @@ type UpdateConfig struct {
294294 // When enabled, updates are applied on browser exit.
295295 // Default: false
296296 AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"`
297+ // NotifyOnNewSettings shows a toast notification on startup when new config settings are available.
298+ // Default: true
299+ NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
297300 }
298301
299302 // WorkspaceConfig captures layout, pane, and tab behavior preferences.
@@ -313,6 +316,9 @@ type WorkspaceConfig struct {
313316 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
314317 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
315318 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
319+ // SwitchToTabOnMove controls whether moving a pane to another tab switches focus to that tab.
320+ // Default: true
321+ SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line
316322 // Popups configures default popup placement rules.
317323 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
318324 // Styling configures workspace visual appearance.
@@ -438,29 +444,21 @@ type WorkspaceStylingConfig struct {
438444 // BorderColor for focused panes (CSS color value or theme variable)
439445 BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
440446
441- // PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay)
442- 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
443- // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)
444- // Defaults to "#4A90E2" (blue) if not set
445- 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
446-
447- // TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay)
448- 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
449- // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable)
450- // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor
451- 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
452-
453- // SessionModeBorderWidth in pixels for session mode indicator border (Ctrl+O overlay)
454- SessionModeBorderWidth int `mapstructure:"session_mode_border_width" yaml:"session_mode_border_width" toml:"session_mode_border_width" json:"session_mode_border_width"` //nolint:lll // struct tags must stay on one line
455- // SessionModeBorderColor for the session mode indicator border (CSS color value or theme variable)
456- // Defaults to "#9B59B6" (purple) if not set - MUST be different from other mode colors
457- SessionModeBorderColor string `mapstructure:"session_mode_border_color" yaml:"session_mode_border_color" toml:"session_mode_border_color" json:"session_mode_border_color"` //nolint:lll // struct tags must stay on one line
458-
459- // ResizeModeBorderWidth in pixels for resize mode indicator border (Ctrl+N overlay)
460- ResizeModeBorderWidth int `mapstructure:"resize_mode_border_width" yaml:"resize_mode_border_width" toml:"resize_mode_border_width" json:"resize_mode_border_width"` //nolint:lll // struct tags must stay on one line
461- // ResizeModeBorderColor for the resize mode indicator border (CSS color value or theme variable)
462- // Defaults to "#00D4AA" (cyan/teal) if not set - MUST be different from other mode colors
463- ResizeModeBorderColor string `mapstructure:"resize_mode_border_color" yaml:"resize_mode_border_color" toml:"resize_mode_border_color" json:"resize_mode_border_color"` //nolint:lll // struct tags must stay on one line
447+ // ModeBorderWidth in pixels for all mode indicator borders (pane, tab, session, resize)
448+ ModeBorderWidth int `mapstructure:"mode_border_width" yaml:"mode_border_width" toml:"mode_border_width" json:"mode_border_width"` //nolint:lll // struct tags must stay on one line
449+
450+ // PaneModeColor for pane mode indicator (border and toaster). Defaults to "#4A90E2" (blue)
451+ PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line
452+ // TabModeColor for tab mode indicator (border and toaster). Defaults to "#FFA500" (orange)
453+ TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line
454+ // SessionModeColor for session mode indicator (border and toaster). Defaults to "#9B59B6" (purple)
455+ SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line
456+ // ResizeModeColor for resize mode indicator (border and toaster). Defaults to "#00D4AA" (teal)
457+ ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line
458+
459+ // ModeIndicatorToasterEnabled shows a toaster notification when modal modes are active.
460+ // Default: true
461+ ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line
464462
465463 // TransitionDuration in milliseconds for border animations
466464 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
A internal/infrastructure/config/schema_provider.go +903 -0
@@ -0,0 +1,903 @@
1+package config
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// Section names for grouping config keys.
10+const (
11+ SectionAppearance = "Appearance"
12+ SectionLogging = "Logging"
13+ SectionHistory = "History"
14+ SectionWorkspace = "Workspace"
15+ SectionSession = "Session"
16+ SectionOmnibox = "Omnibox"
17+ SectionContentFiltering = "Content Filtering"
18+ SectionRendering = "Rendering"
19+ SectionMedia = "Media"
20+ SectionUpdate = "Update"
21+ SectionDmenu = "Dmenu"
22+ SectionDebug = "Debug"
23+ SectionPerformance = "Performance"
24+ SectionRuntime = "Runtime"
25+ SectionDatabase = "Database"
26+ SectionSearch = "Search"
27+)
28+
29+// SchemaProvider implements port.ConfigSchemaProvider.
30+type SchemaProvider struct{}
31+
32+// NewSchemaProvider creates a new SchemaProvider.
33+func NewSchemaProvider() *SchemaProvider {
34+ return &SchemaProvider{}
35+}
36+
37+// GetSchema returns all configuration keys with their metadata.
38+func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo {
39+ defaults := DefaultConfig()
40+
41+ keys := make([]entity.ConfigKeyInfo, 0, 100)
42+
43+ // Appearance section
44+ keys = append(keys, p.getAppearanceKeys(defaults)...)
45+
46+ // Logging section
47+ keys = append(keys, p.getLoggingKeys(defaults)...)
48+
49+ // History section
50+ keys = append(keys, p.getHistoryKeys(defaults)...)
51+
52+ // Search section
53+ keys = append(keys, p.getSearchKeys(defaults)...)
54+
55+ // Dmenu section
56+ keys = append(keys, p.getDmenuKeys(defaults)...)
57+
58+ // Workspace section
59+ keys = append(keys, p.getWorkspaceKeys(defaults)...)
60+
61+ // Session section
62+ keys = append(keys, p.getSessionKeys(defaults)...)
63+
64+ // Omnibox section
65+ keys = append(keys, p.getOmniboxKeys(defaults)...)
66+
67+ // Content Filtering section
68+ keys = append(keys, p.getContentFilteringKeys(defaults)...)
69+
70+ // Rendering section
71+ keys = append(keys, p.getRenderingKeys(defaults)...)
72+
73+ // Media section
74+ keys = append(keys, p.getMediaKeys(defaults)...)
75+
76+ // Update section
77+ keys = append(keys, p.getUpdateKeys(defaults)...)
78+
79+ // Debug section
80+ keys = append(keys, p.getDebugKeys(defaults)...)
81+
82+ // Performance section
83+ keys = append(keys, p.getPerformanceKeys(defaults)...)
84+
85+ // Runtime section
86+ keys = append(keys, p.getRuntimeKeys(defaults)...)
87+
88+ // Database section
89+ keys = append(keys, p.getDatabaseKeys()...)
90+
91+ return keys
92+}
93+
94+func (*SchemaProvider) getAppearanceKeys(defaults *Config) []entity.ConfigKeyInfo {
95+ return []entity.ConfigKeyInfo{
96+ {
97+ Key: "appearance.color_scheme",
98+ Type: "string",
99+ Default: defaults.Appearance.ColorScheme,
100+ Description: "Theme preference (follows system by default)",
101+ Values: []string{"default", "prefer-dark", "prefer-light"},
102+ Section: SectionAppearance,
103+ },
104+ {
105+ Key: "appearance.default_font_size",
106+ Type: "int",
107+ Default: fmt.Sprintf("%d", defaults.Appearance.DefaultFontSize),
108+ Description: "Default font size in CSS pixels",
109+ Range: "1-72",
110+ Section: SectionAppearance,
111+ },
112+ {
113+ Key: "appearance.sans_font",
114+ Type: "string",
115+ Default: defaults.Appearance.SansFont,
116+ Description: "Sans-serif font family for web pages",
117+ Section: SectionAppearance,
118+ },
119+ {
120+ Key: "appearance.serif_font",
121+ Type: "string",
122+ Default: defaults.Appearance.SerifFont,
123+ Description: "Serif font family for web pages",
124+ Section: SectionAppearance,
125+ },
126+ {
127+ Key: "appearance.monospace_font",
128+ Type: "string",
129+ Default: defaults.Appearance.MonospaceFont,
130+ Description: "Monospace font family for code",
131+ Section: SectionAppearance,
132+ },
133+ {
134+ Key: "default_webpage_zoom",
135+ Type: "float64",
136+ Default: fmt.Sprintf("%.1f", defaults.DefaultWebpageZoom),
137+ Description: "Default zoom level for web pages (1.0 = 100%%)",
138+ Range: "0.1-5.0",
139+ Section: SectionAppearance,
140+ },
141+ {
142+ Key: "default_ui_scale",
143+ Type: "float64",
144+ Default: fmt.Sprintf("%.1f", defaults.DefaultUIScale),
145+ Description: "UI scale multiplier for GTK widgets",
146+ Range: "0.5-3.0",
147+ Section: SectionAppearance,
148+ },
149+ {
150+ Key: "appearance.light_palette.*",
151+ Type: "string",
152+ Default: "(hex colors)",
153+ Description: "Light theme color palette (background, surface, surface_variant, text, muted, accent, border)",
154+ Section: SectionAppearance,
155+ },
156+ {
157+ Key: "appearance.dark_palette.*",
158+ Type: "string",
159+ Default: "(hex colors)",
160+ Description: "Dark theme color palette (background, surface, surface_variant, text, muted, accent, border)",
161+ Section: SectionAppearance,
162+ },
163+ }
164+}
165+
166+func (*SchemaProvider) getLoggingKeys(defaults *Config) []entity.ConfigKeyInfo {
167+ return []entity.ConfigKeyInfo{
168+ {
169+ Key: "logging.level",
170+ Type: "string",
171+ Default: defaults.Logging.Level,
172+ Description: "Log verbosity level",
173+ Values: []string{"trace", "debug", "info", "warn", "error", "fatal"},
174+ Section: SectionLogging,
175+ },
176+ {
177+ Key: "logging.format",
178+ Type: "string",
179+ Default: defaults.Logging.Format,
180+ Description: "Log output format",
181+ Values: []string{"text", "json", "console"},
182+ Section: SectionLogging,
183+ },
184+ {
185+ Key: "logging.max_age",
186+ Type: "int",
187+ Default: fmt.Sprintf("%d", defaults.Logging.MaxAge),
188+ Description: "Maximum age of log files in days",
189+ Range: ">=0",
190+ Section: SectionLogging,
191+ },
192+ {
193+ Key: "logging.log_dir",
194+ Type: "string",
195+ Default: defaults.Logging.LogDir,
196+ Description: "Directory for log files",
197+ Section: SectionLogging,
198+ },
199+ {
200+ Key: "logging.enable_file_log",
201+ Type: "bool",
202+ Default: fmt.Sprintf("%t", defaults.Logging.EnableFileLog),
203+ Description: "Enable logging to file",
204+ Section: SectionLogging,
205+ },
206+ {
207+ Key: "logging.capture_console",
208+ Type: "bool",
209+ Default: fmt.Sprintf("%t", defaults.Logging.CaptureConsole),
210+ Description: "Capture browser console messages to log",
211+ Section: SectionLogging,
212+ },
213+ }
214+}
215+
216+func (*SchemaProvider) getHistoryKeys(defaults *Config) []entity.ConfigKeyInfo {
217+ return []entity.ConfigKeyInfo{
218+ {
219+ Key: "history.max_entries",
220+ Type: "int",
221+ Default: fmt.Sprintf("%d", defaults.History.MaxEntries),
222+ Description: "Maximum number of history entries to keep",
223+ Range: ">=0",
224+ Section: SectionHistory,
225+ },
226+ {
227+ Key: "history.retention_period_days",
228+ Type: "int",
229+ Default: fmt.Sprintf("%d", defaults.History.RetentionPeriodDays),
230+ Description: "Number of days to retain history",
231+ Range: ">=0",
232+ Section: SectionHistory,
233+ },
234+ {
235+ Key: "history.cleanup_interval_days",
236+ Type: "int",
237+ Default: fmt.Sprintf("%d", defaults.History.CleanupIntervalDays),
238+ Description: "Days between automatic history cleanup",
239+ Range: ">=0",
240+ Section: SectionHistory,
241+ },
242+ }
243+}
244+
245+func (*SchemaProvider) getSearchKeys(defaults *Config) []entity.ConfigKeyInfo {
246+ return []entity.ConfigKeyInfo{
247+ {
248+ Key: "default_search_engine",
249+ Type: "string",
250+ Default: defaults.DefaultSearchEngine,
251+ Description: "Default search engine URL (must contain %s placeholder)",
252+ Section: SectionSearch,
253+ },
254+ {
255+ Key: "search_shortcuts.<key>",
256+ Type: "object",
257+ Default: "(see defaults)",
258+ Description: "Search shortcuts map with url and description fields",
259+ Section: SectionSearch,
260+ },
261+ }
262+}
263+
264+func (*SchemaProvider) getDmenuKeys(defaults *Config) []entity.ConfigKeyInfo {
265+ return []entity.ConfigKeyInfo{
266+ {
267+ Key: "dmenu.max_history_items",
268+ Type: "int",
269+ Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryItems),
270+ Description: "Maximum history items shown in dmenu",
271+ Range: ">=0",
272+ Section: SectionDmenu,
273+ },
274+ {
275+ Key: "dmenu.show_visit_count",
276+ Type: "bool",
277+ Default: fmt.Sprintf("%t", defaults.Dmenu.ShowVisitCount),
278+ Description: "Show visit count in dmenu entries",
279+ Section: SectionDmenu,
280+ },
281+ {
282+ Key: "dmenu.show_last_visited",
283+ Type: "bool",
284+ Default: fmt.Sprintf("%t", defaults.Dmenu.ShowLastVisited),
285+ Description: "Show last visited date in dmenu entries",
286+ Section: SectionDmenu,
287+ },
288+ {
289+ Key: "dmenu.history_prefix",
290+ Type: "string",
291+ Default: defaults.Dmenu.HistoryPrefix,
292+ Description: "Prefix for history entries in dmenu",
293+ Section: SectionDmenu,
294+ },
295+ {
296+ Key: "dmenu.shortcut_prefix",
297+ Type: "string",
298+ Default: defaults.Dmenu.ShortcutPrefix,
299+ Description: "Prefix for search shortcuts in dmenu",
300+ Section: SectionDmenu,
301+ },
302+ {
303+ Key: "dmenu.url_prefix",
304+ Type: "string",
305+ Default: defaults.Dmenu.URLPrefix,
306+ Description: "Prefix for URL entries in dmenu",
307+ Section: SectionDmenu,
308+ },
309+ {
310+ Key: "dmenu.date_format",
311+ Type: "string",
312+ Default: defaults.Dmenu.DateFormat,
313+ Description: "Go time format for dates in dmenu",
314+ Section: SectionDmenu,
315+ },
316+ {
317+ Key: "dmenu.sort_by_visit_count",
318+ Type: "bool",
319+ Default: fmt.Sprintf("%t", defaults.Dmenu.SortByVisitCount),
320+ Description: "Sort dmenu entries by visit count",
321+ Section: SectionDmenu,
322+ },
323+ }
324+}
325+
326+func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo {
327+ return []entity.ConfigKeyInfo{
328+ {
329+ Key: "workspace.new_pane_url",
330+ Type: "string",
331+ Default: defaults.Workspace.NewPaneURL,
332+ Description: "URL loaded when creating a new pane",
333+ Section: SectionWorkspace,
334+ },
335+ {
336+ Key: "workspace.tab_bar_position",
337+ Type: "string",
338+ Default: defaults.Workspace.TabBarPosition,
339+ Description: "Position of the tab bar",
340+ Values: []string{"top", "bottom"},
341+ Section: SectionWorkspace,
342+ },
343+ {
344+ Key: "workspace.hide_tab_bar_when_single_tab",
345+ Type: "bool",
346+ Default: fmt.Sprintf("%t", defaults.Workspace.HideTabBarWhenSingleTab),
347+ Description: "Hide tab bar when only one tab exists",
348+ Section: SectionWorkspace,
349+ },
350+ {
351+ Key: "workspace.switch_to_tab_on_move",
352+ Type: "bool",
353+ Default: fmt.Sprintf("%t", defaults.Workspace.SwitchToTabOnMove),
354+ Description: "Switch focus to tab when moving pane to it",
355+ Section: SectionWorkspace,
356+ },
357+ // Pane mode
358+ {
359+ Key: "workspace.pane_mode.activation_shortcut",
360+ Type: "string",
361+ Default: defaults.Workspace.PaneMode.ActivationShortcut,
362+ Description: "Shortcut to enter pane mode",
363+ Section: SectionWorkspace,
364+ },
365+ {
366+ Key: "workspace.pane_mode.timeout_ms",
367+ Type: "int",
368+ Default: fmt.Sprintf("%d", defaults.Workspace.PaneMode.TimeoutMilliseconds),
369+ Description: "Pane mode timeout in milliseconds (0 = no timeout)",
370+ Range: ">=0",
371+ Section: SectionWorkspace,
372+ },
373+ {
374+ Key: "workspace.pane_mode.actions.<action>",
375+ Type: "[]string",
376+ Default: "(see defaults)",
377+ Description: "Key bindings for pane mode actions",
378+ Section: SectionWorkspace,
379+ },
380+ // Tab mode
381+ {
382+ Key: "workspace.tab_mode.activation_shortcut",
383+ Type: "string",
384+ Default: defaults.Workspace.TabMode.ActivationShortcut,
385+ Description: "Shortcut to enter tab mode",
386+ Section: SectionWorkspace,
387+ },
388+ {
389+ Key: "workspace.tab_mode.timeout_ms",
390+ Type: "int",
391+ Default: fmt.Sprintf("%d", defaults.Workspace.TabMode.TimeoutMilliseconds),
392+ Description: "Tab mode timeout in milliseconds (0 = no timeout)",
393+ Range: ">=0",
394+ Section: SectionWorkspace,
395+ },
396+ {
397+ Key: "workspace.tab_mode.actions.<action>",
398+ Type: "[]string",
399+ Default: "(see defaults)",
400+ Description: "Key bindings for tab mode actions",
401+ Section: SectionWorkspace,
402+ },
403+ // Resize mode
404+ {
405+ Key: "workspace.resize_mode.activation_shortcut",
406+ Type: "string",
407+ Default: defaults.Workspace.ResizeMode.ActivationShortcut,
408+ Description: "Shortcut to enter resize mode",
409+ Section: SectionWorkspace,
410+ },
411+ {
412+ Key: "workspace.resize_mode.timeout_ms",
413+ Type: "int",
414+ Default: fmt.Sprintf("%d", defaults.Workspace.ResizeMode.TimeoutMilliseconds),
415+ Description: "Resize mode timeout in milliseconds (0 = no timeout)",
416+ Range: ">=0",
417+ Section: SectionWorkspace,
418+ },
419+ {
420+ Key: "workspace.resize_mode.step_percent",
421+ Type: "float64",
422+ Default: fmt.Sprintf("%.1f", defaults.Workspace.ResizeMode.StepPercent),
423+ Description: "Percentage to resize panes per step",
424+ Section: SectionWorkspace,
425+ },
426+ {
427+ Key: "workspace.resize_mode.min_pane_percent",
428+ Type: "float64",
429+ Default: fmt.Sprintf("%.1f", defaults.Workspace.ResizeMode.MinPanePercent),
430+ Description: "Minimum pane size as percentage",
431+ Section: SectionWorkspace,
432+ },
433+ {
434+ Key: "workspace.resize_mode.actions.<action>",
435+ Type: "[]string",
436+ Default: "(see defaults)",
437+ Description: "Key bindings for resize mode actions",
438+ Section: SectionWorkspace,
439+ },
440+ // Global shortcuts
441+ {
442+ Key: "workspace.shortcuts.close_pane",
443+ Type: "string",
444+ Default: defaults.Workspace.Shortcuts.ClosePane,
445+ Description: "Global shortcut to close current pane",
446+ Section: SectionWorkspace,
447+ },
448+ {
449+ Key: "workspace.shortcuts.next_tab",
450+ Type: "string",
451+ Default: defaults.Workspace.Shortcuts.NextTab,
452+ Description: "Global shortcut to switch to next tab",
453+ Section: SectionWorkspace,
454+ },
455+ {
456+ Key: "workspace.shortcuts.previous_tab",
457+ Type: "string",
458+ Default: defaults.Workspace.Shortcuts.PreviousTab,
459+ Description: "Global shortcut to switch to previous tab",
460+ Section: SectionWorkspace,
461+ },
462+ // Popups
463+ {
464+ Key: "workspace.popups.behavior",
465+ Type: "string",
466+ Default: string(defaults.Workspace.Popups.Behavior),
467+ Description: "How to handle JavaScript popup windows",
468+ Values: []string{"split", "stacked", "tabbed", "windowed"},
469+ Section: SectionWorkspace,
470+ },
471+ {
472+ Key: "workspace.popups.placement",
473+ Type: "string",
474+ Default: defaults.Workspace.Popups.Placement,
475+ Description: "Placement direction for split popups",
476+ Values: []string{"right", "left", "top", "bottom"},
477+ Section: SectionWorkspace,
478+ },
479+ {
480+ Key: "workspace.popups.blank_target_behavior",
481+ Type: "string",
482+ Default: defaults.Workspace.Popups.BlankTargetBehavior,
483+ Description: "How to handle target=\"_blank\" links",
484+ Values: []string{"split", "stacked", "tabbed"},
485+ Section: SectionWorkspace,
486+ },
487+ {
488+ Key: "workspace.popups.open_in_new_pane",
489+ Type: "bool",
490+ Default: fmt.Sprintf("%t", defaults.Workspace.Popups.OpenInNewPane),
491+ Description: "Open popups in workspace instead of blocking",
492+ Section: SectionWorkspace,
493+ },
494+ {
495+ Key: "workspace.popups.follow_pane_context",
496+ Type: "bool",
497+ Default: fmt.Sprintf("%t", defaults.Workspace.Popups.FollowPaneContext),
498+ Description: "Popup placement follows parent pane context",
499+ Section: SectionWorkspace,
500+ },
501+ {
502+ Key: "workspace.popups.enable_smart_detection",
503+ Type: "bool",
504+ Default: fmt.Sprintf("%t", defaults.Workspace.Popups.EnableSmartDetection),
505+ Description: "Use window properties to detect popup intent",
506+ Section: SectionWorkspace,
507+ },
508+ {
509+ Key: "workspace.popups.oauth_auto_close",
510+ Type: "bool",
511+ Default: fmt.Sprintf("%t", defaults.Workspace.Popups.OAuthAutoClose),
512+ Description: "Auto-close OAuth popups after success",
513+ Section: SectionWorkspace,
514+ },
515+ // Styling
516+ {
517+ Key: "workspace.styling.border_width",
518+ Type: "int",
519+ Default: fmt.Sprintf("%d", defaults.Workspace.Styling.BorderWidth),
520+ Description: "Active pane border width in pixels",
521+ Range: ">=0",
522+ Section: SectionWorkspace,
523+ },
524+ {
525+ Key: "workspace.styling.border_color",
526+ Type: "string",
527+ Default: defaults.Workspace.Styling.BorderColor,
528+ Description: "Active pane border color (CSS or @theme variable)",
529+ Section: SectionWorkspace,
530+ },
531+ {
532+ Key: "workspace.styling.mode_border_width",
533+ Type: "int",
534+ Default: fmt.Sprintf("%d", defaults.Workspace.Styling.ModeBorderWidth),
535+ Description: "Border width for modal mode indicators",
536+ Range: ">=0",
537+ Section: SectionWorkspace,
538+ },
539+ {
540+ Key: "workspace.styling.pane_mode_color",
541+ Type: "string",
542+ Default: defaults.Workspace.Styling.PaneModeColor,
543+ Description: "Color for pane mode indicator",
544+ Section: SectionWorkspace,
545+ },
546+ {
547+ Key: "workspace.styling.tab_mode_color",
548+ Type: "string",
549+ Default: defaults.Workspace.Styling.TabModeColor,
550+ Description: "Color for tab mode indicator",
551+ Section: SectionWorkspace,
552+ },
553+ {
554+ Key: "workspace.styling.session_mode_color",
555+ Type: "string",
556+ Default: defaults.Workspace.Styling.SessionModeColor,
557+ Description: "Color for session mode indicator",
558+ Section: SectionWorkspace,
559+ },
560+ {
561+ Key: "workspace.styling.resize_mode_color",
562+ Type: "string",
563+ Default: defaults.Workspace.Styling.ResizeModeColor,
564+ Description: "Color for resize mode indicator",
565+ Section: SectionWorkspace,
566+ },
567+ {
568+ Key: "workspace.styling.mode_indicator_toaster_enabled",
569+ Type: "bool",
570+ Default: fmt.Sprintf("%t", defaults.Workspace.Styling.ModeIndicatorToasterEnabled),
571+ Description: "Show toaster notification for modal modes",
572+ Section: SectionWorkspace,
573+ },
574+ {
575+ Key: "workspace.styling.transition_duration",
576+ Type: "int",
577+ Default: fmt.Sprintf("%d", defaults.Workspace.Styling.TransitionDuration),
578+ Description: "Border animation duration in milliseconds",
579+ Range: ">=0",
580+ Section: SectionWorkspace,
581+ },
582+ }
583+}
584+
585+func (*SchemaProvider) getSessionKeys(defaults *Config) []entity.ConfigKeyInfo {
586+ return []entity.ConfigKeyInfo{
587+ {
588+ Key: "session.auto_restore",
589+ Type: "bool",
590+ Default: fmt.Sprintf("%t", defaults.Session.AutoRestore),
591+ Description: "Automatically restore last session on startup",
592+ Section: SectionSession,
593+ },
594+ {
595+ Key: "session.snapshot_interval_ms",
596+ Type: "int",
597+ Default: fmt.Sprintf("%d", defaults.Session.SnapshotIntervalMs),
598+ Description: "Minimum interval between session snapshots",
599+ Range: ">=0",
600+ Section: SectionSession,
601+ },
602+ {
603+ Key: "session.max_exited_sessions",
604+ Type: "int",
605+ Default: fmt.Sprintf("%d", defaults.Session.MaxExitedSessions),
606+ Description: "Maximum number of exited sessions to keep",
607+ Range: ">=0",
608+ Section: SectionSession,
609+ },
610+ {
611+ Key: "session.max_exited_session_age_days",
612+ Type: "int",
613+ Default: fmt.Sprintf("%d", defaults.Session.MaxExitedSessionAgeDays),
614+ Description: "Maximum age of exited sessions in days",
615+ Range: ">=0",
616+ Section: SectionSession,
617+ },
618+ {
619+ Key: "session.session_mode.activation_shortcut",
620+ Type: "string",
621+ Default: defaults.Session.SessionMode.ActivationShortcut,
622+ Description: "Shortcut to enter session mode",
623+ Section: SectionSession,
624+ },
625+ {
626+ Key: "session.session_mode.timeout_ms",
627+ Type: "int",
628+ Default: fmt.Sprintf("%d", defaults.Session.SessionMode.TimeoutMilliseconds),
629+ Description: "Session mode timeout in milliseconds",
630+ Range: ">=0",
631+ Section: SectionSession,
632+ },
633+ {
634+ Key: "session.session_mode.actions.<action>",
635+ Type: "[]string",
636+ Default: "(see defaults)",
637+ Description: "Key bindings for session mode actions",
638+ Section: SectionSession,
639+ },
640+ }
641+}
642+
643+func (*SchemaProvider) getOmniboxKeys(defaults *Config) []entity.ConfigKeyInfo {
644+ return []entity.ConfigKeyInfo{
645+ {
646+ Key: "omnibox.initial_behavior",
647+ Type: "string",
648+ Default: defaults.Omnibox.InitialBehavior,
649+ Description: "What to show when omnibox opens with empty input",
650+ Values: []string{"recent", "most_visited", "none"},
651+ Section: SectionOmnibox,
652+ },
653+ {
654+ Key: "omnibox.auto_open_on_new_pane",
655+ Type: "bool",
656+ Default: fmt.Sprintf("%t", defaults.Omnibox.AutoOpenOnNewPane),
657+ Description: "Auto-open omnibox when creating new pane",
658+ Section: SectionOmnibox,
659+ },
660+ }
661+}
662+
663+func (*SchemaProvider) getContentFilteringKeys(defaults *Config) []entity.ConfigKeyInfo {
664+ return []entity.ConfigKeyInfo{
665+ {
666+ Key: "content_filtering.enabled",
667+ Type: "bool",
668+ Default: fmt.Sprintf("%t", defaults.ContentFiltering.Enabled),
669+ Description: "Enable ad blocking and content filtering",
670+ Section: SectionContentFiltering,
671+ },
672+ {
673+ Key: "content_filtering.auto_update",
674+ Type: "bool",
675+ Default: fmt.Sprintf("%t", defaults.ContentFiltering.AutoUpdate),
676+ Description: "Auto-update filter lists from GitHub",
677+ Section: SectionContentFiltering,
678+ },
679+ }
680+}
681+
682+func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo {
683+ return []entity.ConfigKeyInfo{
684+ {
685+ Key: "rendering.mode",
686+ Type: "string",
687+ Default: string(defaults.Rendering.Mode),
688+ Description: "GPU/CPU rendering selection for WebKit",
689+ Values: []string{"auto", "gpu", "cpu"},
690+ Section: SectionRendering,
691+ },
692+ {
693+ Key: "rendering.gsk_renderer",
694+ Type: "string",
695+ Default: string(defaults.Rendering.GSKRenderer),
696+ Description: "GTK Scene Kit renderer selection",
697+ Values: []string{"auto", "opengl", "vulkan", "cairo"},
698+ Section: SectionRendering,
699+ },
700+ {
701+ Key: "rendering.disable_dmabuf_renderer",
702+ Type: "bool",
703+ Default: fmt.Sprintf("%t", defaults.Rendering.DisableDMABufRenderer),
704+ Description: "Disable DMA-BUF accelerated renderer",
705+ Section: SectionRendering,
706+ },
707+ {
708+ Key: "rendering.force_compositing_mode",
709+ Type: "bool",
710+ Default: fmt.Sprintf("%t", defaults.Rendering.ForceCompositingMode),
711+ Description: "Force accelerated compositing",
712+ Section: SectionRendering,
713+ },
714+ {
715+ Key: "rendering.disable_compositing_mode",
716+ Type: "bool",
717+ Default: fmt.Sprintf("%t", defaults.Rendering.DisableCompositingMode),
718+ Description: "Disable accelerated compositing",
719+ Section: SectionRendering,
720+ },
721+ {
722+ Key: "rendering.disable_mipmaps",
723+ Type: "bool",
724+ Default: fmt.Sprintf("%t", defaults.Rendering.DisableMipmaps),
725+ Description: "Disable mipmap generation",
726+ Section: SectionRendering,
727+ },
728+ {
729+ Key: "rendering.prefer_gl",
730+ Type: "bool",
731+ Default: fmt.Sprintf("%t", defaults.Rendering.PreferGL),
732+ Description: "Force OpenGL over GLES",
733+ Section: SectionRendering,
734+ },
735+ {
736+ Key: "rendering.draw_compositing_indicators",
737+ Type: "bool",
738+ Default: fmt.Sprintf("%t", defaults.Rendering.DrawCompositingIndicators),
739+ Description: "Show compositing layer borders",
740+ Section: SectionRendering,
741+ },
742+ {
743+ Key: "rendering.show_fps",
744+ Type: "bool",
745+ Default: fmt.Sprintf("%t", defaults.Rendering.ShowFPS),
746+ Description: "Display WebKit FPS counter",
747+ Section: SectionRendering,
748+ },
749+ {
750+ Key: "rendering.sample_memory",
751+ Type: "bool",
752+ Default: fmt.Sprintf("%t", defaults.Rendering.SampleMemory),
753+ Description: "Enable memory sampling",
754+ Section: SectionRendering,
755+ },
756+ {
757+ Key: "rendering.debug_frames",
758+ Type: "bool",
759+ Default: fmt.Sprintf("%t", defaults.Rendering.DebugFrames),
760+ Description: "Enable frame timing debug output",
761+ Section: SectionRendering,
762+ },
763+ }
764+}
765+
766+func (*SchemaProvider) getMediaKeys(defaults *Config) []entity.ConfigKeyInfo {
767+ return []entity.ConfigKeyInfo{
768+ {
769+ Key: "media.hardware_decoding",
770+ Type: "string",
771+ Default: string(defaults.Media.HardwareDecodingMode),
772+ Description: "Hardware vs software video decoding",
773+ Values: []string{"auto", "force", "disable"},
774+ Section: SectionMedia,
775+ },
776+ {
777+ Key: "media.gl_rendering_mode",
778+ Type: "string",
779+ Default: string(defaults.Media.GLRenderingMode),
780+ Description: "OpenGL API selection for video rendering",
781+ Values: []string{"auto", "gles2", "gl3", "none"},
782+ Section: SectionMedia,
783+ },
784+ {
785+ Key: "media.prefer_av1",
786+ Type: "bool",
787+ Default: fmt.Sprintf("%t", defaults.Media.PreferAV1),
788+ Description: "Prioritize AV1 codec when available",
789+ Section: SectionMedia,
790+ },
791+ {
792+ Key: "media.show_diagnostics",
793+ Type: "bool",
794+ Default: fmt.Sprintf("%t", defaults.Media.ShowDiagnosticsOnStartup),
795+ Description: "Show media capability warnings at startup",
796+ Section: SectionMedia,
797+ },
798+ {
799+ Key: "media.force_vsync",
800+ Type: "bool",
801+ Default: fmt.Sprintf("%t", defaults.Media.ForceVSync),
802+ Description: "Force vertical sync for video playback",
803+ Section: SectionMedia,
804+ },
805+ {
806+ Key: "media.gstreamer_debug_level",
807+ Type: "int",
808+ Default: fmt.Sprintf("%d", defaults.Media.GStreamerDebugLevel),
809+ Description: "GStreamer debug verbosity (0=off, 1-5)",
810+ Range: "0-5",
811+ Section: SectionMedia,
812+ },
813+ }
814+}
815+
816+func (*SchemaProvider) getUpdateKeys(defaults *Config) []entity.ConfigKeyInfo {
817+ return []entity.ConfigKeyInfo{
818+ {
819+ Key: "update.enable_on_startup",
820+ Type: "bool",
821+ Default: fmt.Sprintf("%t", defaults.Update.EnableOnStartup),
822+ Description: "Check for updates on browser startup",
823+ Section: SectionUpdate,
824+ },
825+ {
826+ Key: "update.auto_download",
827+ Type: "bool",
828+ Default: fmt.Sprintf("%t", defaults.Update.AutoDownload),
829+ Description: "Auto-download updates in background",
830+ Section: SectionUpdate,
831+ },
832+ {
833+ Key: "update.notify_on_new_settings",
834+ Type: "bool",
835+ Default: fmt.Sprintf("%t", defaults.Update.NotifyOnNewSettings),
836+ Description: "Show toast when new config settings available",
837+ Section: SectionUpdate,
838+ },
839+ }
840+}
841+
842+func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo {
843+ return []entity.ConfigKeyInfo{
844+ {
845+ Key: "debug.enable_devtools",
846+ Type: "bool",
847+ Default: fmt.Sprintf("%t", defaults.Debug.EnableDevTools),
848+ Description: "Enable browser developer tools (F12)",
849+ Section: SectionDebug,
850+ },
851+ }
852+}
853+
854+func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyInfo {
855+ return []entity.ConfigKeyInfo{
856+ {
857+ Key: "performance.zoom_cache_size",
858+ Type: "int",
859+ Default: fmt.Sprintf("%d", defaults.Performance.ZoomCacheSize),
860+ Description: "Number of domain zoom levels to cache",
861+ Range: ">=0",
862+ Section: SectionPerformance,
863+ },
864+ {
865+ Key: "performance.webview_pool_prewarm_count",
866+ Type: "int",
867+ Default: fmt.Sprintf("%d", defaults.Performance.WebViewPoolPrewarmCount),
868+ Description: "WebViews to pre-create at startup",
869+ Range: ">=0",
870+ Section: SectionPerformance,
871+ },
872+ }
873+}
874+
875+func (*SchemaProvider) getRuntimeKeys(_ *Config) []entity.ConfigKeyInfo {
876+ return []entity.ConfigKeyInfo{
877+ {
878+ Key: "runtime.prefix",
879+ Type: "string",
880+ Default: "(empty)",
881+ Description: "Custom runtime prefix for WebKitGTK/GTK",
882+ Section: SectionRuntime,
883+ },
884+ }
885+}
886+
887+func (*SchemaProvider) getDatabaseKeys() []entity.ConfigKeyInfo {
888+ // Get actual default path for display
889+ dbPath := "$XDG_DATA_HOME/" + appName + "/" + databaseName
890+ if path, err := GetDatabaseFile(); err == nil {
891+ dbPath = path
892+ }
893+
894+ return []entity.ConfigKeyInfo{
895+ {
896+ Key: "database.path",
897+ Type: "string",
898+ Default: dbPath,
899+ Description: "Path to SQLite database file",
900+ Section: SectionDatabase,
901+ },
902+ }
903+}
M internal/infrastructure/config/validation.go +2 -5
@@ -146,11 +146,8 @@ func validateWorkspaceStyling(config *Config) []string {
146146 if config.Workspace.Styling.BorderWidth < 0 {
147147 validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
148148 }
149- if config.Workspace.Styling.PaneModeBorderWidth < 0 {
150- validationErrors = append(validationErrors, "workspace.styling.pane_mode_border_width must be non-negative")
151- }
152- if config.Workspace.Styling.TabModeBorderWidth < 0 {
153- validationErrors = append(validationErrors, "workspace.styling.tab_mode_border_width must be non-negative")
149+ if config.Workspace.Styling.ModeBorderWidth < 0 {
150+ validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative")
154151 }
155152 if config.Workspace.Styling.TransitionDuration < 0 {
156153 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
M internal/infrastructure/favicon/cache.go +27 -0
@@ -172,6 +172,33 @@ func (c *Cache) WritePNG(domain string, pngData []byte) {
172172 }
173173 }
174174
175+// WritePNGSized writes raw PNG data to disk for a domain at a specific size.
176+// Used for internal favicons that are pre-sized (e.g., embedded app logo).
177+func (c *Cache) WritePNGSized(domain string, pngData []byte, size int) {
178+ if c.diskDir == "" || len(pngData) == 0 || domain == "" {
179+ return
180+ }
181+
182+ // Ensure directory exists
183+ if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil {
184+ return
185+ }
186+
187+ filename := domainurl.SanitizeDomainForPNGSized(domain, size)
188+ finalPath := filepath.Join(c.diskDir, filename)
189+ tempPath := finalPath + ".tmp"
190+
191+ // Write to temp file
192+ if err := os.WriteFile(tempPath, pngData, diskCacheFilePerm); err != nil {
193+ return
194+ }
195+
196+ // Atomic rename
197+ if err := os.Rename(tempPath, finalPath); err != nil {
198+ _ = os.Remove(tempPath)
199+ }
200+}
201+
175202 // HasOnDisk checks if a favicon exists on disk for the given domain.
176203 func (c *Cache) HasOnDisk(domain string) bool {
177204 path := c.DiskPath(domain)
M internal/infrastructure/favicon/service.go +36 -0
@@ -3,10 +3,18 @@ package favicon
33 import (
44 "context"
55 "fmt"
6+ "strings"
67
8+ "github.com/bnema/dumber/assets"
79 "github.com/bnema/dumber/internal/logging"
810 )
911
12+// InternalScheme is the URL scheme used for internal browser pages.
13+const InternalScheme = "dumb://"
14+
15+// InternalDomain is the pseudo-domain used for caching internal page favicons.
16+const InternalDomain = "dumb"
17+
1018 // Service implements the domain FaviconService interface.
1119 // It coordinates between the cache and fetcher components.
1220 type Service struct {
@@ -30,6 +38,11 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
3038 return nil, nil
3139 }
3240
41+ // Internal domain returns the app logo
42+ if domain == InternalDomain {
43+ return assets.LogoSVG, nil
44+ }
45+
3346 // Check cache first (memory + disk)
3447 if data, ok := s.cache.Get(domain); ok {
3548 return data, nil
@@ -49,6 +62,17 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
4962 return data, nil
5063 }
5164
65+// IsInternalURL checks if a URL uses the internal dumb:// scheme.
66+func IsInternalURL(url string) bool {
67+ return strings.HasPrefix(url, InternalScheme)
68+}
69+
70+// GetLogoBytes returns the raw SVG bytes of the app logo.
71+// Used for internal pages that need the logo as a favicon.
72+func GetLogoBytes() []byte {
73+ return assets.LogoSVG
74+}
75+
5276 // GetCached returns favicon bytes only if already cached (no external fetch).
5377 func (s *Service) GetCached(domain string) ([]byte, bool) {
5478 return s.cache.Get(domain)
@@ -149,3 +173,15 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
149173
150174 return ResizePNG(srcPath, dstPath, size)
151175 }
176+
177+// EnsureInternalFaviconPNG ensures the internal app logo PNG exists on disk.
178+// Returns the path to the sized PNG file. Used by dmenu to get a filesystem
179+// path to the internal favicon for rofi/fuzzel display.
180+func (s *Service) EnsureInternalFaviconPNG(pngData []byte, size int) string {
181+ if s.HasPNGSizedOnDisk(InternalDomain, size) {
182+ return s.DiskPathPNGSized(InternalDomain, size)
183+ }
184+
185+ s.cache.WritePNGSized(InternalDomain, pngData, size)
186+ return s.DiskPathPNGSized(InternalDomain, size)
187+}
M internal/infrastructure/persistence/sqlite/connection.go +23 -6
@@ -33,6 +33,9 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
3333 return nil, fmt.Errorf("failed to open database: %w", err)
3434 }
3535
36+ // Configure connection pool (must be done before any queries)
37+ configurePool(db)
38+
3639 // Test the connection
3740 if err := db.Ping(); err != nil {
3841 _ = db.Close()
@@ -59,12 +62,13 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
5962 // applyPragmas configures SQLite for optimal performance.
6063 func applyPragmas(db *sql.DB) error {
6164 pragmas := []string{
62- "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access
63- "PRAGMA synchronous = NORMAL", // Safe in WAL mode
64- "PRAGMA cache_size = -64000", // 64MB cache
65- "PRAGMA temp_store = MEMORY", // Temporary tables in RAM
66- "PRAGMA mmap_size = 30000000000", // 30GB memory-mapped I/O
67- "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention
65+ "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access
66+ "PRAGMA synchronous = NORMAL", // Safe in WAL mode
67+ "PRAGMA cache_size = -64000", // 64MB cache
68+ "PRAGMA temp_store = MEMORY", // Temporary tables in RAM
69+ "PRAGMA mmap_size = 268435456", // 256MB memory-mapped I/O (reasonable for browser history)
70+ "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention
71+ "PRAGMA foreign_keys = ON", // Enable referential integrity
6872 }
6973
7074 for _, pragma := range pragmas {
@@ -76,6 +80,19 @@ func applyPragmas(db *sql.DB) error {
7680 return nil
7781 }
7882
83+// configurePool sets connection pool parameters optimized for SQLite.
84+// SQLite only supports one writer at a time, so we limit connections.
85+//
86+// Connections are configured to never expire or close while idle. This is safe
87+// because the application's lifecycle matches the database connection's lifecycle:
88+// the process terminates before the underlying database file is rotated or replaced.
89+func configurePool(db *sql.DB) {
90+ db.SetMaxOpenConns(1) // SQLite is single-writer
91+ db.SetMaxIdleConns(1) // Keep one connection alive
92+ db.SetConnMaxLifetime(0) // Never expire connections
93+ db.SetConnMaxIdleTime(0) // Never close idle connections
94+}
95+
7996 // Close closes the database connection gracefully.
8097 func Close(db *sql.DB) error {
8198 if db == nil {
A internal/infrastructure/persistence/sqlite/lazy_db.go +92 -0
@@ -0,0 +1,92 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// LazyDB implements port.DatabaseProvider with lazy initialization.
14+// The database connection is created on first access, deferring the ~300-400ms
15+// WASM compilation and migration overhead until actually needed.
16+//
17+// Currently unused: the application uses eager initialization via OpenDatabase
18+// in RunParallelDBWebKit. This implementation is kept for potential future use
19+// in scenarios where deferring database initialization past first paint provides
20+// additional latency benefits, or for CLI commands that may not need DB access.
21+type LazyDB struct {
22+ dbPath string
23+ db *sql.DB
24+ err error
25+ once sync.Once
26+ mu sync.RWMutex
27+}
28+
29+// Compile-time interface check.
30+var _ port.DatabaseProvider = (*LazyDB)(nil)
31+
32+// NewLazyDB creates a new lazy database provider.
33+// The actual connection is not established until DB() is called.
34+func NewLazyDB(dbPath string) *LazyDB {
35+ return &LazyDB{dbPath: dbPath}
36+}
37+
38+// DB returns the database connection, initializing it if necessary.
39+// This method is thread-safe and will only initialize once.
40+func (l *LazyDB) DB(ctx context.Context) (*sql.DB, error) {
41+ l.once.Do(func() {
42+ log := logging.FromContext(ctx)
43+ log.Debug().Str("path", l.dbPath).Msg("lazy database initialization starting")
44+
45+ db, err := NewConnection(ctx, l.dbPath)
46+
47+ // Acquire lock before assigning to ensure IsInitialized() sees
48+ // a consistent state when reading l.db under RLock.
49+ l.mu.Lock()
50+ l.db = db
51+ l.err = err
52+ l.mu.Unlock()
53+
54+ if err != nil {
55+ log.Error().Err(err).Msg("lazy database initialization failed")
56+ } else {
57+ log.Debug().Msg("lazy database initialized successfully")
58+ }
59+ })
60+
61+ l.mu.RLock()
62+ db, err := l.db, l.err
63+ l.mu.RUnlock()
64+
65+ if err != nil {
66+ return nil, fmt.Errorf("database initialization failed: %w", err)
67+ }
68+ return db, nil
69+}
70+
71+// Close closes the database connection if it was initialized.
72+func (l *LazyDB) Close() error {
73+ l.mu.Lock()
74+ defer l.mu.Unlock()
75+
76+ if l.db != nil {
77+ return l.db.Close()
78+ }
79+ return nil
80+}
81+
82+// IsInitialized returns true if the database has been initialized.
83+func (l *LazyDB) IsInitialized() bool {
84+ l.mu.RLock()
85+ defer l.mu.RUnlock()
86+ return l.db != nil
87+}
88+
89+// Path returns the database path.
90+func (l *LazyDB) Path() string {
91+ return l.dbPath
92+}
A internal/infrastructure/persistence/sqlite/lazy_db_test.go +125 -0
@@ -0,0 +1,125 @@
1+package sqlite_test
2+
3+import (
4+ "path/filepath"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestLazyDB_NotInitializedByDefault(t *testing.T) {
14+ dbPath := filepath.Join(t.TempDir(), "test.db")
15+ lazy := sqlite.NewLazyDB(dbPath)
16+
17+ assert.False(t, lazy.IsInitialized(), "LazyDB should not be initialized before DB() is called")
18+}
19+
20+func TestLazyDB_InitializesOnFirstAccess(t *testing.T) {
21+ ctx := testCtx()
22+ dbPath := filepath.Join(t.TempDir(), "test.db")
23+ lazy := sqlite.NewLazyDB(dbPath)
24+
25+ db, err := lazy.DB(ctx)
26+ require.NoError(t, err)
27+ require.NotNil(t, db)
28+
29+ assert.True(t, lazy.IsInitialized(), "LazyDB should be initialized after DB() is called")
30+
31+ // Cleanup
32+ require.NoError(t, lazy.Close())
33+}
34+
35+func TestLazyDB_ReturnsSameConnection(t *testing.T) {
36+ ctx := testCtx()
37+ dbPath := filepath.Join(t.TempDir(), "test.db")
38+ lazy := sqlite.NewLazyDB(dbPath)
39+
40+ db1, err := lazy.DB(ctx)
41+ require.NoError(t, err)
42+
43+ db2, err := lazy.DB(ctx)
44+ require.NoError(t, err)
45+
46+ assert.Same(t, db1, db2, "DB() should return the same connection instance")
47+
48+ require.NoError(t, lazy.Close())
49+}
50+
51+func TestLazyDB_ConcurrentAccess(t *testing.T) {
52+ ctx := testCtx()
53+ dbPath := filepath.Join(t.TempDir(), "test.db")
54+ lazy := sqlite.NewLazyDB(dbPath)
55+
56+ const goroutines = 10
57+ var wg sync.WaitGroup
58+ wg.Add(goroutines)
59+
60+ results := make(chan *struct {
61+ db interface{}
62+ err error
63+ }, goroutines)
64+
65+ for range goroutines {
66+ go func() {
67+ defer wg.Done()
68+ db, err := lazy.DB(ctx)
69+ results <- &struct {
70+ db interface{}
71+ err error
72+ }{db, err}
73+ }()
74+ }
75+
76+ wg.Wait()
77+ close(results)
78+
79+ var firstDB interface{}
80+ for result := range results {
81+ require.NoError(t, result.err)
82+ require.NotNil(t, result.db)
83+
84+ if firstDB == nil {
85+ firstDB = result.db
86+ } else {
87+ assert.Same(t, firstDB, result.db, "All goroutines should receive the same DB instance")
88+ }
89+ }
90+
91+ require.NoError(t, lazy.Close())
92+}
93+
94+func TestLazyDB_CloseBeforeInit(t *testing.T) {
95+ dbPath := filepath.Join(t.TempDir(), "test.db")
96+ lazy := sqlite.NewLazyDB(dbPath)
97+
98+ // Close without ever calling DB() should not error
99+ err := lazy.Close()
100+ assert.NoError(t, err)
101+}
102+
103+func TestLazyDB_Path(t *testing.T) {
104+ dbPath := "/some/path/to/db.sqlite"
105+ lazy := sqlite.NewLazyDB(dbPath)
106+
107+ assert.Equal(t, dbPath, lazy.Path())
108+}
109+
110+func TestLazyDB_DBIsUsable(t *testing.T) {
111+ ctx := testCtx()
112+ dbPath := filepath.Join(t.TempDir(), "test.db")
113+ lazy := sqlite.NewLazyDB(dbPath)
114+
115+ db, err := lazy.DB(ctx)
116+ require.NoError(t, err)
117+
118+ // Verify we can execute a simple query
119+ var result int
120+ err = db.QueryRowContext(ctx, "SELECT 1").Scan(&result)
121+ require.NoError(t, err)
122+ assert.Equal(t, 1, result)
123+
124+ require.NoError(t, lazy.Close())
125+}
A internal/infrastructure/persistence/sqlite/lazy_repos.go +669 -0
@@ -0,0 +1,669 @@
1+// Package sqlite provides SQLite implementations of domain repositories.
2+//
3+// # Lazy Repository Infrastructure
4+//
5+// This file contains lazy-loading repository wrappers that defer database initialization
6+// until first access. While currently unused (the application uses eager initialization
7+// via OpenDatabase in RunParallelDBWebKit), this infrastructure is kept for potential
8+// future optimization scenarios:
9+//
10+// - CLI commands that may not need database access
11+// - Further startup latency reduction by deferring DB init past first paint
12+// - Testing with mock DatabaseProvider implementations
13+//
14+// The lazy wrappers implement the same repository interfaces as their eager counterparts,
15+// making them drop-in replacements when lazy initialization becomes beneficial.
16+package sqlite
17+
18+import (
19+ "context"
20+ "database/sql"
21+ "sync"
22+ "time"
23+
24+ "github.com/bnema/dumber/internal/application/port"
25+ "github.com/bnema/dumber/internal/domain/entity"
26+ "github.com/bnema/dumber/internal/domain/repository"
27+)
28+
29+// LazyHistoryRepository wraps a history repository with lazy database initialization.
30+type LazyHistoryRepository struct {
31+ provider port.DatabaseProvider
32+ repo repository.HistoryRepository
33+ once sync.Once
34+ initErr error
35+}
36+
37+// NewLazyHistoryRepository creates a lazy-loading history repository.
38+func NewLazyHistoryRepository(provider port.DatabaseProvider) repository.HistoryRepository {
39+ return &LazyHistoryRepository{provider: provider}
40+}
41+
42+func (r *LazyHistoryRepository) init(ctx context.Context) error {
43+ r.once.Do(func() {
44+ db, err := r.provider.DB(ctx)
45+ if err != nil {
46+ r.initErr = err
47+ return
48+ }
49+ r.repo = NewHistoryRepository(db)
50+ })
51+ return r.initErr
52+}
53+
54+func (r *LazyHistoryRepository) Save(ctx context.Context, entry *entity.HistoryEntry) error {
55+ if err := r.init(ctx); err != nil {
56+ return err
57+ }
58+ return r.repo.Save(ctx, entry)
59+}
60+
61+func (r *LazyHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
62+ if err := r.init(ctx); err != nil {
63+ return nil, err
64+ }
65+ return r.repo.FindByURL(ctx, url)
66+}
67+
68+func (r *LazyHistoryRepository) Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error) {
69+ if err := r.init(ctx); err != nil {
70+ return nil, err
71+ }
72+ return r.repo.Search(ctx, query, limit)
73+}
74+
75+func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
76+ if err := r.init(ctx); err != nil {
77+ return nil, err
78+ }
79+ return r.repo.GetRecent(ctx, limit, offset)
80+}
81+
82+func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
83+ if err := r.init(ctx); err != nil {
84+ return err
85+ }
86+ return r.repo.IncrementVisitCount(ctx, url)
87+}
88+
89+func (r *LazyHistoryRepository) Delete(ctx context.Context, id int64) error {
90+ if err := r.init(ctx); err != nil {
91+ return err
92+ }
93+ return r.repo.Delete(ctx, id)
94+}
95+
96+func (r *LazyHistoryRepository) DeleteOlderThan(ctx context.Context, before time.Time) error {
97+ if err := r.init(ctx); err != nil {
98+ return err
99+ }
100+ return r.repo.DeleteOlderThan(ctx, before)
101+}
102+
103+func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error {
104+ if err := r.init(ctx); err != nil {
105+ return err
106+ }
107+ return r.repo.DeleteAll(ctx)
108+}
109+
110+func (r *LazyHistoryRepository) DeleteByDomain(ctx context.Context, domain string) error {
111+ if err := r.init(ctx); err != nil {
112+ return err
113+ }
114+ return r.repo.DeleteByDomain(ctx, domain)
115+}
116+
117+func (r *LazyHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
118+ if err := r.init(ctx); err != nil {
119+ return nil, err
120+ }
121+ return r.repo.GetStats(ctx)
122+}
123+
124+func (r *LazyHistoryRepository) GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
125+ if err := r.init(ctx); err != nil {
126+ return nil, err
127+ }
128+ return r.repo.GetDomainStats(ctx, limit)
129+}
130+
131+func (r *LazyHistoryRepository) GetHourlyDistribution(ctx context.Context) ([]*entity.HourlyDistribution, error) {
132+ if err := r.init(ctx); err != nil {
133+ return nil, err
134+ }
135+ return r.repo.GetHourlyDistribution(ctx)
136+}
137+
138+func (r *LazyHistoryRepository) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {
139+ if err := r.init(ctx); err != nil {
140+ return nil, err
141+ }
142+ return r.repo.GetDailyVisitCount(ctx, daysAgo)
143+}
144+
145+// LazyFavoriteRepository wraps a favorite repository with lazy database initialization.
146+type LazyFavoriteRepository struct {
147+ provider port.DatabaseProvider
148+ repo repository.FavoriteRepository
149+ once sync.Once
150+ initErr error
151+}
152+
153+// NewLazyFavoriteRepository creates a lazy-loading favorite repository.
154+func NewLazyFavoriteRepository(provider port.DatabaseProvider) repository.FavoriteRepository {
155+ return &LazyFavoriteRepository{provider: provider}
156+}
157+
158+func (r *LazyFavoriteRepository) init(ctx context.Context) error {
159+ r.once.Do(func() {
160+ db, err := r.provider.DB(ctx)
161+ if err != nil {
162+ r.initErr = err
163+ return
164+ }
165+ r.repo = NewFavoriteRepository(db)
166+ })
167+ return r.initErr
168+}
169+
170+func (r *LazyFavoriteRepository) Save(ctx context.Context, fav *entity.Favorite) error {
171+ if err := r.init(ctx); err != nil {
172+ return err
173+ }
174+ return r.repo.Save(ctx, fav)
175+}
176+
177+func (r *LazyFavoriteRepository) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
178+ if err := r.init(ctx); err != nil {
179+ return nil, err
180+ }
181+ return r.repo.FindByID(ctx, id)
182+}
183+
184+func (r *LazyFavoriteRepository) FindByURL(ctx context.Context, url string) (*entity.Favorite, error) {
185+ if err := r.init(ctx); err != nil {
186+ return nil, err
187+ }
188+ return r.repo.FindByURL(ctx, url)
189+}
190+
191+func (r *LazyFavoriteRepository) GetAll(ctx context.Context) ([]*entity.Favorite, error) {
192+ if err := r.init(ctx); err != nil {
193+ return nil, err
194+ }
195+ return r.repo.GetAll(ctx)
196+}
197+
198+func (r *LazyFavoriteRepository) GetByFolder(ctx context.Context, folderID *entity.FolderID) ([]*entity.Favorite, error) {
199+ if err := r.init(ctx); err != nil {
200+ return nil, err
201+ }
202+ return r.repo.GetByFolder(ctx, folderID)
203+}
204+
205+func (r *LazyFavoriteRepository) GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error) {
206+ if err := r.init(ctx); err != nil {
207+ return nil, err
208+ }
209+ return r.repo.GetByShortcut(ctx, key)
210+}
211+
212+func (r *LazyFavoriteRepository) UpdatePosition(ctx context.Context, id entity.FavoriteID, position int) error {
213+ if err := r.init(ctx); err != nil {
214+ return err
215+ }
216+ return r.repo.UpdatePosition(ctx, id, position)
217+}
218+
219+func (r *LazyFavoriteRepository) SetFolder(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error {
220+ if err := r.init(ctx); err != nil {
221+ return err
222+ }
223+ return r.repo.SetFolder(ctx, id, folderID)
224+}
225+
226+func (r *LazyFavoriteRepository) SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error {
227+ if err := r.init(ctx); err != nil {
228+ return err
229+ }
230+ return r.repo.SetShortcut(ctx, id, key)
231+}
232+
233+func (r *LazyFavoriteRepository) Delete(ctx context.Context, id entity.FavoriteID) error {
234+ if err := r.init(ctx); err != nil {
235+ return err
236+ }
237+ return r.repo.Delete(ctx, id)
238+}
239+
240+// LazyFolderRepository wraps a folder repository with lazy database initialization.
241+type LazyFolderRepository struct {
242+ provider port.DatabaseProvider
243+ repo repository.FolderRepository
244+ once sync.Once
245+ initErr error
246+}
247+
248+// NewLazyFolderRepository creates a lazy-loading folder repository.
249+func NewLazyFolderRepository(provider port.DatabaseProvider) repository.FolderRepository {
250+ return &LazyFolderRepository{provider: provider}
251+}
252+
253+func (r *LazyFolderRepository) init(ctx context.Context) error {
254+ r.once.Do(func() {
255+ db, err := r.provider.DB(ctx)
256+ if err != nil {
257+ r.initErr = err
258+ return
259+ }
260+ r.repo = NewFolderRepository(db)
261+ })
262+ return r.initErr
263+}
264+
265+func (r *LazyFolderRepository) Save(ctx context.Context, folder *entity.Folder) error {
266+ if err := r.init(ctx); err != nil {
267+ return err
268+ }
269+ return r.repo.Save(ctx, folder)
270+}
271+
272+func (r *LazyFolderRepository) FindByID(ctx context.Context, id entity.FolderID) (*entity.Folder, error) {
273+ if err := r.init(ctx); err != nil {
274+ return nil, err
275+ }
276+ return r.repo.FindByID(ctx, id)
277+}
278+
279+func (r *LazyFolderRepository) GetAll(ctx context.Context) ([]*entity.Folder, error) {
280+ if err := r.init(ctx); err != nil {
281+ return nil, err
282+ }
283+ return r.repo.GetAll(ctx)
284+}
285+
286+func (r *LazyFolderRepository) GetChildren(ctx context.Context, parentID *entity.FolderID) ([]*entity.Folder, error) {
287+ if err := r.init(ctx); err != nil {
288+ return nil, err
289+ }
290+ return r.repo.GetChildren(ctx, parentID)
291+}
292+
293+func (r *LazyFolderRepository) UpdatePosition(ctx context.Context, id entity.FolderID, position int) error {
294+ if err := r.init(ctx); err != nil {
295+ return err
296+ }
297+ return r.repo.UpdatePosition(ctx, id, position)
298+}
299+
300+func (r *LazyFolderRepository) Delete(ctx context.Context, id entity.FolderID) error {
301+ if err := r.init(ctx); err != nil {
302+ return err
303+ }
304+ return r.repo.Delete(ctx, id)
305+}
306+
307+// LazyTagRepository wraps a tag repository with lazy database initialization.
308+type LazyTagRepository struct {
309+ provider port.DatabaseProvider
310+ repo repository.TagRepository
311+ once sync.Once
312+ initErr error
313+}
314+
315+// NewLazyTagRepository creates a lazy-loading tag repository.
316+func NewLazyTagRepository(provider port.DatabaseProvider) repository.TagRepository {
317+ return &LazyTagRepository{provider: provider}
318+}
319+
320+func (r *LazyTagRepository) init(ctx context.Context) error {
321+ r.once.Do(func() {
322+ db, err := r.provider.DB(ctx)
323+ if err != nil {
324+ r.initErr = err
325+ return
326+ }
327+ r.repo = NewTagRepository(db)
328+ })
329+ return r.initErr
330+}
331+
332+func (r *LazyTagRepository) Save(ctx context.Context, tag *entity.Tag) error {
333+ if err := r.init(ctx); err != nil {
334+ return err
335+ }
336+ return r.repo.Save(ctx, tag)
337+}
338+
339+func (r *LazyTagRepository) FindByID(ctx context.Context, id entity.TagID) (*entity.Tag, error) {
340+ if err := r.init(ctx); err != nil {
341+ return nil, err
342+ }
343+ return r.repo.FindByID(ctx, id)
344+}
345+
346+func (r *LazyTagRepository) FindByName(ctx context.Context, name string) (*entity.Tag, error) {
347+ if err := r.init(ctx); err != nil {
348+ return nil, err
349+ }
350+ return r.repo.FindByName(ctx, name)
351+}
352+
353+func (r *LazyTagRepository) GetAll(ctx context.Context) ([]*entity.Tag, error) {
354+ if err := r.init(ctx); err != nil {
355+ return nil, err
356+ }
357+ return r.repo.GetAll(ctx)
358+}
359+
360+func (r *LazyTagRepository) AssignToFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
361+ if err := r.init(ctx); err != nil {
362+ return err
363+ }
364+ return r.repo.AssignToFavorite(ctx, tagID, favID)
365+}
366+
367+func (r *LazyTagRepository) RemoveFromFavorite(ctx context.Context, tagID entity.TagID, favID entity.FavoriteID) error {
368+ if err := r.init(ctx); err != nil {
369+ return err
370+ }
371+ return r.repo.RemoveFromFavorite(ctx, tagID, favID)
372+}
373+
374+func (r *LazyTagRepository) GetForFavorite(ctx context.Context, favID entity.FavoriteID) ([]*entity.Tag, error) {
375+ if err := r.init(ctx); err != nil {
376+ return nil, err
377+ }
378+ return r.repo.GetForFavorite(ctx, favID)
379+}
380+
381+func (r *LazyTagRepository) Delete(ctx context.Context, id entity.TagID) error {
382+ if err := r.init(ctx); err != nil {
383+ return err
384+ }
385+ return r.repo.Delete(ctx, id)
386+}
387+
388+// LazyZoomRepository wraps a zoom repository with lazy database initialization.
389+type LazyZoomRepository struct {
390+ provider port.DatabaseProvider
391+ repo repository.ZoomRepository
392+ once sync.Once
393+ initErr error
394+}
395+
396+// NewLazyZoomRepository creates a lazy-loading zoom repository.
397+func NewLazyZoomRepository(provider port.DatabaseProvider) repository.ZoomRepository {
398+ return &LazyZoomRepository{provider: provider}
399+}
400+
401+func (r *LazyZoomRepository) init(ctx context.Context) error {
402+ r.once.Do(func() {
403+ db, err := r.provider.DB(ctx)
404+ if err != nil {
405+ r.initErr = err
406+ return
407+ }
408+ r.repo = NewZoomRepository(db)
409+ })
410+ return r.initErr
411+}
412+
413+func (r *LazyZoomRepository) Get(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
414+ if err := r.init(ctx); err != nil {
415+ return nil, err
416+ }
417+ return r.repo.Get(ctx, domain)
418+}
419+
420+func (r *LazyZoomRepository) Set(ctx context.Context, zoom *entity.ZoomLevel) error {
421+ if err := r.init(ctx); err != nil {
422+ return err
423+ }
424+ return r.repo.Set(ctx, zoom)
425+}
426+
427+func (r *LazyZoomRepository) Delete(ctx context.Context, domain string) error {
428+ if err := r.init(ctx); err != nil {
429+ return err
430+ }
431+ return r.repo.Delete(ctx, domain)
432+}
433+
434+func (r *LazyZoomRepository) GetAll(ctx context.Context) ([]*entity.ZoomLevel, error) {
435+ if err := r.init(ctx); err != nil {
436+ return nil, err
437+ }
438+ return r.repo.GetAll(ctx)
439+}
440+
441+// LazySessionRepository wraps a session repository with lazy database initialization.
442+type LazySessionRepository struct {
443+ provider port.DatabaseProvider
444+ repo repository.SessionRepository
445+ once sync.Once
446+ initErr error
447+}
448+
449+// NewLazySessionRepository creates a lazy-loading session repository.
450+func NewLazySessionRepository(provider port.DatabaseProvider) repository.SessionRepository {
451+ return &LazySessionRepository{provider: provider}
452+}
453+
454+func (r *LazySessionRepository) init(ctx context.Context) error {
455+ r.once.Do(func() {
456+ db, err := r.provider.DB(ctx)
457+ if err != nil {
458+ r.initErr = err
459+ return
460+ }
461+ r.repo = NewSessionRepository(db)
462+ })
463+ return r.initErr
464+}
465+
466+func (r *LazySessionRepository) Save(ctx context.Context, session *entity.Session) error {
467+ if err := r.init(ctx); err != nil {
468+ return err
469+ }
470+ return r.repo.Save(ctx, session)
471+}
472+
473+func (r *LazySessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
474+ if err := r.init(ctx); err != nil {
475+ return nil, err
476+ }
477+ return r.repo.FindByID(ctx, id)
478+}
479+
480+func (r *LazySessionRepository) GetActive(ctx context.Context) (*entity.Session, error) {
481+ if err := r.init(ctx); err != nil {
482+ return nil, err
483+ }
484+ return r.repo.GetActive(ctx)
485+}
486+
487+func (r *LazySessionRepository) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {
488+ if err := r.init(ctx); err != nil {
489+ return nil, err
490+ }
491+ return r.repo.GetRecent(ctx, limit)
492+}
493+
494+func (r *LazySessionRepository) MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error {
495+ if err := r.init(ctx); err != nil {
496+ return err
497+ }
498+ return r.repo.MarkEnded(ctx, id, endedAt)
499+}
500+
501+func (r *LazySessionRepository) Delete(ctx context.Context, id entity.SessionID) error {
502+ if err := r.init(ctx); err != nil {
503+ return err
504+ }
505+ return r.repo.Delete(ctx, id)
506+}
507+
508+func (r *LazySessionRepository) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) {
509+ if err := r.init(ctx); err != nil {
510+ return 0, err
511+ }
512+ return r.repo.DeleteOldestExited(ctx, keepCount)
513+}
514+
515+func (r *LazySessionRepository) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) {
516+ if err := r.init(ctx); err != nil {
517+ return 0, err
518+ }
519+ return r.repo.DeleteExitedBefore(ctx, cutoff)
520+}
521+
522+// LazySessionStateRepository wraps a session state repository with lazy database initialization.
523+type LazySessionStateRepository struct {
524+ provider port.DatabaseProvider
525+ repo repository.SessionStateRepository
526+ once sync.Once
527+ initErr error
528+}
529+
530+// NewLazySessionStateRepository creates a lazy-loading session state repository.
531+func NewLazySessionStateRepository(provider port.DatabaseProvider) repository.SessionStateRepository {
532+ return &LazySessionStateRepository{provider: provider}
533+}
534+
535+func (r *LazySessionStateRepository) init(ctx context.Context) error {
536+ r.once.Do(func() {
537+ db, err := r.provider.DB(ctx)
538+ if err != nil {
539+ r.initErr = err
540+ return
541+ }
542+ r.repo = NewSessionStateRepository(db)
543+ })
544+ return r.initErr
545+}
546+
547+func (r *LazySessionStateRepository) SaveSnapshot(ctx context.Context, state *entity.SessionState) error {
548+ if err := r.init(ctx); err != nil {
549+ return err
550+ }
551+ return r.repo.SaveSnapshot(ctx, state)
552+}
553+
554+func (r *LazySessionStateRepository) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) {
555+ if err := r.init(ctx); err != nil {
556+ return nil, err
557+ }
558+ return r.repo.GetSnapshot(ctx, sessionID)
559+}
560+
561+func (r *LazySessionStateRepository) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
562+ if err := r.init(ctx); err != nil {
563+ return err
564+ }
565+ return r.repo.DeleteSnapshot(ctx, sessionID)
566+}
567+
568+func (r *LazySessionStateRepository) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) {
569+ if err := r.init(ctx); err != nil {
570+ return nil, err
571+ }
572+ return r.repo.GetAllSnapshots(ctx)
573+}
574+
575+func (r *LazySessionStateRepository) GetTotalSnapshotsSize(ctx context.Context) (int64, error) {
576+ if err := r.init(ctx); err != nil {
577+ return 0, err
578+ }
579+ return r.repo.GetTotalSnapshotsSize(ctx)
580+}
581+
582+// LazyContentWhitelistRepository wraps a content whitelist repository with lazy database initialization.
583+type LazyContentWhitelistRepository struct {
584+ provider port.DatabaseProvider
585+ repo repository.ContentWhitelistRepository
586+ once sync.Once
587+ initErr error
588+}
589+
590+// NewLazyContentWhitelistRepository creates a lazy-loading content whitelist repository.
591+func NewLazyContentWhitelistRepository(provider port.DatabaseProvider) repository.ContentWhitelistRepository {
592+ return &LazyContentWhitelistRepository{provider: provider}
593+}
594+
595+func (r *LazyContentWhitelistRepository) init(ctx context.Context) error {
596+ r.once.Do(func() {
597+ db, err := r.provider.DB(ctx)
598+ if err != nil {
599+ r.initErr = err
600+ return
601+ }
602+ r.repo = NewContentWhitelistRepository(db)
603+ })
604+ return r.initErr
605+}
606+
607+func (r *LazyContentWhitelistRepository) Add(ctx context.Context, domain string) error {
608+ if err := r.init(ctx); err != nil {
609+ return err
610+ }
611+ return r.repo.Add(ctx, domain)
612+}
613+
614+func (r *LazyContentWhitelistRepository) Remove(ctx context.Context, domain string) error {
615+ if err := r.init(ctx); err != nil {
616+ return err
617+ }
618+ return r.repo.Remove(ctx, domain)
619+}
620+
621+func (r *LazyContentWhitelistRepository) Contains(ctx context.Context, domain string) (bool, error) {
622+ if err := r.init(ctx); err != nil {
623+ return false, err
624+ }
625+ return r.repo.Contains(ctx, domain)
626+}
627+
628+func (r *LazyContentWhitelistRepository) GetAll(ctx context.Context) ([]string, error) {
629+ if err := r.init(ctx); err != nil {
630+ return nil, err
631+ }
632+ return r.repo.GetAll(ctx)
633+}
634+
635+// LazyRepositories holds all lazy-loaded repositories.
636+type LazyRepositories struct {
637+ History repository.HistoryRepository
638+ Favorite repository.FavoriteRepository
639+ Folder repository.FolderRepository
640+ Tag repository.TagRepository
641+ Zoom repository.ZoomRepository
642+ Session repository.SessionRepository
643+ SessionState repository.SessionStateRepository
644+ Filter repository.ContentWhitelistRepository
645+}
646+
647+// NewLazyRepositories creates all lazy repositories from a database provider.
648+func NewLazyRepositories(provider port.DatabaseProvider) *LazyRepositories {
649+ return &LazyRepositories{
650+ History: NewLazyHistoryRepository(provider),
651+ Favorite: NewLazyFavoriteRepository(provider),
652+ Folder: NewLazyFolderRepository(provider),
653+ Tag: NewLazyTagRepository(provider),
654+ Zoom: NewLazyZoomRepository(provider),
655+ Session: NewLazySessionRepository(provider),
656+ SessionState: NewLazySessionStateRepository(provider),
657+ Filter: NewLazyContentWhitelistRepository(provider),
658+ }
659+}
660+
661+// GetDB returns the underlying *sql.DB for operations that need it directly.
662+// Returns nil if the database hasn't been initialized yet.
663+func GetDB(provider port.DatabaseProvider, ctx context.Context) *sql.DB {
664+ db, err := provider.DB(ctx)
665+ if err != nil {
666+ return nil
667+ }
668+ return db
669+}
A internal/infrastructure/webkit/bgcolor.go +38 -0
@@ -0,0 +1,38 @@
1+package webkit
2+
3+import (
4+ "sync"
5+
6+ "github.com/jwijenbergh/puregotk/v4/gdk"
7+)
8+
9+// bgColor holds RGBA components for WebView background color (eliminates white flash).
10+// It is thread-safe and can be embedded in pool/factory structs.
11+type bgColor struct {
12+ r, g, b, a float32
13+ mu sync.RWMutex
14+}
15+
16+// set stores the background color components.
17+func (c *bgColor) set(r, g, b, a float32) {
18+ c.mu.Lock()
19+ c.r, c.g, c.b, c.a = r, g, b, a
20+ c.mu.Unlock()
21+}
22+
23+// get returns the background color components.
24+func (c *bgColor) get() (r, g, b, a float32) {
25+ c.mu.RLock()
26+ r, g, b, a = c.r, c.g, c.b, c.a
27+ c.mu.RUnlock()
28+ return
29+}
30+
31+// toGdkRGBA returns a *gdk.RGBA if a valid color is configured (alpha > 0), nil otherwise.
32+func (c *bgColor) toGdkRGBA() *gdk.RGBA {
33+ r, g, b, a := c.get()
34+ if a <= 0 {
35+ return nil
36+ }
37+ return &gdk.RGBA{Red: r, Green: g, Blue: b, Alpha: a}
38+}
M internal/infrastructure/webkit/factory.go +6 -18
@@ -3,7 +3,6 @@ package webkit
33 import (
44 "context"
55 "fmt"
6- "sync"
76
87 "github.com/bnema/dumber/internal/application/port"
98 "github.com/bnema/dumber/internal/logging"
@@ -21,8 +20,7 @@ type WebViewFactory struct {
2120 filterApplier FilterApplier // Optional content filter applier
2221
2322 // Background color for WebViews (eliminates white flash)
24- bgR, bgG, bgB, bgA float32
25- bgMu sync.RWMutex
23+ bg bgColor
2624 }
2725
2826 // NewWebViewFactory creates a new WebViewFactory.
@@ -56,9 +54,7 @@ func (f *WebViewFactory) SetFilterApplier(applier FilterApplier) {
5654 // SetBackgroundColor sets the background color for newly created WebViews.
5755 // This color is shown before content is painted, eliminating white flash.
5856 func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) {
59- f.bgMu.Lock()
60- f.bgR, f.bgG, f.bgB, f.bgA = r, g, b, a
61- f.bgMu.Unlock()
57+ f.bg.set(r, g, b, a)
6258 // Also propagate to the pool if present
6359 if f.pool != nil {
6460 f.pool.SetBackgroundColor(r, g, b, a)
@@ -106,10 +102,7 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
106102 }
107103
108104 // Set background color to match theme (eliminates white flash)
109- f.bgMu.RLock()
110- r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA
111- f.bgMu.RUnlock()
112- if a > 0 {
105+ if r, g, b, a := f.bg.get(); a > 0 {
113106 wv.SetBackgroundColor(r, g, b, a)
114107 }
115108
@@ -138,18 +131,13 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
138131 func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
139132 log := logging.FromContext(ctx)
140133
141- wv, err := NewWebView(ctx, f.wkCtx, f.settings)
134+ wv, err := NewWebView(ctx, f.wkCtx, f.settings, f.bg.toGdkRGBA())
142135 if err != nil {
143136 return nil, err
144137 }
145138
146- // Set background color to match theme (eliminates white flash)
147- f.bgMu.RLock()
148- r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA
149- f.bgMu.RUnlock()
150- if a > 0 {
151- wv.SetBackgroundColor(r, g, b, a)
152- }
139+ // Add CSS class for theme background styling (prevents white flash)
140+ wv.inner.AddCssClass("webview-themed")
153141
154142 // Keep hidden until content is painted
155143 wv.inner.SetVisible(false)
M internal/infrastructure/webkit/injector.go +56 -5
@@ -25,6 +25,34 @@ const themeCSSScript = `(function() {
2525 (document.head || document.documentElement).appendChild(style);
2626 })();`
2727
28+// earlyBackgroundScript injects a temporary background color to prevent white flash.
29+// It's injected at document start and removed after the page loads.
30+// The %s placeholder is replaced with the hex color (e.g., "#0a0a0b").
31+const earlyBackgroundScript = `(function() {
32+ var style = document.createElement('style');
33+ style.id = '__dumber_early_bg';
34+ style.textContent = 'html,body{background-color:%s!important}';
35+ document.documentElement.appendChild(style);
36+
37+ // Remove the style after page content loads to avoid overriding page styles
38+ function removeEarlyBg() {
39+ var s = document.getElementById('__dumber_early_bg');
40+ if (s) s.remove();
41+ }
42+
43+ // Try multiple events to ensure removal
44+ if (document.readyState === 'complete') {
45+ setTimeout(removeEarlyBg, 100);
46+ } else {
47+ window.addEventListener('DOMContentLoaded', function() {
48+ setTimeout(removeEarlyBg, 100);
49+ }, {once: true});
50+ window.addEventListener('load', function() {
51+ setTimeout(removeEarlyBg, 100);
52+ }, {once: true});
53+ }
54+})();`
55+
2856 // darkModeScript patches window.matchMedia and sets theme class on <html>.
2957 // It must be injected at document start, after __dumber_gtk_prefers_dark is set.
3058 // This script:
@@ -132,6 +160,7 @@ type ContentInjector struct {
132160 prefersDark bool
133161 themeCSSVars string // CSS custom property declarations for WebUI
134162 findCSS string // CSS for find-in-page highlight styling
163+ bgColor string // Background color hex for early injection (prevents white flash)
135164 }
136165
137166 // NewContentInjector creates a new injector instance.
@@ -171,6 +200,13 @@ func (ci *ContentInjector) SetPrefersDark(prefersDark bool) {
171200 ci.prefersDark = prefersDark
172201 }
173202
203+// SetBackgroundColor sets the background color for early CSS injection.
204+// This color is applied to html/body at document start to prevent white flash.
205+// The hex color should include the # prefix (e.g., "#0a0a0b").
206+func (ci *ContentInjector) SetBackgroundColor(hex string) {
207+ ci.bgColor = hex
208+}
209+
174210 // InjectScripts adds the minimal dark mode detection scripts to the given content manager.
175211 // Only injects:
176212 // - window.__dumber_gtk_prefers_dark flag
@@ -193,7 +229,22 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
193229 log.Debug().Str("script", label).Msg("injected user script")
194230 }
195231
196- // 1. Inject GTK dark mode preference (must be before dark mode handler)
232+ // 1. Inject early background color to prevent white flash (must be first!)
233+ if ci.bgColor != "" {
234+ earlyBgScript := fmt.Sprintf(earlyBackgroundScript, ci.bgColor)
235+ addScript(
236+ webkit.NewUserScript(
237+ earlyBgScript,
238+ webkit.UserContentInjectTopFrameValue,
239+ webkit.UserScriptInjectAtDocumentStartValue,
240+ nil,
241+ nil,
242+ ),
243+ "early-background",
244+ )
245+ }
246+
247+ // 2. Inject GTK dark mode preference (must be before dark mode handler)
197248 darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
198249 addScript(
199250 webkit.NewUserScript(
@@ -206,7 +257,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
206257 "gtk-dark-mode-pref",
207258 )
208259
209- // 2. Inject WebView ID for debugging
260+ // 3. Inject WebView ID for debugging
210261 if webviewID != 0 {
211262 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
212263 addScript(
@@ -221,7 +272,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
221272 )
222273 }
223274
224- // 3. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark)
275+ // 4. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark)
225276 addScript(
226277 webkit.NewUserScript(
227278 darkModeScript,
@@ -233,7 +284,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
233284 "dark-mode-handler",
234285 )
235286
236- // 4. Inject theme CSS for internal pages (dumb://* only)
287+ // 5. Inject theme CSS for internal pages (dumb://* only)
237288 if ci.themeCSSVars != "" {
238289 // Escape for JS string literal
239290 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
@@ -253,7 +304,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
253304 log.Debug().Msg("theme CSS vars injection configured")
254305 }
255306
256- // 5. Inject find highlight CSS for all pages
307+ // 6. Inject find highlight CSS for all pages
257308 if ci.findCSS != "" {
258309 stylesheet := webkit.NewUserStyleSheet(
259310 ci.findCSS,
M internal/infrastructure/webkit/messaging.go +11 -0
@@ -62,6 +62,17 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
6262 }
6363 }
6464
65+// SetBaseContext updates the base context used for logging and handler execution.
66+func (r *MessageRouter) SetBaseContext(ctx context.Context) {
67+ if r == nil {
68+ return
69+ }
70+ if ctx == nil {
71+ ctx = context.Background()
72+ }
73+ r.baseCtx = ctx
74+}
75+
6576 // RegisterHandler registers a handler for a message type.
6677 func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
6778 if msgType == "" {
M internal/infrastructure/webkit/pool.go +125 -14
@@ -2,14 +2,19 @@ package webkit
22
33 import (
44 "context"
5+ "errors"
56 "sync"
67 "sync/atomic"
78 "time"
89
910 "github.com/bnema/dumber/internal/logging"
1011 "github.com/bnema/puregotk-webkit/webkit"
12+ "github.com/jwijenbergh/puregotk/v4/glib"
1113 )
1214
15+// ErrPoolClosed is returned when operations are attempted on a closed pool.
16+var ErrPoolClosed = errors.New("webview pool is closed")
17+
1318 // FilterApplier applies content filters to a UserContentManager.
1419 // This interface decouples the pool from the filtering package.
1520 type FilterApplier interface {
@@ -50,8 +55,7 @@ type WebViewPool struct {
5055 filterApplier FilterApplier // Optional content filter applier
5156
5257 // Background color for WebViews (eliminates white flash)
53- bgR, bgG, bgB, bgA float32
54- bgMu sync.RWMutex
58+ bg bgColor
5559
5660 closed atomic.Bool
5761 wg sync.WaitGroup
@@ -103,9 +107,7 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
103107 // SetBackgroundColor sets the background color for newly created WebViews.
104108 // This color is shown before content is painted, eliminating white flash.
105109 func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
106- p.bgMu.Lock()
107- p.bgR, p.bgG, p.bgB, p.bgA = r, g, b, a
108- p.bgMu.Unlock()
110+ p.bg.set(r, g, b, a)
109111 }
110112
111113 // Acquire gets a WebView from the pool or creates a new one.
@@ -113,13 +115,20 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
113115 log := logging.FromContext(ctx)
114116
115117 if p.closed.Load() {
116- return nil, context.Canceled
118+ return nil, ErrPoolClosed
117119 }
118120
119121 // Try to get from pool first (non-blocking)
120122 select {
121123 case wv := <-p.pool:
122124 if wv != nil && !wv.IsDestroyed() {
125+ if r, g, b, a := p.bg.get(); a > 0 {
126+ wv.SetBackgroundColor(r, g, b, a)
127+ }
128+ wv.inner.AddCssClass("webview-themed")
129+ // Keep pooled WebViews hidden until we explicitly reveal them.
130+ wv.inner.SetVisible(false)
131+
123132 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
124133 _ = wv.AttachFrontend(ctx, p.injector, p.router)
125134 // Apply filters if available (may not have been applied during prewarm)
@@ -142,18 +151,13 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
142151 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
143152 log := logging.FromContext(ctx)
144153
145- wv, err := NewWebView(ctx, p.wkCtx, p.settings)
154+ wv, err := NewWebView(ctx, p.wkCtx, p.settings, p.bg.toGdkRGBA())
146155 if err != nil {
147156 return nil, err
148157 }
149158
150- // Set background color to match theme (eliminates white flash)
151- p.bgMu.RLock()
152- r, g, b, a := p.bgR, p.bgG, p.bgB, p.bgA
153- p.bgMu.RUnlock()
154- if a > 0 { // Only set if a valid color was configured
155- wv.SetBackgroundColor(r, g, b, a)
156- }
159+ // Add CSS class for theme background styling (prevents white flash)
160+ wv.inner.AddCssClass("webview-themed")
157161
158162 // Keep WebView hidden until content is painted (see content.go:onLoadCommitted)
159163 wv.inner.SetVisible(false)
@@ -195,6 +199,34 @@ func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
195199 wv.Destroy()
196200 }
197201
202+// PrewarmFirst creates exactly one WebView synchronously.
203+// Call this during startup (before GTK activate) to ensure first Acquire() is instant.
204+// This is the most impactful optimization for cold start since WebView creation
205+// is the heaviest operation (spawns WebKit web process).
206+// Returns error if WebView creation fails; caller should log and continue.
207+func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
208+ if p.closed.Load() {
209+ return ErrPoolClosed
210+ }
211+ if len(p.pool) > 0 {
212+ return nil // Already have at least one
213+ }
214+
215+ log := logging.FromContext(ctx)
216+ wv, err := p.createWebView(ctx)
217+ if err != nil {
218+ return err
219+ }
220+
221+ select {
222+ case p.pool <- wv:
223+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
224+ default:
225+ wv.Destroy() // Pool somehow full (shouldn't happen)
226+ }
227+ return nil
228+}
229+
198230 // Prewarm creates WebViews synchronously to populate the pool.
199231 // Must be called from the GTK main thread (after GTK application is initialized).
200232 func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
@@ -207,6 +239,85 @@ func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
207239 p.prewarmSync(ctx, count)
208240 }
209241
242+// PrewarmAsync schedules WebView creation on the GTK idle loop.
243+// This avoids blocking startup (especially cold-start navigation) while still
244+// warming up WebViews for subsequent tab creation.
245+func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
246+ log := logging.FromContext(ctx)
247+
248+ if count <= 0 {
249+ count = p.config.PrewarmCount
250+ }
251+ if count <= 0 {
252+ return
253+ }
254+ if p.closed.Load() {
255+ return
256+ }
257+
258+ log.Debug().Int("count", count).Int("pool_size", len(p.pool)).Msg("scheduling async webview pool prewarm")
259+
260+ p.wg.Add(1)
261+ var doneOnce sync.Once
262+ remaining := count
263+
264+ var schedule func()
265+ schedule = func() {
266+ // Stop early if the caller canceled the operation.
267+ if err := ctx.Err(); err != nil {
268+ log.Debug().Err(err).Msg("async webview pool prewarm canceled")
269+ doneOnce.Do(p.wg.Done)
270+ return
271+ }
272+
273+ cb := glib.SourceFunc(func(_ uintptr) bool {
274+ // Stop if the caller canceled the operation.
275+ if err := ctx.Err(); err != nil {
276+ log.Debug().Err(err).Msg("async webview pool prewarm canceled")
277+ doneOnce.Do(p.wg.Done)
278+ return false
279+ }
280+
281+ // Stop if pool is closing/closed.
282+ if p.closed.Load() {
283+ doneOnce.Do(p.wg.Done)
284+ return false
285+ }
286+
287+ // Stop early if we reached capacity.
288+ if len(p.pool) >= p.config.MaxSize {
289+ doneOnce.Do(p.wg.Done)
290+ return false
291+ }
292+
293+ wv, err := p.createWebView(ctx)
294+ if err != nil {
295+ log.Warn().Err(err).Msg("failed to prewarm webview")
296+ } else {
297+ select {
298+ case p.pool <- wv:
299+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
300+ default:
301+ wv.Destroy()
302+ }
303+ }
304+
305+ remaining--
306+ if remaining <= 0 {
307+ log.Debug().Int("pool_size", len(p.pool)).Msg("async webview pool prewarm complete")
308+ doneOnce.Do(p.wg.Done)
309+ return false
310+ }
311+
312+ schedule()
313+ return false
314+ })
315+ glib.IdleAdd(&cb, 0)
316+ }
317+
318+ schedule()
319+}
320+
210321 // prewarmSync creates WebViews synchronously.
211322 func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
212323 log := logging.FromContext(ctx)
M internal/infrastructure/webkit/webview.go +18 -3
@@ -88,6 +88,7 @@ type WebView struct {
8888
8989 // State (protected by mutex)
9090 destroyed atomic.Bool
91+ isRelated bool // true if created via NewWebViewWithRelated (shares web process with parent)
9192 uri string
9293 title string
9394 progress float64
@@ -239,7 +240,8 @@ func (a *findControllerAdapter) DisconnectSignal(id uint32) {
239240
240241 // NewWebView creates a new WebView with the given context and settings.
241242 // Uses the persistent NetworkSession from wkCtx for cookie/data persistence.
242-func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager) (*WebView, error) {
243+// bgColor is optional - if provided, sets background immediately to prevent white flash.
244+func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, bgColor *gdk.RGBA) (*WebView, error) {
243245 log := logging.FromContext(ctx)
244246
245247 if wkCtx == nil || !wkCtx.IsInitialized() {
@@ -252,6 +254,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan
252254 return nil, fmt.Errorf("failed to create webkit webview with network session")
253255 }
254256
257+ // Set background color IMMEDIATELY after creation, before any other operations.
258+ // This prevents white flash by ensuring WebKit's renderer starts with correct color.
259+ if bgColor != nil {
260+ inner.SetBackgroundColor(bgColor)
261+ }
262+
255263 wv := &WebView{
256264 inner: inner,
257265 ucm: inner.GetUserContentManager(),
@@ -294,6 +302,7 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti
294302
295303 wv := &WebView{
296304 inner: inner,
305+ isRelated: true, // Shares web process with parent - must not terminate process on destroy
297306 ucm: inner.GetUserContentManager(),
298307 logger: log.With().Str("component", "webview-popup").Logger(),
299308 signalIDs: make([]uint32, 0, 6),
@@ -981,7 +990,9 @@ func (wv *WebView) Destroy() {
981990
982991 // 5. Terminate the web process to free GPU resources (VA-API, DMA-BUF, GL contexts)
983992 // This is critical to prevent zombie processes that hold video decoder resources.
984- if wv.inner != nil {
993+ // IMPORTANT: Skip for related views (popups) - they share the web process with their
994+ // parent. Terminating the shared process would kill the parent WebView too!
995+ if !wv.isRelated && wv.inner != nil {
985996 wv.inner.TerminateWebProcess()
986997 }
987998
@@ -993,7 +1004,11 @@ func (wv *WebView) Destroy() {
9931004 wv.ucm = nil
9941005 wv.findController = nil
9951006
996- wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed and web process terminated")
1007+ if wv.isRelated {
1008+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("related webview destroyed (process shared with parent)")
1009+ } else {
1010+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed and web process terminated")
1011+ }
9971012 }
9981013
9991014 // RunJavaScript executes script in the specified world (empty for main world).
M internal/infrastructure/xdg/adapter.go +17 -0
@@ -1,6 +1,7 @@
11 package xdg
22
33 import (
4+ "os"
45 "path/filepath"
56
67 "github.com/bnema/dumber/internal/application/port"
@@ -55,4 +56,20 @@ func (a *Adapter) FilterCacheDir() (string, error) {
5556 return config.GetFilterCacheDir()
5657 }
5758
59+func (a *Adapter) ManDir() (string, error) {
60+ // Man pages go to the user's XDG_DATA_HOME/man/man1, not the dumber-specific dir.
61+ // This allows 'man dumber' to work without custom MANPATH configuration.
62+ home, err := os.UserHomeDir()
63+ if err != nil {
64+ return "", err
65+ }
66+
67+ dataHome := os.Getenv("XDG_DATA_HOME")
68+ if dataHome == "" {
69+ dataHome = filepath.Join(home, ".local", "share")
70+ }
71+
72+ return filepath.Join(dataHome, "man", "man1"), nil
73+}
74+
5875 var _ port.XDGPaths = (*Adapter)(nil)
M internal/logging/logger.go +12 -3
@@ -10,9 +10,10 @@ import (
1010 )
1111
1212 const (
13- logFormatConsole = "console"
14- logFormatJSON = "json"
15- logFormatText = "text"
13+ logFormatConsole = "console"
14+ logFormatJSON = "json"
15+ logFormatText = "text"
16+ ConsoleTimeFormat = "15:04:05"
1617 )
1718
1819 // Config holds logging configuration
@@ -146,8 +147,16 @@ func NewFromEnv() zerolog.Logger {
146147 // This is used by main.go to create a logger from the config package's LoggingConfig
147148 // without creating an import cycle.
148149 func NewFromConfigValues(level, format string) zerolog.Logger {
150+ return NewFromConfigValuesWithTimeFormat(level, format, "")
151+}
152+
153+// NewFromConfigValuesWithTimeFormat creates a logger from level/format and overrides time format.
154+func NewFromConfigValuesWithTimeFormat(level, format, timeFormat string) zerolog.Logger {
149155 cfg := DefaultConfig()
150156 cfg.Level = ParseLevel(level)
157+ if timeFormat != "" {
158+ cfg.TimeFormat = timeFormat
159+ }
151160
152161 switch format {
153162 case logFormatJSON:
M internal/ui/adapter/favicon.go +31 -0
@@ -46,19 +46,33 @@ func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
4646 }
4747
4848 // GetTextureByURL returns a cached texture by extracting domain from URL.
49+// For internal dumb:// URLs, returns the app logo texture.
4950 func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
51+ // Handle internal dumb:// scheme URLs
52+ if favicon.IsInternalURL(pageURL) {
53+ return a.GetTexture(favicon.InternalDomain)
54+ }
5055 domain := domainurl.ExtractDomain(pageURL)
5156 return a.GetTexture(domain)
5257 }
5358
5459 // GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
5560 // The callback is invoked on the GTK main thread with the texture (or nil).
61+// For internal dumb:// URLs, returns the app logo texture.
5662 func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
5763 if callback == nil {
5864 return
5965 }
6066
6167 log := logging.FromContext(ctx)
68+
69+ // Handle internal dumb:// scheme URLs - use app logo
70+ if favicon.IsInternalURL(pageURL) {
71+ texture := a.getOrCreateLogoTexture()
72+ callback(texture)
73+ return
74+ }
75+
6276 domain := domainurl.ExtractDomain(pageURL)
6377
6478 // Check texture cache first
@@ -311,6 +325,23 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
311325 }
312326 }
313327
328+// getOrCreateLogoTexture returns the app logo texture, creating it if needed.
329+// The texture is cached under the InternalDomain key.
330+func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
331+ // Check cache first
332+ if texture := a.GetTexture(favicon.InternalDomain); texture != nil {
333+ return texture
334+ }
335+
336+ // Create texture from logo bytes
337+ logoBytes := favicon.GetLogoBytes()
338+ texture := bytesToTexture(logoBytes)
339+ if texture != nil {
340+ a.setTexture(favicon.InternalDomain, texture)
341+ }
342+ return texture
343+}
344+
314345 // bytesToTexture converts raw favicon bytes to a GDK texture.
315346 func bytesToTexture(data []byte) *gdk.Texture {
316347 if len(data) == 0 {
M internal/ui/app.go +427 -12
@@ -87,17 +87,25 @@ type App struct {
8787
8888 // App-level toaster for system notifications (filter status, etc.)
8989 appToaster *component.Toaster
90+ // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
91+ modeToaster *component.Toaster
9092
9193 // Session management
9294 sessionManager *component.SessionManager
95+ tabPicker *component.TabPicker
96+ tabPickerWidget layout.Widget
97+ tabPickerPaneID entity.PaneID
9398 snapshotService *snapshot.Service
9499
95100 // Update management
96101 updateCoord *coordinator.UpdateCoordinator
97102
98103 // ID generator for tabs/panes
99- idCounter uint64
100- idMu sync.Mutex
104+ idCounter uint64
105+ idMu sync.Mutex
106+ firstWebViewShownOnce sync.Once
107+
108+ movePaneToTabUC *usecase.MovePaneToTabUseCase
101109
102110 // lifecycle
103111 cancel context.CancelCauseFunc
@@ -179,7 +187,9 @@ func (a *App) onActivate(ctx context.Context) {
179187 log.Debug().Msg("GTK application activated")
180188
181189 a.applyGTKColorSchemePreference(ctx)
182- a.prewarmWebViewPool(ctx)
190+ // Configure pool background color early (prevents white flash), but avoid
191+ // synchronous prewarming that delays the first navigation on cold start.
192+ a.setupPoolBackgroundColor(ctx)
183193
184194 if err := a.createMainWindow(ctx); err != nil {
185195 log.Error().Err(err).Msg("failed to create main window")
@@ -195,9 +205,12 @@ func (a *App) onActivate(ctx context.Context) {
195205 a.initOmniboxConfig(ctx)
196206 a.initFindBarConfig()
197207 a.initSessionManager(ctx)
208+ a.initTabPicker(ctx)
198209 a.initSnapshotService(ctx)
199210 a.initUpdateCoordinator(ctx)
200211 a.createInitialTab(ctx)
212+ // Prewarm the remaining WebViews after the initial tab starts loading.
213+ a.prewarmWebViewPoolAsync(ctx)
201214 a.finalizeActivation(ctx)
202215 }
203216
@@ -223,19 +236,31 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
223236 Msg("set gtk-application-prefer-dark-theme")
224237 }
225238
226-func (a *App) prewarmWebViewPool(ctx context.Context) {
239+func (a *App) setupPoolBackgroundColor(ctx context.Context) {
240+ log := logging.FromContext(ctx)
241+
227242 // Set theme background color on pool to eliminate white flash.
228- // Must be done before prewarming so WebViews get the correct color.
229- if a.pool != nil && a.deps != nil && a.deps.Theme != nil {
230- r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
231- a.pool.SetBackgroundColor(r, g, b, alpha)
243+ // Must be done before any WebView creation so WebViews get the correct color.
244+ if a.pool == nil {
245+ log.Debug().Msg("webview pool not available; skipping background color setup")
246+ return
232247 }
248+ if a.deps == nil || a.deps.Theme == nil {
249+ log.Debug().Msg("theme not available; skipping webview pool background color setup")
250+ return
251+ }
252+
253+ r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
254+ a.pool.SetBackgroundColor(r, g, b, alpha)
255+ log.Debug().Msg("configured webview pool background color")
256+}
233257
234- // Prewarm WebView pool now that GTK is initialized.
258+func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
259+ // Prewarm WebView pool after startup so cold-start navigation is not blocked.
235260 if a.pool == nil {
236261 return
237262 }
238- a.pool.Prewarm(ctx, 0)
263+ a.pool.PrewarmAsync(ctx, 0)
239264 }
240265
241266 func (a *App) createMainWindow(ctx context.Context) error {
@@ -279,6 +304,18 @@ func (a *App) initAppToasterOverlay() {
279304 return
280305 }
281306 a.mainWindow.AddOverlay(gtkWidget)
307+
308+ // Create mode indicator toaster for modal mode notifications.
309+ a.modeToaster = component.NewToaster(a.widgetFactory)
310+ modeToasterWidget := a.modeToaster.Widget()
311+ if modeToasterWidget == nil {
312+ return
313+ }
314+ modeGtkWidget := modeToasterWidget.GtkWidget()
315+ if modeGtkWidget == nil {
316+ return
317+ }
318+ a.mainWindow.AddOverlay(modeGtkWidget)
282319 }
283320
284321 func (a *App) initFocusAndBorderOverlay() {
@@ -317,10 +354,13 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
317354 a.handleModeChange(ctx, from, to)
318355 })
319356 a.keyboardHandler.SetShouldBypassInput(func() bool {
320- // Bypass keyboard handler when session manager is visible
357+ // Bypass keyboard handler when modals are visible
321358 if a.sessionManager != nil && a.sessionManager.IsVisible() {
322359 return true
323360 }
361+ if a.tabPicker != nil && a.tabPicker.IsVisible() {
362+ return true
363+ }
324364 wsView := a.activeWorkspaceView()
325365 if wsView == nil {
326366 return false
@@ -349,13 +389,23 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
349389
350390 log := logging.FromContext(ctx)
351391
392+ // Convert config shortcuts to use case type
393+ shortcuts := make(map[string]usecase.SearchShortcut, len(a.deps.Config.SearchShortcuts))
394+ for key, shortcut := range a.deps.Config.SearchShortcuts {
395+ shortcuts[key] = usecase.SearchShortcut{
396+ URL: shortcut.URL,
397+ Description: shortcut.Description,
398+ }
399+ }
400+ shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
401+
352402 // Store omnibox config (omnibox is created per-pane via WorkspaceView).
353403 a.omniboxCfg = component.OmniboxConfig{
354404 HistoryUC: a.deps.HistoryUC,
355405 FavoritesUC: a.deps.FavoritesUC,
356406 FaviconAdapter: a.faviconAdapter,
357407 CopyURLUC: a.deps.CopyURLUC,
358- Shortcuts: a.deps.Config.SearchShortcuts,
408+ ShortcutsUC: shortcutsUC,
359409 DefaultSearch: a.deps.Config.DefaultSearchEngine,
360410 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
361411 UIScale: a.deps.Config.DefaultUIScale,
@@ -464,6 +514,280 @@ func (a *App) ToggleSessionManager(ctx context.Context) {
464514 a.sessionManager.Toggle(ctx)
465515 }
466516
517+func (a *App) attachTabPickerToActivePane() {
518+ if a.tabPicker == nil || a.widgetFactory == nil {
519+ return
520+ }
521+ wsView := a.activeWorkspaceView()
522+ if wsView == nil {
523+ return
524+ }
525+
526+ activeTab := a.tabs.ActiveTab()
527+ if activeTab == nil || activeTab.Workspace == nil {
528+ return
529+ }
530+ activePaneID := activeTab.Workspace.ActivePaneID
531+ if activePaneID == "" {
532+ return
533+ }
534+ pv := wsView.GetPaneView(activePaneID)
535+ if pv == nil {
536+ return
537+ }
538+
539+ if a.tabPickerWidget == nil {
540+ a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)
541+ if a.tabPickerWidget == nil {
542+ return
543+ }
544+ }
545+
546+ // If currently attached to a different pane overlay, detach.
547+ if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
548+ for _, view := range a.workspaceViews {
549+ if view == nil {
550+ continue
551+ }
552+ if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {
553+ if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
554+ oldPV.RemoveOverlayWidget(a.tabPickerWidget)
555+ } else if parent != nil {
556+ a.tabPickerWidget.Unparent()
557+ }
558+ break
559+ }
560+ }
561+ }
562+
563+ // Ensure the widget can be reparented.
564+ if parent := a.tabPickerWidget.GetParent(); parent != nil {
565+ a.tabPickerWidget.Unparent()
566+ }
567+
568+ a.tabPicker.SetParentOverlay(pv.Overlay())
569+ pv.AddOverlayWidget(a.tabPickerWidget)
570+ a.tabPickerPaneID = activePaneID
571+}
572+
573+func (a *App) HandleMovePaneToTab(ctx context.Context) error {
574+ if a.movePaneToTabUC == nil {
575+ return nil
576+ }
577+ if a.tabPicker == nil {
578+ return nil
579+ }
580+
581+ sourceTab := a.tabs.ActiveTab()
582+ if sourceTab == nil || sourceTab.Workspace == nil {
583+ return nil
584+ }
585+
586+ // If there is only 1 tab, auto-create a new tab and move there.
587+ if a.tabs.Count() <= 1 {
588+ return a.MoveActivePaneToTab(ctx, "")
589+ }
590+
591+ items := make([]component.TabPickerItem, 0, a.tabs.Count())
592+ for _, tab := range a.tabs.Tabs {
593+ if tab == nil {
594+ continue
595+ }
596+ if tab.ID == sourceTab.ID {
597+ continue
598+ }
599+ items = append(items, component.TabPickerItem{
600+ TabID: tab.ID,
601+ Title: tab.Title(),
602+ IsNew: false,
603+ Index: tab.Position,
604+ })
605+ }
606+ items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
607+
608+ a.attachTabPickerToActivePane()
609+ a.tabPicker.Show(ctx, items)
610+ return nil
611+}
612+
613+func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
614+ if a.movePaneToTabUC == nil {
615+ return nil
616+ }
617+ active := a.tabs.ActiveTab()
618+ if active == nil {
619+ return nil
620+ }
621+
622+ nextPos := active.Position + 1
623+ if nextPos >= a.tabs.Count() {
624+ // Create a new tab on the right.
625+ return a.MoveActivePaneToTab(ctx, "")
626+ }
627+ next := a.tabs.TabAt(nextPos)
628+ if next == nil {
629+ return nil
630+ }
631+ return a.MoveActivePaneToTab(ctx, next.ID)
632+}
633+
634+func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) error {
635+ in, sourceTab := a.buildMovePaneToTabInput(targetTabID)
636+ if in == nil {
637+ return nil
638+ }
639+
640+ out, err := a.movePaneToTabUC.Execute(*in)
641+ if err != nil {
642+ return err
643+ }
644+ if out == nil || out.TargetTab == nil {
645+ return nil
646+ }
647+
648+ a.applyMovePaneToTabUI(ctx, out, sourceTab)
649+ a.switchToTargetTabIfConfigured(ctx, out)
650+ a.updateTabBarVisibility(ctx)
651+ a.MarkDirty()
652+ return nil
653+}
654+
655+func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
656+ if a.movePaneToTabUC == nil {
657+ return nil, nil
658+ }
659+ activeTab := a.tabs.ActiveTab()
660+ if activeTab == nil || activeTab.Workspace == nil {
661+ return nil, nil
662+ }
663+ sourcePaneID := activeTab.Workspace.ActivePaneID
664+ if sourcePaneID == "" {
665+ return nil, nil
666+ }
667+
668+ in := &usecase.MovePaneToTabInput{
669+ TabList: a.tabs,
670+ SourceTabID: activeTab.ID,
671+ SourcePaneID: sourcePaneID,
672+ TargetTabID: targetTabID,
673+ }
674+ return in, activeTab
675+}
676+
677+func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToTabOutput, sourceTab *entity.Tab) {
678+ if out == nil || out.TargetTab == nil {
679+ return
680+ }
681+ sourceTabID := entity.TabID("")
682+ if sourceTab != nil {
683+ sourceTabID = sourceTab.ID
684+ }
685+
686+ if out.SourceTabClosed {
687+ a.removeSourceTabUI(sourceTabID)
688+ }
689+ if out.NewTabCreated {
690+ a.ensureTargetTabUI(ctx, out.TargetTab)
691+ }
692+
693+ if !out.SourceTabClosed {
694+ a.rebuildAndAttachWorkspace(ctx, sourceTabID, sourceTab)
695+ }
696+ a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
697+}
698+
699+func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
700+ delete(a.workspaceViews, sourceTabID)
701+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
702+ a.mainWindow.TabBar().RemoveTab(sourceTabID)
703+ }
704+}
705+
706+func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) {
707+ if tab == nil {
708+ return
709+ }
710+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
711+ a.mainWindow.TabBar().AddTab(tab)
712+ }
713+ a.createWorkspaceViewWithoutAttach(ctx, tab)
714+}
715+
716+func (a *App) rebuildAndAttachWorkspace(ctx context.Context, tabID entity.TabID, tab *entity.Tab) {
717+ wsView := a.workspaceViews[tabID]
718+ if wsView == nil {
719+ return
720+ }
721+ _ = wsView.Rebuild(ctx)
722+
723+ if tab == nil || tab.Workspace == nil || a.contentCoord == nil {
724+ return
725+ }
726+ a.contentCoord.AttachToWorkspace(ctx, tab.Workspace, wsView)
727+}
728+
729+func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
730+ if out == nil || out.TargetTab == nil {
731+ return
732+ }
733+
734+ switchToTarget := config.Get().Workspace.SwitchToTabOnMove
735+ if out.SourceTabClosed {
736+ switchToTarget = true
737+ }
738+ if !switchToTarget {
739+ return
740+ }
741+
742+ a.tabs.SetActive(out.TargetTab.ID)
743+ if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
744+ a.mainWindow.TabBar().SetActive(out.TargetTab.ID)
745+ }
746+ a.switchWorkspaceView(ctx, out.TargetTab.ID)
747+}
748+
749+func (a *App) updateTabBarVisibility(ctx context.Context) {
750+ if a.tabCoord != nil {
751+ a.tabCoord.UpdateBarVisibility(ctx)
752+ }
753+}
754+
755+func (a *App) initTabPicker(ctx context.Context) {
756+ log := logging.FromContext(ctx)
757+
758+ if a.deps == nil || a.deps.Config == nil {
759+ log.Debug().Msg("deps/config not available, skipping tab picker")
760+ return
761+ }
762+
763+ a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
764+ UIScale: a.deps.Config.DefaultUIScale,
765+ OnClose: func() {
766+ log.Debug().Msg("tab picker closed")
767+ },
768+ OnSelect: func(item component.TabPickerItem) {
769+ cb := glib.SourceFunc(func(_ uintptr) bool {
770+ var targetID entity.TabID
771+ if !item.IsNew {
772+ targetID = item.TabID
773+ }
774+ if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
775+ log.Warn().Err(err).Msg("move pane to tab failed")
776+ }
777+ return false
778+ })
779+ glib.IdleAdd(&cb, 0)
780+ },
781+ })
782+
783+ if a.tabPicker == nil {
784+ log.Warn().Msg("failed to create tab picker")
785+ return
786+ }
787+
788+ log.Debug().Msg("tab picker initialized")
789+}
790+
467791 func (a *App) initSnapshotService(ctx context.Context) {
468792 log := logging.FromContext(ctx)
469793
@@ -630,6 +954,9 @@ func (a *App) finalizeActivation(ctx context.Context) {
630954
631955 // Start async filter loading after window is visible.
632956 a.initFilteringAsync(ctx)
957+
958+ // Check for config migration after window is visible.
959+ a.checkConfigMigration(ctx)
633960 }
634961
635962 // onShutdown is called when the GTK application is shutting down.
@@ -732,6 +1059,20 @@ func (a *App) initCoordinators(ctx context.Context) {
7321059 })
7331060 }
7341061
1062+ // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)
1063+ a.contentCoord.SetOnFullscreenChanged(func(entering bool) {
1064+ if a.mainWindow == nil || a.mainWindow.TabBar() == nil {
1065+ return
1066+ }
1067+ if entering {
1068+ // Hide tab bar when entering fullscreen video
1069+ a.mainWindow.TabBar().SetVisible(false)
1070+ } else {
1071+ // Restore tab bar visibility based on normal logic
1072+ a.tabCoord.UpdateBarVisibility(ctx)
1073+ }
1074+ })
1075+
7351076 // 3. Workspace Coordinator
7361077 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
7371078 PanesUC: a.panesUC,
@@ -782,6 +1123,9 @@ func (a *App) initCoordinators(ctx context.Context) {
7821123 return nil
7831124 })
7841125
1126+ // Move pane use case (cross-tab)
1127+ a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
1128+
7851129 // 4. Navigation Coordinator
7861130 a.navCoord = coordinator.NewNavigationCoordinator(
7871131 ctx,
@@ -811,6 +1155,25 @@ func (a *App) initCoordinators(ctx context.Context) {
8111155 a.MarkDirty()
8121156 })
8131157
1158+ // Hide loading skeleton once the WebView paints
1159+ a.contentCoord.SetOnWebViewShown(func(paneID entity.PaneID) {
1160+ // The WebView can be shown while its pane is in a background tab, so scan
1161+ // all WorkspaceViews rather than only the active one.
1162+ for _, wsView := range a.workspaceViews {
1163+ if wsView == nil {
1164+ continue
1165+ }
1166+ if pv := wsView.GetPaneView(paneID); pv != nil {
1167+ pv.HideLoadingSkeleton()
1168+ }
1169+ }
1170+ if a.deps != nil && a.deps.OnFirstWebViewShown != nil {
1171+ a.firstWebViewShownOnce.Do(func() {
1172+ a.deps.OnFirstWebViewShown(ctx)
1173+ })
1174+ }
1175+ })
1176+
8141177 // 5. Keyboard Dispatcher
8151178 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
8161179 ctx,
@@ -837,6 +1200,12 @@ func (a *App) initCoordinators(ctx context.Context) {
8371200 a.CloseFindBar(ctx)
8381201 return nil
8391202 })
1203+ a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {
1204+ return a.HandleMovePaneToTab(ctx)
1205+ })
1206+ a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
1207+ return a.HandleMovePaneToNextTab(ctx)
1208+ })
8401209
8411210 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
8421211 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {
@@ -925,6 +1294,52 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
9251294 if a.borderMgr != nil {
9261295 a.borderMgr.OnModeChange(ctx, from, to)
9271296 }
1297+
1298+ // Show/hide mode indicator toaster based on config.
1299+ a.updateModeIndicatorToaster(ctx, to)
1300+}
1301+
1302+// updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
1303+func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
1304+ if a.modeToaster == nil {
1305+ return
1306+ }
1307+
1308+ // Check if mode indicator toaster is enabled in config.
1309+ if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {
1310+ a.modeToaster.Hide()
1311+ return
1312+ }
1313+
1314+ if mode == input.ModeNormal {
1315+ a.modeToaster.Hide()
1316+ return
1317+ }
1318+
1319+ // Show persistent toaster at bottom-left with mode display name.
1320+ // Mode class is applied atomically with Show() to avoid visual flicker.
1321+ modeClass := getModeToastClass(mode)
1322+ a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
1323+ component.WithDuration(0), // Persistent until mode exits.
1324+ component.WithPosition(component.ToastPositionBottomLeft),
1325+ component.WithModeClass(modeClass),
1326+ )
1327+}
1328+
1329+// getModeToastClass returns the CSS class for the given mode's toast styling.
1330+func getModeToastClass(mode input.Mode) string {
1331+ switch mode {
1332+ case input.ModePane:
1333+ return "toast-pane-mode"
1334+ case input.ModeTab:
1335+ return "toast-tab-mode"
1336+ case input.ModeSession:
1337+ return "toast-session-mode"
1338+ case input.ModeResize:
1339+ return "toast-resize-mode"
1340+ default:
1341+ return ""
1342+ }
9281343 }
9291344
9301345 func (a *App) clearResizeModeBorder() {
A internal/ui/component/loading_skeleton.go +97 -0
@@ -0,0 +1,97 @@
1+package component
2+
3+import (
4+ "github.com/bnema/dumber/internal/ui/layout"
5+ "github.com/jwijenbergh/puregotk/v4/gtk"
6+)
7+
8+// LoadingSkeleton displays a themed loading placeholder.
9+// It is intended to be embedded as an overlay while primary content is loading.
10+// Shows a faded app logo and spinner.
11+type LoadingSkeleton struct {
12+ container layout.BoxWidget
13+ content layout.BoxWidget
14+ spinner layout.SpinnerWidget
15+ logo layout.ImageWidget
16+}
17+
18+const (
19+ loadingSkeletonSpacing = 6
20+ loadingSkeletonLogoSize = 512
21+)
22+
23+func NewLoadingSkeleton(factory layout.WidgetFactory) *LoadingSkeleton {
24+ container := factory.NewBox(layout.OrientationVertical, 0)
25+ container.SetHexpand(true)
26+ container.SetVexpand(true)
27+ container.SetHalign(gtk.AlignFillValue)
28+ container.SetValign(gtk.AlignFillValue)
29+ container.SetCanFocus(false)
30+ container.SetCanTarget(false)
31+ container.AddCssClass("loading-skeleton")
32+
33+ content := factory.NewBox(layout.OrientationVertical, loadingSkeletonSpacing)
34+ content.SetHalign(gtk.AlignCenterValue)
35+ content.SetValign(gtk.AlignCenterValue)
36+ content.SetCanFocus(false)
37+ content.SetCanTarget(false)
38+ content.AddCssClass("loading-skeleton-content")
39+
40+ spinner := factory.NewSpinner()
41+ spinner.SetHalign(gtk.AlignCenterValue)
42+ spinner.SetValign(gtk.AlignCenterValue)
43+ spinner.SetCanFocus(false)
44+ spinner.SetCanTarget(false)
45+ spinner.SetSizeRequest(32, 32)
46+ spinner.AddCssClass("loading-skeleton-spinner")
47+
48+ // Faded app logo
49+ logo := factory.NewImage()
50+ logo.SetHalign(gtk.AlignCenterValue)
51+ logo.SetValign(gtk.AlignCenterValue)
52+ logo.SetCanFocus(false)
53+ logo.SetCanTarget(false)
54+ logo.SetSizeRequest(loadingSkeletonLogoSize, loadingSkeletonLogoSize)
55+ logo.SetPixelSize(loadingSkeletonLogoSize)
56+ logo.AddCssClass("loading-skeleton-logo")
57+
58+ // Set logo texture if available
59+ if logoTexture := GetLogoTexture(); logoTexture != nil {
60+ logo.SetFromPaintable(logoTexture)
61+ }
62+
63+ // Layout: logo, spinner (vertically centered)
64+ content.Append(logo)
65+ content.Append(spinner)
66+ container.Append(content)
67+
68+ ls := &LoadingSkeleton{
69+ container: container,
70+ content: content,
71+ spinner: spinner,
72+ logo: logo,
73+ }
74+ ls.SetVisible(true)
75+ return ls
76+}
77+
78+func (ls *LoadingSkeleton) Widget() layout.Widget {
79+ if ls == nil {
80+ return nil
81+ }
82+ return ls.container
83+}
84+
85+func (ls *LoadingSkeleton) SetVisible(visible bool) {
86+ if ls == nil || ls.container == nil {
87+ return
88+ }
89+ ls.container.SetVisible(visible)
90+ if ls.spinner != nil {
91+ if visible {
92+ ls.spinner.Start()
93+ } else {
94+ ls.spinner.Stop()
95+ }
96+ }
97+}
A internal/ui/component/loading_skeleton_test.go +121 -0
@@ -0,0 +1,121 @@
1+package component_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/mock"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/dumber/internal/ui/component"
10+ "github.com/bnema/dumber/internal/ui/layout"
11+ "github.com/bnema/dumber/internal/ui/layout/mocks"
12+)
13+
14+func TestNewLoadingSkeleton_ShowsAndStartsSpinner(t *testing.T) {
15+ mockFactory := mocks.NewMockWidgetFactory(t)
16+ mockContainer := mocks.NewMockBoxWidget(t)
17+ mockContent := mocks.NewMockBoxWidget(t)
18+ mockSpinner := mocks.NewMockSpinnerWidget(t)
19+ mockLogo := mocks.NewMockImageWidget(t)
20+
21+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
22+ mockContainer.EXPECT().SetHexpand(true).Once()
23+ mockContainer.EXPECT().SetVexpand(true).Once()
24+ mockContainer.EXPECT().SetHalign(mock.Anything).Once()
25+ mockContainer.EXPECT().SetValign(mock.Anything).Once()
26+ mockContainer.EXPECT().SetCanFocus(false).Once()
27+ mockContainer.EXPECT().SetCanTarget(false).Once()
28+ mockContainer.EXPECT().AddCssClass("loading-skeleton").Once()
29+
30+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockContent).Once()
31+ mockContent.EXPECT().SetHalign(mock.Anything).Once()
32+ mockContent.EXPECT().SetValign(mock.Anything).Once()
33+ mockContent.EXPECT().SetCanFocus(false).Once()
34+ mockContent.EXPECT().SetCanTarget(false).Once()
35+ mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
36+
37+ mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once()
38+ mockSpinner.EXPECT().SetHalign(mock.Anything).Once()
39+ mockSpinner.EXPECT().SetValign(mock.Anything).Once()
40+ mockSpinner.EXPECT().SetCanFocus(false).Once()
41+ mockSpinner.EXPECT().SetCanTarget(false).Once()
42+ mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
43+ mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
44+
45+ mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
46+ mockLogo.EXPECT().SetHalign(mock.Anything).Once()
47+ mockLogo.EXPECT().SetValign(mock.Anything).Once()
48+ mockLogo.EXPECT().SetCanFocus(false).Once()
49+ mockLogo.EXPECT().SetCanTarget(false).Once()
50+ mockLogo.EXPECT().SetSizeRequest(512, 512).Once()
51+ mockLogo.EXPECT().SetPixelSize(512).Once()
52+ mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
53+ mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
54+
55+ mockContent.EXPECT().Append(mockLogo).Once()
56+ mockContent.EXPECT().Append(mockSpinner).Once()
57+ mockContainer.EXPECT().Append(mockContent).Once()
58+
59+ // NewLoadingSkeleton ends by calling SetVisible(true).
60+ mockContainer.EXPECT().SetVisible(true).Once()
61+ mockSpinner.EXPECT().Start().Once()
62+
63+ ls := component.NewLoadingSkeleton(mockFactory)
64+ require.NotNil(t, ls)
65+}
66+
67+func TestLoadingSkeleton_SetVisible_StopsSpinnerWhenHidden(t *testing.T) {
68+ mockFactory := mocks.NewMockWidgetFactory(t)
69+ mockContainer := mocks.NewMockBoxWidget(t)
70+ mockContent := mocks.NewMockBoxWidget(t)
71+ mockSpinner := mocks.NewMockSpinnerWidget(t)
72+ mockLogo := mocks.NewMockImageWidget(t)
73+
74+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
75+ mockContainer.EXPECT().SetHexpand(true).Once()
76+ mockContainer.EXPECT().SetVexpand(true).Once()
77+ mockContainer.EXPECT().SetHalign(mock.Anything).Once()
78+ mockContainer.EXPECT().SetValign(mock.Anything).Once()
79+ mockContainer.EXPECT().SetCanFocus(false).Once()
80+ mockContainer.EXPECT().SetCanTarget(false).Once()
81+ mockContainer.EXPECT().AddCssClass("loading-skeleton").Once()
82+
83+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockContent).Once()
84+ mockContent.EXPECT().SetHalign(mock.Anything).Once()
85+ mockContent.EXPECT().SetValign(mock.Anything).Once()
86+ mockContent.EXPECT().SetCanFocus(false).Once()
87+ mockContent.EXPECT().SetCanTarget(false).Once()
88+ mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
89+
90+ mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once()
91+ mockSpinner.EXPECT().SetHalign(mock.Anything).Once()
92+ mockSpinner.EXPECT().SetValign(mock.Anything).Once()
93+ mockSpinner.EXPECT().SetCanFocus(false).Once()
94+ mockSpinner.EXPECT().SetCanTarget(false).Once()
95+ mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
96+ mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
97+
98+ mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
99+ mockLogo.EXPECT().SetHalign(mock.Anything).Once()
100+ mockLogo.EXPECT().SetValign(mock.Anything).Once()
101+ mockLogo.EXPECT().SetCanFocus(false).Once()
102+ mockLogo.EXPECT().SetCanTarget(false).Once()
103+ mockLogo.EXPECT().SetSizeRequest(512, 512).Once()
104+ mockLogo.EXPECT().SetPixelSize(512).Once()
105+ mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
106+ mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
107+
108+ mockContent.EXPECT().Append(mockLogo).Once()
109+ mockContent.EXPECT().Append(mockSpinner).Once()
110+ mockContainer.EXPECT().Append(mockContent).Once()
111+
112+ mockContainer.EXPECT().SetVisible(true).Once()
113+ mockSpinner.EXPECT().Start().Once()
114+
115+ ls := component.NewLoadingSkeleton(mockFactory)
116+ require.NotNil(t, ls)
117+
118+ mockContainer.EXPECT().SetVisible(false).Once()
119+ mockSpinner.EXPECT().Stop().Once()
120+ ls.SetVisible(false)
121+}
A internal/ui/component/logo.go +37 -0
@@ -0,0 +1,37 @@
1+// Package component provides reusable GTK UI components.
2+package component
3+
4+import (
5+ "sync"
6+
7+ "github.com/bnema/dumber/assets"
8+ "github.com/jwijenbergh/puregotk/v4/gdk"
9+ "github.com/jwijenbergh/puregotk/v4/glib"
10+)
11+
12+var (
13+ logoTexture *gdk.Texture
14+ logoTextureOnce sync.Once
15+)
16+
17+// GetLogoTexture returns a cached GDK texture of the dumber logo.
18+// Safe to call from any goroutine; lazily initializes on first call.
19+// Returns nil if the logo cannot be loaded.
20+func GetLogoTexture() *gdk.Texture {
21+ logoTextureOnce.Do(func() {
22+ data := assets.LogoSVG
23+ if len(data) == 0 {
24+ return
25+ }
26+ bytes := glib.NewBytes(data, uint(len(data)))
27+ if bytes == nil {
28+ return
29+ }
30+ texture, err := gdk.NewTextureFromBytes(bytes)
31+ if err != nil {
32+ return
33+ }
34+ logoTexture = texture
35+ })
36+ return logoTexture
37+}
M internal/ui/component/modal_sizing.go +15 -0
@@ -65,6 +65,21 @@ var SessionManagerListDefaults = ListDisplayDefaults{
6565 MaxResults: 50,
6666 }
6767
68+// TabPickerSizeDefaults provides default sizing for tab picker modal.
69+var TabPickerSizeDefaults = ModalSizeConfig{
70+ WidthPct: 0.6,
71+ MaxWidth: 600,
72+ TopMarginPct: 0.15,
73+ FallbackWidth: 600,
74+ FallbackHeight: 600,
75+}
76+
77+// TabPickerListDefaults provides display limits for tab picker modal.
78+var TabPickerListDefaults = ListDisplayDefaults{
79+ MaxVisibleRows: 8,
80+ MaxResults: 20,
81+}
82+
6883 // CalculateModalDimensions computes width and top margin based on parent overlay.
6984 // Returns calculated width and top margin in pixels.
7085 func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {
M internal/ui/component/omnibox.go +382 -71
@@ -10,7 +10,6 @@ import (
1010 "github.com/bnema/dumber/internal/application/usecase"
1111 "github.com/bnema/dumber/internal/domain/entity"
1212 "github.com/bnema/dumber/internal/domain/url"
13- "github.com/bnema/dumber/internal/infrastructure/config"
1413 "github.com/bnema/dumber/internal/logging"
1514 "github.com/bnema/dumber/internal/ui/adapter"
1615 "github.com/bnema/dumber/internal/ui/input"
@@ -22,6 +21,7 @@ import (
2221
2322 const (
2423 debounceDelayMs = 150
24+ endBoxSpacing = 6
2525 )
2626
2727 // ViewMode distinguishes history search from favorites display.
@@ -37,6 +37,14 @@ type Suggestion struct {
3737 URL string
3838 Title string
3939 FaviconURL string
40+ IsFavorite bool // Indicates if this URL is also bookmarked as a favorite
41+}
42+
43+// BangSuggestion represents a configured bang shortcut.
44+// A bang is invoked by typing "!<key>".
45+type BangSuggestion struct {
46+ Key string
47+ Description string
4048 }
4149
4250 // Favorite represents a bookmarked URL.
@@ -57,6 +65,7 @@ type Omnibox struct {
5765 historyBtn *gtk.Button
5866 favoritesBtn *gtk.Button
5967 zoomLabel *gtk.Label
68+ bangBadge *gtk.Label
6069 entry *gtk.SearchEntry
6170 scrolledWin *gtk.ScrolledWindow
6271 listBox *gtk.ListBox
@@ -65,20 +74,23 @@ type Omnibox struct {
6574 parentOverlay layout.OverlayWidget
6675
6776 // State
68- mu sync.RWMutex
69- visible bool
70- viewMode ViewMode
71- selectedIndex int
72- suggestions []Suggestion
73- favorites []Favorite
74- hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
77+ mu sync.RWMutex
78+ visible bool
79+ viewMode ViewMode
80+ selectedIndex int
81+ suggestions []Suggestion
82+ favorites []Favorite
83+ bangSuggestions []BangSuggestion
84+ bangMode bool
85+ detectedBang string
86+ hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite)
7587
7688 // Dependencies
7789 historyUC *usecase.SearchHistoryUseCase
7890 favoritesUC *usecase.ManageFavoritesUseCase
7991 faviconAdapter *adapter.FaviconAdapter
8092 copyURLUC *usecase.CopyURLUseCase
81- shortcuts map[string]config.SearchShortcut
93+ shortcutsUC *usecase.SearchShortcutsUseCase
8294 defaultSearch string
8395 initialBehavior string
8496 ctx context.Context
@@ -86,7 +98,7 @@ type Omnibox struct {
8698 // Callbacks
8799 onNavigate func(url string)
88100 onClose func()
89- onToast func(message string)
101+ onToast func(ctx context.Context, message string, level ToastLevel)
90102
91103 // Debouncing
92104 debounceTimer *time.Timer
@@ -114,12 +126,12 @@ type OmniboxConfig struct {
114126 FavoritesUC *usecase.ManageFavoritesUseCase
115127 FaviconAdapter *adapter.FaviconAdapter
116128 CopyURLUC *usecase.CopyURLUseCase
117- Shortcuts map[string]config.SearchShortcut
129+ ShortcutsUC *usecase.SearchShortcutsUseCase
118130 DefaultSearch string
119131 InitialBehavior string
120- UIScale float64 // UI scale for favicon sizing
121- OnNavigate func(url string) // Callback when user navigates via omnibox
122- OnToast func(message string) // Callback to show toast notification
132+ UIScale float64 // UI scale for favicon sizing
133+ OnNavigate func(url string) // Callback when user navigates via omnibox
134+ OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification
123135 }
124136
125137 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -139,7 +151,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
139151 favoritesUC: cfg.FavoritesUC,
140152 faviconAdapter: cfg.FaviconAdapter,
141153 copyURLUC: cfg.CopyURLUC,
142- shortcuts: cfg.Shortcuts,
154+ shortcutsUC: cfg.ShortcutsUC,
143155 defaultSearch: cfg.DefaultSearch,
144156 initialBehavior: cfg.InitialBehavior,
145157 onToast: cfg.OnToast,
@@ -350,12 +362,16 @@ func (o *Omnibox) initHeader() error {
350362 o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb)
351363 o.favoritesBtn.ConnectClicked(&favoritesClickCb)
352364
365+ o.bangBadge = gtk.NewLabel(nil)
366+ if o.bangBadge != nil {
367+ o.bangBadge.AddCssClass("omnibox-bang-badge")
368+ o.bangBadge.SetVisible(false)
369+ }
370+
353371 o.zoomLabel = gtk.NewLabel(nil)
354372 if o.zoomLabel != nil {
355373 o.zoomLabel.AddCssClass("omnibox-zoom-indicator")
356374 o.zoomLabel.SetVisible(false)
357- o.zoomLabel.SetHalign(gtk.AlignEndValue)
358- o.zoomLabel.SetHexpand(true)
359375 }
360376 return nil
361377 }
@@ -368,7 +384,7 @@ func (o *Omnibox) initEntry() error {
368384 o.entry.AddCssClass("omnibox-entry")
369385 o.entry.SetHexpand(true)
370386
371- placeholder := "Search history or enter URL..."
387+ placeholder := "Search history or enter URL… (! lists bangs)"
372388 o.entry.SetPlaceholderText(&placeholder)
373389 return nil
374390 }
@@ -412,8 +428,18 @@ func (o *Omnibox) initList() error {
412428 func (o *Omnibox) assembleWidgets() {
413429 o.headerBox.Append(&o.historyBtn.Widget)
414430 o.headerBox.Append(&o.favoritesBtn.Widget)
415- if o.zoomLabel != nil {
416- o.headerBox.Append(&o.zoomLabel.Widget)
431+
432+ endBox := gtk.NewBox(gtk.OrientationHorizontalValue, endBoxSpacing)
433+ if endBox != nil {
434+ endBox.SetHexpand(true)
435+ endBox.SetHalign(gtk.AlignEndValue)
436+ if o.bangBadge != nil {
437+ endBox.Append(&o.bangBadge.Widget)
438+ }
439+ if o.zoomLabel != nil {
440+ endBox.Append(&o.zoomLabel.Widget)
441+ }
442+ o.headerBox.Append(&endBox.Widget)
417443 }
418444
419445 o.scrolledWin.SetChild(&o.listBox.Widget)
@@ -577,6 +603,14 @@ func (o *Omnibox) performSearch() {
577603 o.lastQuery = query
578604 o.debounceMu.Unlock()
579605
606+ if strings.HasPrefix(query, "!") {
607+ o.updateBangDetection(query)
608+ o.loadBangSuggestions(query)
609+ return
610+ }
611+
612+ o.clearBangState()
613+
580614 if mode == ViewModeFavorites {
581615 o.loadFavorites(query)
582616 return
@@ -596,21 +630,46 @@ func (o *Omnibox) performSearch() {
596630 return
597631 }
598632
599- searchInput := usecase.SearchInput{
600- Query: query,
601- Limit: OmniboxListDefaults.MaxResults,
633+ // Run history search and favorite URL fetch in parallel
634+ type searchResult struct {
635+ output *usecase.SearchOutput
636+ err error
602637 }
603- output, err := o.historyUC.Search(ctx, searchInput)
604- if err != nil {
605- log.Error().Err(err).Msg("history search failed")
638+ searchCh := make(chan searchResult, 1)
639+ favCh := make(chan map[string]struct{}, 1)
640+
641+ go func() {
642+ searchInput := usecase.SearchInput{
643+ Query: query,
644+ Limit: OmniboxListDefaults.MaxResults,
645+ }
646+ output, err := o.historyUC.Search(ctx, searchInput)
647+ searchCh <- searchResult{output, err}
648+ }()
649+
650+ go func() {
651+ favCh <- o.getFavoriteURLs(ctx)
652+ }()
653+
654+ // Wait for both results
655+ sr := <-searchCh
656+ favoriteURLs := <-favCh
657+
658+ if sr.err != nil {
659+ log.Error().Err(sr.err).Msg("history search failed")
660+ return
661+ }
662+ if sr.output == nil {
606663 return
607664 }
608665
609- suggestions := make([]Suggestion, 0, len(output.Matches))
610- for _, r := range output.Matches {
666+ suggestions := make([]Suggestion, 0, len(sr.output.Matches))
667+ for _, r := range sr.output.Matches {
668+ _, isFav := favoriteURLs[r.Entry.URL]
611669 suggestions = append(suggestions, Suggestion{
612- URL: r.Entry.URL,
613- Title: r.Entry.Title,
670+ URL: r.Entry.URL,
671+ Title: r.Entry.Title,
672+ IsFavorite: isFav,
614673 })
615674 }
616675
@@ -636,17 +695,43 @@ func (o *Omnibox) loadInitialHistory() {
636695 return
637696
638697 case "most_visited", "recent", "":
639- // TODO: Implement GetMostVisited in use case if needed
640- results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0)
641- if err != nil {
642- log.Error().Err(err).Msg("failed to load recent history")
698+ // Run history fetch and favorite URL fetch in parallel
699+ type historyResult struct {
700+ results []*entity.HistoryEntry
701+ err error
702+ }
703+ historyCh := make(chan historyResult, 1)
704+ favCh := make(chan map[string]struct{}, 1)
705+
706+ go func() {
707+ // TODO: Implement GetMostVisited in use case if needed
708+ results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0)
709+ historyCh <- historyResult{results, err}
710+ }()
711+
712+ go func() {
713+ favCh <- o.getFavoriteURLs(ctx)
714+ }()
715+
716+ // Wait for both results
717+ hr := <-historyCh
718+ favoriteURLs := <-favCh
719+
720+ if hr.err != nil {
721+ log.Error().Err(hr.err).Msg("failed to load recent history")
643722 return
644723 }
645- suggestions = make([]Suggestion, 0, len(results))
646- for _, r := range results {
724+ if hr.results == nil {
725+ return
726+ }
727+
728+ suggestions = make([]Suggestion, 0, len(hr.results))
729+ for _, r := range hr.results {
730+ _, isFav := favoriteURLs[r.URL]
647731 suggestions = append(suggestions, Suggestion{
648- URL: r.URL,
649- Title: r.Title,
732+ URL: r.URL,
733+ Title: r.Title,
734+ IsFavorite: isFav,
650735 })
651736 }
652737 }
@@ -693,9 +778,108 @@ func (o *Omnibox) loadFavorites(query string) {
693778 }()
694779 }
695780
781+func (o *Omnibox) loadBangSuggestions(query string) {
782+ if o.shortcutsUC == nil {
783+ return
784+ }
785+ output := o.shortcutsUC.FilterBangs(o.ctx, usecase.FilterBangsInput{Query: query})
786+ suggestions := make([]BangSuggestion, len(output.Suggestions))
787+ for i, s := range output.Suggestions {
788+ suggestions[i] = BangSuggestion{Key: s.Key, Description: s.Description}
789+ }
790+ o.idleAddUpdateBangSuggestions(suggestions)
791+}
792+
793+func (o *Omnibox) updateBangDetection(query string) {
794+ if o.shortcutsUC == nil {
795+ o.clearDetectedBang()
796+ return
797+ }
798+ output := o.shortcutsUC.DetectBangKey(o.ctx, usecase.DetectBangKeyInput{Query: query})
799+ if output.Key == "" {
800+ o.clearDetectedBang()
801+ return
802+ }
803+
804+ o.setDetectedBang(output.Key, output.Description)
805+}
806+
807+func (o *Omnibox) clearBangState() {
808+ o.mu.Lock()
809+ o.bangMode = false
810+ o.bangSuggestions = nil
811+ o.mu.Unlock()
812+
813+ o.clearDetectedBang()
814+}
815+
816+func (o *Omnibox) setDetectedBang(key, description string) {
817+ o.mu.Lock()
818+ o.detectedBang = key
819+ o.mu.Unlock()
820+
821+ if o.bangBadge == nil {
822+ return
823+ }
824+ label := description
825+ var cb glib.SourceFunc = func(uintptr) bool {
826+ o.entry.AddCssClass("omnibox-entry-bang-active")
827+ o.bangBadge.SetText(label)
828+ o.bangBadge.SetVisible(true)
829+ return false
830+ }
831+ glib.IdleAdd(&cb, 0)
832+}
833+
834+func (o *Omnibox) clearDetectedBang() {
835+ o.mu.Lock()
836+ o.detectedBang = ""
837+ o.mu.Unlock()
838+
839+ if o.bangBadge == nil {
840+ return
841+ }
842+ var cb glib.SourceFunc = func(uintptr) bool {
843+ o.entry.RemoveCssClass("omnibox-entry-bang-active")
844+ o.bangBadge.SetVisible(false)
845+ return false
846+ }
847+ glib.IdleAdd(&cb, 0)
848+}
849+
850+func (o *Omnibox) idleAddUpdateBangSuggestions(suggestions []BangSuggestion) {
851+ var cb glib.SourceFunc = func(uintptr) bool {
852+ o.updateBangSuggestions(suggestions)
853+ return false
854+ }
855+ glib.IdleAdd(&cb, 0)
856+}
857+
858+func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
859+ o.mu.Lock()
860+ o.bangMode = true
861+ o.bangSuggestions = suggestions
862+ o.selectedIndex = -1
863+ o.mu.Unlock()
864+
865+ o.rebuildList()
866+
867+ rowCount := len(suggestions)
868+ if o.scrolledWin != nil {
869+ o.scrolledWin.SetVisible(rowCount > 0)
870+ }
871+ o.resizeAndCenter(rowCount)
872+
873+ if rowCount > 0 {
874+ o.selectIndex(0)
875+ }
876+}
877+
696878 // updateSuggestions updates the list with history suggestions.
697879 func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
698880 o.mu.Lock()
881+ o.bangMode = false
882+ o.bangSuggestions = nil
699883 o.suggestions = suggestions
700884 o.selectedIndex = -1
701885 o.mu.Unlock()
@@ -718,6 +902,8 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
718902 // updateFavorites updates the list with favorites.
719903 func (o *Omnibox) updateFavorites(favorites []Favorite) {
720904 o.mu.Lock()
905+ o.bangMode = false
906+ o.bangSuggestions = nil
721907 o.favorites = favorites
722908 o.selectedIndex = -1
723909 o.mu.Unlock()
@@ -746,9 +932,18 @@ func (o *Omnibox) rebuildList() {
746932 mode := o.viewMode
747933 suggestions := o.suggestions
748934 favorites := o.favorites
935+ bangMode := o.bangMode
936+ bangSuggestions := o.bangSuggestions
749937 o.mu.RUnlock()
750938
751- if mode == ViewModeHistory {
939+ if bangMode {
940+ for i, b := range bangSuggestions {
941+ row := o.createBangRow(b, i)
942+ if row != nil {
943+ o.listBox.Append(&row.Widget)
944+ }
945+ }
946+ } else if mode == ViewModeHistory {
752947 for i, s := range suggestions {
753948 row := o.createSuggestionRow(s, i)
754949 if row != nil {
@@ -779,7 +974,9 @@ func (o *Omnibox) rebuildList() {
779974 // Re-trigger resize with accurate measurements
780975 o.mu.RLock()
781976 var count int
782- if o.viewMode == ViewModeHistory {
977+ if o.bangMode {
978+ count = len(o.bangSuggestions)
979+ } else if o.viewMode == ViewModeHistory {
783980 count = len(o.suggestions)
784981 } else {
785982 count = len(o.favorites)
@@ -820,7 +1017,14 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
8201017 }
8211018
8221019 // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
1020+// Uses rawURL for both favicon fetching and display.
8231021 func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
1022+ return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index)
1023+}
1024+
1025+// createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.
1026+// faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.
1027+func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow {
8241028 row := gtk.NewListBoxRow()
8251029 if row == nil {
8261030 return nil
@@ -835,7 +1039,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
8351039 hbox.SetHexpand(true)
8361040
8371041 // Favicon image (vertically centered)
838- if favicon := o.createFaviconImage(rawURL, fallbackIcon); favicon != nil {
1042+ if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil {
8391043 favicon.SetValign(gtk.AlignCenterValue)
8401044 hbox.Append(&favicon.Widget)
8411045 }
@@ -851,7 +1055,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
8511055 // Title label (or URL if no title)
8521056 displayTitle := title
8531057 if displayTitle == "" {
854- displayTitle = rawURL
1058+ displayTitle = displayURL
8551059 }
8561060 titleLabel := gtk.NewLabel(nil)
8571061 if titleLabel != nil {
@@ -863,10 +1067,10 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
8631067 }
8641068
8651069 // URL label (only if title exists and differs from URL)
866- if title != "" && title != rawURL {
1070+ if title != "" && title != displayURL {
8671071 urlLabel := gtk.NewLabel(nil)
8681072 if urlLabel != nil {
869- urlLabel.SetText(rawURL)
1073+ urlLabel.SetText(displayURL)
8701074 urlLabel.AddCssClass("omnibox-suggestion-url")
8711075 urlLabel.SetHalign(gtk.AlignStartValue)
8721076 urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
@@ -898,7 +1102,11 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
8981102
8991103 // createSuggestionRow creates a ListBoxRow for a suggestion.
9001104 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
901- return o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
1105+ row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
1106+ if row != nil && s.IsFavorite {
1107+ row.AddCssClass("omnibox-row-favorite")
1108+ }
1109+ return row
9021110 }
9031111
9041112 // createFavoriteRow creates a ListBoxRow for a favorite.
@@ -906,6 +1114,16 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
9061114 return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
9071115 }
9081116
1117+func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
1118+ // Pass description as URL param (displayed as secondary label) and empty
1119+ // faviconURL to skip async favicon fetching - bang rows use static icon only
1120+ row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index)
1121+ if row != nil {
1122+ row.AddCssClass("omnibox-row-bang")
1123+ }
1124+ return row
1125+}
1126+
9091127 // selectIndex selects a row by index.
9101128 func (o *Omnibox) selectIndex(index int) {
9111129 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
@@ -922,8 +1140,11 @@ func (o *Omnibox) selectNext() {
9221140 o.mu.Lock()
9231141 current := o.selectedIndex
9241142 mode := o.viewMode
1143+ bangMode := o.bangMode
9251144 var maxIndex int
926- if mode == ViewModeHistory {
1145+ if bangMode {
1146+ maxIndex = len(o.bangSuggestions) - 1
1147+ } else if mode == ViewModeHistory {
9271148 maxIndex = len(o.suggestions) - 1
9281149 } else {
9291150 maxIndex = len(o.favorites) - 1
@@ -947,8 +1168,11 @@ func (o *Omnibox) selectPrevious() {
9471168 o.mu.Lock()
9481169 current := o.selectedIndex
9491170 mode := o.viewMode
1171+ bangMode := o.bangMode
9501172 var maxIndex int
951- if mode == ViewModeHistory {
1173+ if bangMode {
1174+ maxIndex = len(o.bangSuggestions) - 1
1175+ } else if mode == ViewModeHistory {
9521176 maxIndex = len(o.suggestions) - 1
9531177 } else {
9541178 maxIndex = len(o.favorites) - 1
@@ -981,9 +1205,37 @@ func (o *Omnibox) navigateToSelected() {
9811205 idx := o.selectedIndex
9821206 suggestions := o.suggestions
9831207 favorites := o.favorites
1208+ bangMode := o.bangMode
1209+ bangSuggestions := o.bangSuggestions
9841210 o.mu.RUnlock()
9851211
9861212 entryText := o.entry.GetText()
1213+
1214+ if bangMode {
1215+ // If user typed a full bang query, navigate using the bang URL.
1216+ if o.shortcutsUC != nil {
1217+ navOutput := o.shortcutsUC.BuildNavigationText(o.ctx, usecase.BuildNavigationTextInput{EntryText: entryText})
1218+ if navOutput.Valid {
1219+ targetURL := o.buildURL(navOutput.Text)
1220+ if targetURL == "" {
1221+ return
1222+ }
1223+ o.Hide(o.ctx)
1224+ if o.onNavigate != nil {
1225+ o.onNavigate(targetURL)
1226+ }
1227+ return
1228+ }
1229+ }
1230+
1231+ // Otherwise, Enter autocompletes the selected bang.
1232+ if idx >= 0 && idx < len(bangSuggestions) {
1233+ o.entry.SetText("!" + bangSuggestions[idx].Key + " ")
1234+ o.entry.SetPosition(-1)
1235+ return
1236+ }
1237+ }
1238+
9871239 var targetURL string
9881240
9891241 // Check if user typed a URL-like string (contains . and no spaces)
@@ -1039,15 +1291,15 @@ func (o *Omnibox) toggleFavorite() {
10391291 }
10401292
10411293 if mode == ViewModeHistory {
1042- // Add to favorites
1294+ // Toggle favorite in history mode
10431295 if idx < 0 || idx >= len(suggestions) {
1044- log.Debug().Int("index", idx).Msg("add favorite: invalid selection")
1296+ log.Debug().Int("index", idx).Msg("toggle favorite: invalid selection")
10451297 return
10461298 }
10471299
10481300 s := suggestions[idx]
10491301 if s.URL == "" {
1050- log.Debug().Msg("add favorite: empty URL")
1302+ log.Debug().Msg("toggle favorite: empty URL")
10511303 return
10521304 }
10531305
@@ -1055,20 +1307,39 @@ func (o *Omnibox) toggleFavorite() {
10551307 ctx := o.ctx
10561308 goLog := logging.FromContext(ctx)
10571309
1058- goLog.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites")
1059-
1060- addInput := usecase.AddFavoriteInput{
1061- URL: s.URL,
1062- Title: s.Title,
1063- }
1064-
1065- fav, err := o.favoritesUC.Add(ctx, addInput)
1310+ result, err := o.favoritesUC.Toggle(ctx, s.URL, s.Title)
10661311 if err != nil {
1067- goLog.Error().Err(err).Str("url", s.URL).Msg("failed to add favorite")
1312+ goLog.Error().Err(err).Str("url", s.URL).Msg("failed to toggle favorite")
1313+ if o.onToast != nil {
1314+ msg := "Failed to add favorite"
1315+ if s.IsFavorite {
1316+ msg = "Failed to remove favorite"
1317+ }
1318+ cb := glib.SourceFunc(func(_ uintptr) bool {
1319+ o.onToast(ctx, msg, ToastError)
1320+ return false
1321+ })
1322+ glib.IdleAdd(&cb, 0)
1323+ }
10681324 return
10691325 }
10701326
1071- goLog.Info().Str("url", s.URL).Int64("id", int64(fav.ID)).Msg("favorite added from omnibox")
1327+ // Update suggestion state
1328+ o.mu.Lock()
1329+ if idx < len(o.suggestions) && o.suggestions[idx].URL == s.URL {
1330+ o.suggestions[idx].IsFavorite = result.Added
1331+ }
1332+ o.mu.Unlock()
1333+
1334+ // Update row CSS and show toast on GTK main thread
1335+ cb := glib.SourceFunc(func(_ uintptr) bool {
1336+ o.updateRowFavoriteIndicator(idx, result.Added)
1337+ if o.onToast != nil {
1338+ o.onToast(ctx, result.Message, ToastSuccess)
1339+ }
1340+ return false
1341+ })
1342+ glib.IdleAdd(&cb, 0)
10721343 }()
10731344 } else {
10741345 // Remove from favorites
@@ -1092,17 +1363,46 @@ func (o *Omnibox) toggleFavorite() {
10921363 err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID))
10931364 if err != nil {
10941365 log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite")
1366+ // Show error toast
1367+ if o.onToast != nil {
1368+ cb := glib.SourceFunc(func(_ uintptr) bool {
1369+ o.onToast(ctx, "Failed to remove favorite", ToastError)
1370+ return false
1371+ })
1372+ glib.IdleAdd(&cb, 0)
1373+ }
10951374 return
10961375 }
10971376
10981377 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
10991378
1100- // Refresh favorites list
1379+ // Refresh favorites list and show toast
11011380 o.loadFavorites(o.entry.GetText())
1381+ if o.onToast != nil {
1382+ cb := glib.SourceFunc(func(_ uintptr) bool {
1383+ o.onToast(ctx, "Favorite removed", ToastSuccess)
1384+ return false
1385+ })
1386+ glib.IdleAdd(&cb, 0)
1387+ }
11021388 }()
11031389 }
11041390 }
11051391
1392+// updateRowFavoriteIndicator updates a single row's favorite indicator CSS class.
1393+// Must be called from GTK main thread (via glib.IdleAdd).
1394+func (o *Omnibox) updateRowFavoriteIndicator(index int, isFavorite bool) {
1395+ row := o.listBox.GetRowAtIndex(index)
1396+ if row == nil {
1397+ return
1398+ }
1399+ if isFavorite {
1400+ row.AddCssClass("omnibox-row-favorite")
1401+ } else {
1402+ row.RemoveCssClass("omnibox-row-favorite")
1403+ }
1404+}
1405+
11061406 // yankSelectedURL copies the URL of the selected item to clipboard.
11071407 func (o *Omnibox) yankSelectedURL() {
11081408 log := logging.FromContext(o.ctx)
@@ -1148,7 +1448,7 @@ func (o *Omnibox) yankSelectedURL() {
11481448 // Show toast notification on success (must run on GTK main thread)
11491449 if o.onToast != nil {
11501450 cb := glib.SourceFunc(func(_ uintptr) bool {
1151- o.onToast("URL copied")
1451+ o.onToast(ctx, "URL copied", ToastSuccess)
11521452 return false // Don't repeat
11531453 })
11541454 glib.IdleAdd(&cb, 0)
@@ -1158,16 +1458,11 @@ func (o *Omnibox) yankSelectedURL() {
11581458
11591459 // buildURL constructs a URL from text, handling search shortcuts.
11601460 func (o *Omnibox) buildURL(text string) string {
1161- return url.BuildSearchURL(text, o.shortcutURLs(), o.defaultSearch)
1162-}
1163-
1164-// shortcutURLs returns a map of shortcut keys to URL templates.
1165-func (o *Omnibox) shortcutURLs() map[string]string {
1166- result := make(map[string]string, len(o.shortcuts))
1167- for key, shortcut := range o.shortcuts {
1168- result[key] = shortcut.URL
1461+ var shortcutURLs map[string]string
1462+ if o.shortcutsUC != nil {
1463+ shortcutURLs = o.shortcutsUC.ShortcutURLs()
11691464 }
1170- return result
1465+ return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)
11711466 }
11721467
11731468 // toggleViewMode switches between history and favorites.
@@ -1274,6 +1569,7 @@ func (o *Omnibox) Hide(ctx context.Context) {
12741569 o.outerBox.SetVisible(false)
12751570
12761571 // Clear state
1572+ o.clearBangState()
12771573 o.entry.SetText("")
12781574 o.listBox.RemoveAll()
12791575
@@ -1340,6 +1636,21 @@ func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {
13401636 glib.IdleAdd(&cb, 0)
13411637 }
13421638
1639+// getFavoriteURLs returns a set of all favorited URLs for batch lookup.
1640+// Returns empty map on error to avoid blocking history display.
1641+func (o *Omnibox) getFavoriteURLs(ctx context.Context) map[string]struct{} {
1642+ if o.favoritesUC == nil {
1643+ return make(map[string]struct{})
1644+ }
1645+ urls, err := o.favoritesUC.GetAllURLs(ctx)
1646+ if err != nil {
1647+ log := logging.FromContext(ctx)
1648+ log.Warn().Err(err).Msg("failed to load favorite URLs for indicator")
1649+ return make(map[string]struct{})
1650+ }
1651+ return urls
1652+}
1653+
13431654 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
13441655 func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {
13451656 var cb glib.SourceFunc = func(data uintptr) bool {
M internal/ui/component/pane_view.go +39 -2
@@ -25,6 +25,7 @@ type PaneView struct {
2525 progressBar *ProgressBar // Loading progress indicator
2626 toaster *Toaster // Toast notification overlay
2727 linkStatus *LinkStatusOverlay // Link hover URL overlay
28+ loading *LoadingSkeleton // Placeholder shown until WebView paints
2829 paneID entity.PaneID
2930 isActive bool
3031
@@ -51,6 +52,15 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg
5152 overlay.SetChild(webViewWidget)
5253 }
5354
55+ // Loading skeleton overlay (shown until WebView paints)
56+ loading := NewLoadingSkeleton(factory)
57+ loading.Widget().SetCanFocus(false)
58+ loading.Widget().SetCanTarget(false)
59+ overlay.AddOverlay(loading.Widget())
60+ // Don't let loading affect layout
61+ overlay.SetClipOverlay(loading.Widget(), false)
62+ overlay.SetMeasureOverlay(loading.Widget(), false)
63+
5464 // Create border overlay box (used for active pane indication)
5565 // This is an empty box that gets styled via CSS to show a border
5666 borderBox := factory.NewBox(layout.OrientationVertical, 0)
@@ -75,6 +85,7 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg
7585 webViewWidget: webViewWidget,
7686 borderBox: borderBox,
7787 progressBar: nil, // Created lazily in ensureProgressBar()
88+ loading: loading,
7889 paneID: paneID,
7990 isActive: false,
8091 }
@@ -133,13 +144,28 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
133144 pv.webViewWidget = widget
134145
135146 if widget != nil {
147+ // If this widget already had a parent, we're reparenting an existing WebView
148+ // (e.g. after rebuilding/moving panes). In that case the WebView has likely
149+ // already painted, so the loading skeleton should be hidden immediately.
150+ hadParent := widget.GetParent() != nil
151+ wasVisible := false
152+ if hadParent {
153+ wasVisible = widget.IsVisible()
154+ }
155+
136156 // Unparent widget from any previous parent (critical for rebuild scenarios)
137157 // In GTK4, a widget can only have one parent at a time
138- if widget.GetParent() != nil {
158+ if hadParent {
139159 widget.Unparent()
140160 }
141- widget.SetVisible(true)
142161 pv.overlay.SetChild(widget)
162+
163+ if hadParent && wasVisible {
164+ widget.SetVisible(true)
165+ }
166+ if hadParent && pv.loading != nil {
167+ pv.loading.SetVisible(false)
168+ }
143169 }
144170 }
145171
@@ -316,6 +342,17 @@ func (pv *PaneView) SetLoading(loading bool) {
316342 }
317343 }
318344
345+// HideLoadingSkeleton hides the loading skeleton overlay (if present).
346+func (pv *PaneView) HideLoadingSkeleton() {
347+ pv.mu.Lock()
348+ loading := pv.loading
349+ pv.mu.Unlock()
350+
351+ if loading != nil {
352+ loading.SetVisible(false)
353+ }
354+}
355+
319356 // ensureToaster creates the toaster lazily on first use.
320357 // Must be called with write lock held.
321358 func (pv *PaneView) ensureToaster() *Toaster {
A internal/ui/component/pane_view_internal_test.go +71 -0
@@ -0,0 +1,71 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/mock"
7+
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/bnema/dumber/internal/ui/layout/mocks"
10+)
11+
12+func TestPaneView_HideLoadingSkeleton_HidesWhenPresent(t *testing.T) {
13+ mockFactory := mocks.NewMockWidgetFactory(t)
14+ mockContainer := mocks.NewMockBoxWidget(t)
15+ mockContent := mocks.NewMockBoxWidget(t)
16+ mockSpinner := mocks.NewMockSpinnerWidget(t)
17+ mockLogo := mocks.NewMockImageWidget(t)
18+
19+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockContainer).Once()
20+ mockContainer.EXPECT().SetHexpand(true).Once()
21+ mockContainer.EXPECT().SetVexpand(true).Once()
22+ mockContainer.EXPECT().SetHalign(mock.Anything).Once()
23+ mockContainer.EXPECT().SetValign(mock.Anything).Once()
24+ mockContainer.EXPECT().SetCanFocus(false).Once()
25+ mockContainer.EXPECT().SetCanTarget(false).Once()
26+ mockContainer.EXPECT().AddCssClass("loading-skeleton").Once()
27+
28+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockContent).Once()
29+ mockContent.EXPECT().SetHalign(mock.Anything).Once()
30+ mockContent.EXPECT().SetValign(mock.Anything).Once()
31+ mockContent.EXPECT().SetCanFocus(false).Once()
32+ mockContent.EXPECT().SetCanTarget(false).Once()
33+ mockContent.EXPECT().AddCssClass("loading-skeleton-content").Once()
34+
35+ mockFactory.EXPECT().NewSpinner().Return(mockSpinner).Once()
36+ mockSpinner.EXPECT().SetHalign(mock.Anything).Once()
37+ mockSpinner.EXPECT().SetValign(mock.Anything).Once()
38+ mockSpinner.EXPECT().SetCanFocus(false).Once()
39+ mockSpinner.EXPECT().SetCanTarget(false).Once()
40+ mockSpinner.EXPECT().SetSizeRequest(32, 32).Once()
41+ mockSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Once()
42+
43+ mockFactory.EXPECT().NewImage().Return(mockLogo).Once()
44+ mockLogo.EXPECT().SetHalign(mock.Anything).Once()
45+ mockLogo.EXPECT().SetValign(mock.Anything).Once()
46+ mockLogo.EXPECT().SetCanFocus(false).Once()
47+ mockLogo.EXPECT().SetCanTarget(false).Once()
48+ mockLogo.EXPECT().SetSizeRequest(512, 512).Once()
49+ mockLogo.EXPECT().SetPixelSize(512).Once()
50+ mockLogo.EXPECT().AddCssClass("loading-skeleton-logo").Once()
51+ mockLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
52+
53+ mockContent.EXPECT().Append(mockLogo).Once()
54+ mockContent.EXPECT().Append(mockSpinner).Once()
55+ mockContainer.EXPECT().Append(mockContent).Once()
56+
57+ mockContainer.EXPECT().SetVisible(true).Once()
58+ mockSpinner.EXPECT().Start().Once()
59+ ls := NewLoadingSkeleton(mockFactory)
60+
61+ pv := &PaneView{loading: ls}
62+
63+ mockContainer.EXPECT().SetVisible(false).Once()
64+ mockSpinner.EXPECT().Stop().Once()
65+ pv.HideLoadingSkeleton()
66+}
67+
68+func TestPaneView_HideLoadingSkeleton_NoOpWhenNil(t *testing.T) {
69+ pv := &PaneView{}
70+ pv.HideLoadingSkeleton()
71+}
M internal/ui/component/pane_view_test.go +106 -23
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/mock"
78 "github.com/stretchr/testify/require"
89
910 "github.com/bnema/dumber/internal/domain/entity"
@@ -19,6 +20,11 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
1920 mockBorderBox := mocks.NewMockBoxWidget(t)
2021 mockWebView := mocks.NewMockWidget(t)
2122
23+ mockLoadingContainer := mocks.NewMockBoxWidget(t)
24+ mockLoadingContent := mocks.NewMockBoxWidget(t)
25+ mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
26+ mockLoadingLogo := mocks.NewMockImageWidget(t)
27+
2228 paneID := entity.PaneID("pane-1")
2329
2430 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
@@ -28,6 +34,8 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
2834 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
2935 mockOverlay.EXPECT().SetChild(mockWebView).Once()
3036
37+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
38+
3139 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
3240 mockBorderBox.EXPECT().SetCanFocus(false).Once()
3341 mockBorderBox.EXPECT().SetCanTarget(false).Once()
@@ -53,6 +61,11 @@ func TestNewPaneView_NilWebView(t *testing.T) {
5361 mockOverlay := mocks.NewMockOverlayWidget(t)
5462 mockBorderBox := mocks.NewMockBoxWidget(t)
5563
64+ mockLoadingContainer := mocks.NewMockBoxWidget(t)
65+ mockLoadingContent := mocks.NewMockBoxWidget(t)
66+ mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
67+ mockLoadingLogo := mocks.NewMockImageWidget(t)
68+
5669 paneID := entity.PaneID("pane-1")
5770
5871 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
@@ -62,6 +75,8 @@ func TestNewPaneView_NilWebView(t *testing.T) {
6275 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
6376 // SetChild should NOT be called when webview is nil
6477
78+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
79+
6580 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
6681 mockBorderBox.EXPECT().SetCanFocus(false).Once()
6782 mockBorderBox.EXPECT().SetCanTarget(false).Once()
@@ -87,7 +102,7 @@ func TestSetActive_True_AddsCSSClass(t *testing.T) {
87102 mockBorderBox := mocks.NewMockBoxWidget(t)
88103 mockWebView := mocks.NewMockWidget(t)
89104
90- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
105+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
91106
92107 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
93108
@@ -108,7 +123,7 @@ func TestSetActive_False_RemovesCSSClass(t *testing.T) {
108123 mockBorderBox := mocks.NewMockBoxWidget(t)
109124 mockWebView := mocks.NewMockWidget(t)
110125
111- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
126+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
112127
113128 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
114129
@@ -133,7 +148,7 @@ func TestSetActive_NoChangeWhenSameState(t *testing.T) {
133148 mockBorderBox := mocks.NewMockBoxWidget(t)
134149 mockWebView := mocks.NewMockWidget(t)
135150
136- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
151+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
137152
138153 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
139154
@@ -152,7 +167,7 @@ func TestPaneID_ReturnsPaneID(t *testing.T) {
152167 mockWebView := mocks.NewMockWidget(t)
153168
154169 paneID := entity.PaneID("test-pane-123")
155- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
170+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
156171
157172 pv := component.NewPaneView(mockFactory, paneID, mockWebView)
158173
@@ -170,7 +185,7 @@ func TestWebViewWidget_ReturnsWebView(t *testing.T) {
170185 mockBorderBox := mocks.NewMockBoxWidget(t)
171186 mockWebView := mocks.NewMockWidget(t)
172187
173- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
188+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
174189
175190 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
176191
@@ -189,14 +204,13 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) {
189204 mockOldWebView := mocks.NewMockWidget(t)
190205 mockNewWebView := mocks.NewMockWidget(t)
191206
192- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockOldWebView)
207+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockOldWebView)
193208
194209 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockOldWebView)
195210
196211 // Expect removal of old widget and addition of new
197212 mockOverlay.EXPECT().SetChild(nil).Once()
198213 mockNewWebView.EXPECT().GetParent().Return(nil).Once()
199- mockNewWebView.EXPECT().SetVisible(true).Once()
200214 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
201215
202216 // Act
@@ -213,13 +227,12 @@ func TestSetWebViewWidget_FromNil(t *testing.T) {
213227 mockBorderBox := mocks.NewMockBoxWidget(t)
214228 mockNewWebView := mocks.NewMockWidget(t)
215229
216- setupPaneViewMocksNoWebView(mockFactory, mockOverlay, mockBorderBox)
230+ setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
217231
218232 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
219233
220234 // Expect only setting new child (no removal since old was nil)
221235 mockNewWebView.EXPECT().GetParent().Return(nil).Once()
222- mockNewWebView.EXPECT().SetVisible(true).Once()
223236 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
224237
225238 // Act
@@ -236,7 +249,7 @@ func TestGrabFocus_DelegatesToWebView(t *testing.T) {
236249 mockBorderBox := mocks.NewMockBoxWidget(t)
237250 mockWebView := mocks.NewMockWidget(t)
238251
239- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
252+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
240253
241254 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
242255
@@ -255,7 +268,7 @@ func TestGrabFocus_NilWebView_ReturnsFalse(t *testing.T) {
255268 mockOverlay := mocks.NewMockOverlayWidget(t)
256269 mockBorderBox := mocks.NewMockBoxWidget(t)
257270
258- setupPaneViewMocksNoWebView(mockFactory, mockOverlay, mockBorderBox)
271+ setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
259272
260273 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
261274
@@ -273,7 +286,7 @@ func TestHasFocus_DelegatesToWebView(t *testing.T) {
273286 mockBorderBox := mocks.NewMockBoxWidget(t)
274287 mockWebView := mocks.NewMockWidget(t)
275288
276- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
289+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
277290
278291 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
279292
@@ -292,7 +305,7 @@ func TestHasFocus_NilWebView_ReturnsFalse(t *testing.T) {
292305 mockOverlay := mocks.NewMockOverlayWidget(t)
293306 mockBorderBox := mocks.NewMockBoxWidget(t)
294307
295- setupPaneViewMocksNoWebView(mockFactory, mockOverlay, mockBorderBox)
308+ setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
296309
297310 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
298311
@@ -310,7 +323,7 @@ func TestWidget_ReturnsOverlay(t *testing.T) {
310323 mockBorderBox := mocks.NewMockBoxWidget(t)
311324 mockWebView := mocks.NewMockWidget(t)
312325
313- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
326+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
314327
315328 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
316329
@@ -328,7 +341,7 @@ func TestOverlay_ReturnsOverlayWidget(t *testing.T) {
328341 mockBorderBox := mocks.NewMockBoxWidget(t)
329342 mockWebView := mocks.NewMockWidget(t)
330343
331- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
344+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
332345
333346 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
334347
@@ -346,7 +359,7 @@ func TestShow_DelegatesToOverlay(t *testing.T) {
346359 mockBorderBox := mocks.NewMockBoxWidget(t)
347360 mockWebView := mocks.NewMockWidget(t)
348361
349- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
362+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
350363
351364 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
352365
@@ -365,7 +378,7 @@ func TestHide_DelegatesToOverlay(t *testing.T) {
365378 mockBorderBox := mocks.NewMockBoxWidget(t)
366379 mockWebView := mocks.NewMockWidget(t)
367380
368- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
381+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
369382
370383 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
371384
@@ -384,7 +397,7 @@ func TestSetVisible_DelegatesToOverlay(t *testing.T) {
384397 mockBorderBox := mocks.NewMockBoxWidget(t)
385398 mockWebView := mocks.NewMockWidget(t)
386399
387- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
400+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
388401
389402 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
390403
@@ -403,7 +416,7 @@ func TestIsVisible_DelegatesToOverlay(t *testing.T) {
403416 mockBorderBox := mocks.NewMockBoxWidget(t)
404417 mockWebView := mocks.NewMockWidget(t)
405418
406- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
419+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
407420
408421 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
409422
@@ -423,7 +436,7 @@ func TestAddCssClass_DelegatesToOverlay(t *testing.T) {
423436 mockBorderBox := mocks.NewMockBoxWidget(t)
424437 mockWebView := mocks.NewMockWidget(t)
425438
426- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
439+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
427440
428441 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
429442
@@ -442,7 +455,7 @@ func TestRemoveCssClass_DelegatesToOverlay(t *testing.T) {
442455 mockBorderBox := mocks.NewMockBoxWidget(t)
443456 mockWebView := mocks.NewMockWidget(t)
444457
445- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
458+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
446459
447460 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
448461
@@ -461,7 +474,7 @@ func TestSetOnFocusIn_SetsCallback(t *testing.T) {
461474 mockBorderBox := mocks.NewMockBoxWidget(t)
462475 mockWebView := mocks.NewMockWidget(t)
463476
464- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
477+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
465478
466479 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
467480
@@ -481,7 +494,7 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) {
481494 mockBorderBox := mocks.NewMockBoxWidget(t)
482495 mockWebView := mocks.NewMockWidget(t)
483496
484- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)
497+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
485498
486499 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
487500
@@ -494,13 +507,73 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) {
494507 assert.NotNil(t, pv)
495508 }
496509
510+// Helper function to setup common mock expectations for PaneView creation
511+func setupLoadingSkeletonMocks(
512+ mockFactory *mocks.MockWidgetFactory,
513+ mockOverlay *mocks.MockOverlayWidget,
514+ mockLoadingContainer *mocks.MockBoxWidget,
515+ mockLoadingContent *mocks.MockBoxWidget,
516+ mockLoadingSpinner *mocks.MockSpinnerWidget,
517+ mockLoadingLogo *mocks.MockImageWidget,
518+) {
519+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()
520+ mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()
521+ mockLoadingContainer.EXPECT().SetVexpand(true).Maybe()
522+ mockLoadingContainer.EXPECT().SetHalign(mock.Anything).Maybe()
523+ mockLoadingContainer.EXPECT().SetValign(mock.Anything).Maybe()
524+ mockLoadingContainer.EXPECT().SetCanFocus(false).Maybe()
525+ mockLoadingContainer.EXPECT().SetCanTarget(false).Maybe()
526+ mockLoadingContainer.EXPECT().AddCssClass("loading-skeleton").Maybe()
527+ mockLoadingContainer.EXPECT().SetVisible(true).Maybe()
528+
529+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockLoadingContent).Once()
530+ mockLoadingContent.EXPECT().SetHalign(mock.Anything).Maybe()
531+ mockLoadingContent.EXPECT().SetValign(mock.Anything).Maybe()
532+ mockLoadingContent.EXPECT().SetCanFocus(false).Maybe()
533+ mockLoadingContent.EXPECT().SetCanTarget(false).Maybe()
534+ mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe()
535+
536+ mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once()
537+ mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe()
538+ mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe()
539+ mockLoadingSpinner.EXPECT().SetCanFocus(false).Maybe()
540+ mockLoadingSpinner.EXPECT().SetCanTarget(false).Maybe()
541+ mockLoadingSpinner.EXPECT().SetSizeRequest(32, 32).Maybe()
542+ mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
543+ mockLoadingSpinner.EXPECT().Start().Maybe()
544+
545+ mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once()
546+ mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe()
547+ mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe()
548+ mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe()
549+ mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe()
550+ mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe()
551+ mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe()
552+ mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe()
553+ mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
554+
555+ mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()
556+ mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()
557+ mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()
558+
559+ mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()
560+ mockOverlay.EXPECT().SetClipOverlay(mockLoadingContainer, false).Once()
561+ mockOverlay.EXPECT().SetMeasureOverlay(mockLoadingContainer, false).Once()
562+}
563+
497564 // Helper function to setup common mock expectations for PaneView creation
498565 func setupPaneViewMocks(
566+ t *testing.T,
499567 mockFactory *mocks.MockWidgetFactory,
500568 mockOverlay *mocks.MockOverlayWidget,
501569 mockBorderBox *mocks.MockBoxWidget,
502570 mockWebView *mocks.MockWidget,
503571 ) {
572+ mockLoadingContainer := mocks.NewMockBoxWidget(t)
573+ mockLoadingContent := mocks.NewMockBoxWidget(t)
574+ mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
575+ mockLoadingLogo := mocks.NewMockImageWidget(t)
576+
504577 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
505578 mockOverlay.EXPECT().SetHexpand(true).Once()
506579 mockOverlay.EXPECT().SetVexpand(true).Once()
@@ -508,6 +581,8 @@ func setupPaneViewMocks(
508581 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
509582 mockOverlay.EXPECT().SetChild(mockWebView).Once()
510583
584+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
585+
511586 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
512587 mockBorderBox.EXPECT().SetCanFocus(false).Once()
513588 mockBorderBox.EXPECT().SetCanTarget(false).Once()
@@ -520,16 +595,24 @@ func setupPaneViewMocks(
520595 }
521596
522597 func setupPaneViewMocksNoWebView(
598+ t *testing.T,
523599 mockFactory *mocks.MockWidgetFactory,
524600 mockOverlay *mocks.MockOverlayWidget,
525601 mockBorderBox *mocks.MockBoxWidget,
526602 ) {
603+ mockLoadingContainer := mocks.NewMockBoxWidget(t)
604+ mockLoadingContent := mocks.NewMockBoxWidget(t)
605+ mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
606+ mockLoadingLogo := mocks.NewMockImageWidget(t)
607+
527608 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
528609 mockOverlay.EXPECT().SetHexpand(true).Once()
529610 mockOverlay.EXPECT().SetVexpand(true).Once()
530611 mockOverlay.EXPECT().SetVisible(true).Once()
531612 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
532613
614+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
615+
533616 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
534617 mockBorderBox.EXPECT().SetCanFocus(false).Once()
535618 mockBorderBox.EXPECT().SetCanTarget(false).Once()
M internal/ui/component/progress_bar.go +45 -1
@@ -15,11 +15,14 @@ const (
1515 progressStep = 0.02
1616 // Animation interval in milliseconds
1717 progressIntervalMs = 16 // ~60fps
18+ // Timeout to auto-hide progress bar if stuck (30 seconds)
19+ progressTimeoutMs = 30000
1820 )
1921
2022 // ProgressBar displays a slim loading progress indicator at the bottom of a pane.
2123 // Uses native GtkProgressBar with "osd" styling for overlay appearance.
2224 // Implements smooth animation by incrementing towards the target value.
25+// Includes a 30-second timeout to auto-hide if the page load stalls.
2326 type ProgressBar struct {
2427 progressBar layout.ProgressBarWidget
2528
@@ -27,6 +30,7 @@ type ProgressBar struct {
2730 currentValue float64 // Current displayed value
2831 targetValue float64 // Target value to animate towards
2932 animationTimer uint // Timer source ID for animation
33+ timeoutTimer uint // Timer source ID for auto-hide timeout
3034
3135 mu sync.Mutex
3236 }
@@ -127,7 +131,7 @@ func (pb *ProgressBar) startAnimation() {
127131 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)
128132 }
129133
130-// Show makes the progress bar visible.
134+// Show makes the progress bar visible and starts the auto-hide timeout.
131135 func (pb *ProgressBar) Show() {
132136 pb.mu.Lock()
133137 defer pb.mu.Unlock()
@@ -136,6 +140,34 @@ func (pb *ProgressBar) Show() {
136140 pb.visible = true
137141 pb.progressBar.SetVisible(true)
138142 }
143+
144+ // Reset timeout timer on every Show call
145+ pb.resetTimeout()
146+}
147+
148+// resetTimeout cancels any existing timeout and starts a new one.
149+// Must be called with lock held.
150+func (pb *ProgressBar) resetTimeout() {
151+ // Cancel existing timeout
152+ if pb.timeoutTimer != 0 {
153+ glib.SourceRemove(pb.timeoutTimer)
154+ pb.timeoutTimer = 0
155+ }
156+
157+ // Start new timeout
158+ cb := glib.SourceFunc(func(_ uintptr) bool {
159+ pb.mu.Lock()
160+ defer pb.mu.Unlock()
161+
162+ // Clear timer ID (timer is being removed)
163+ pb.timeoutTimer = 0
164+
165+ // Hide the progress bar inline to avoid race condition
166+ pb.hideInternal()
167+ return false // Don't repeat
168+ })
169+
170+ pb.timeoutTimer = glib.TimeoutAdd(progressTimeoutMs, &cb, 0)
139171 }
140172
141173 // Hide makes the progress bar invisible and resets state.
@@ -143,6 +175,12 @@ func (pb *ProgressBar) Hide() {
143175 pb.mu.Lock()
144176 defer pb.mu.Unlock()
145177
178+ pb.hideInternal()
179+}
180+
181+// hideInternal performs the actual hide operation.
182+// Must be called with lock held.
183+func (pb *ProgressBar) hideInternal() {
146184 if pb.visible {
147185 pb.visible = false
148186 pb.progressBar.SetVisible(false)
@@ -153,6 +191,12 @@ func (pb *ProgressBar) Hide() {
153191 pb.animationTimer = 0
154192 }
155193
194+ // Stop timeout timer
195+ if pb.timeoutTimer != 0 {
196+ glib.SourceRemove(pb.timeoutTimer)
197+ pb.timeoutTimer = 0
198+ }
199+
156200 // Reset values
157201 pb.currentValue = 0
158202 pb.targetValue = 0
A internal/ui/component/tab_picker.go +482 -0
@@ -0,0 +1,482 @@
1+package component
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/ui/layout"
9+ "github.com/jwijenbergh/puregotk/v4/gdk"
10+ "github.com/jwijenbergh/puregotk/v4/glib"
11+ "github.com/jwijenbergh/puregotk/v4/gtk"
12+)
13+
14+type TabPickerItem struct {
15+ TabID entity.TabID
16+ Title string
17+ IsNew bool
18+ Index int // 0-based index in tab list, -1 for + New
19+}
20+
21+// TabPicker is a modal overlay for selecting a tab destination.
22+//
23+// It is intentionally similar to SessionManager/Omnibox patterns.
24+type TabPicker struct {
25+ // GTK widgets
26+ outerBox *gtk.Box
27+ mainBox *gtk.Box
28+ headerBox *gtk.Box
29+ titleLabel *gtk.Label
30+ scrolledWindow *gtk.ScrolledWindow
31+ listBox *gtk.ListBox
32+ footerLabel *gtk.Label
33+
34+ parentOverlay layout.OverlayWidget
35+
36+ mu sync.RWMutex
37+ visible bool
38+ items []TabPickerItem
39+ selectedIndex int
40+ uiScale float64
41+
42+ onClose func()
43+ onSelect func(item TabPickerItem)
44+
45+ retainedCallbacks []interface{}
46+ ctx context.Context
47+}
48+
49+type TabPickerConfig struct {
50+ UIScale float64
51+ OnClose func()
52+ OnSelect func(item TabPickerItem)
53+}
54+
55+func NewTabPicker(ctx context.Context, cfg TabPickerConfig) *TabPicker {
56+ uiScale := cfg.UIScale
57+ if uiScale <= 0 {
58+ uiScale = 1.0
59+ }
60+
61+ tp := &TabPicker{
62+ ctx: ctx,
63+ onClose: cfg.OnClose,
64+ onSelect: cfg.OnSelect,
65+ selectedIndex: 0,
66+ uiScale: uiScale,
67+ }
68+
69+ if err := tp.createWidgets(); err != nil {
70+ return nil
71+ }
72+ tp.attachKeyController()
73+ return tp
74+}
75+
76+func (tp *TabPicker) SetParentOverlay(overlay layout.OverlayWidget) {
77+ tp.mu.Lock()
78+ defer tp.mu.Unlock()
79+ tp.parentOverlay = overlay
80+}
81+
82+func (tp *TabPicker) Widget() *gtk.Widget {
83+ if tp.outerBox == nil {
84+ return nil
85+ }
86+ return &tp.outerBox.Widget
87+}
88+
89+func (tp *TabPicker) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
90+ if tp.outerBox == nil {
91+ return nil
92+ }
93+ return factory.WrapWidget(&tp.outerBox.Widget)
94+}
95+
96+func (tp *TabPicker) IsVisible() bool {
97+ tp.mu.RLock()
98+ defer tp.mu.RUnlock()
99+ return tp.visible
100+}
101+
102+func (tp *TabPicker) Show(ctx context.Context, items []TabPickerItem) {
103+ tp.mu.Lock()
104+ if tp.visible {
105+ tp.mu.Unlock()
106+ return
107+ }
108+ tp.visible = true
109+ tp.items = items
110+ tp.selectedIndex = 0
111+ tp.mu.Unlock()
112+
113+ tp.populateList()
114+ tp.resizeAndCenter()
115+ if tp.outerBox != nil {
116+ tp.outerBox.SetVisible(true)
117+ }
118+ if tp.listBox != nil {
119+ tp.listBox.GrabFocus()
120+ }
121+}
122+
123+func (tp *TabPicker) Hide(ctx context.Context) {
124+ tp.mu.Lock()
125+ if !tp.visible {
126+ tp.mu.Unlock()
127+ return
128+ }
129+ tp.visible = false
130+ tp.mu.Unlock()
131+
132+ if tp.outerBox != nil {
133+ tp.outerBox.SetVisible(false)
134+ }
135+ if tp.listBox != nil {
136+ tp.listBox.RemoveAll()
137+ }
138+
139+ if tp.onClose != nil {
140+ tp.onClose()
141+ }
142+}
143+
144+func (tp *TabPicker) Toggle(ctx context.Context, items []TabPickerItem) {
145+ if tp.IsVisible() {
146+ tp.Hide(ctx)
147+ } else {
148+ tp.Show(ctx, items)
149+ }
150+}
151+
152+func (tp *TabPicker) createWidgets() error {
153+ if err := tp.createOuter(); err != nil {
154+ return err
155+ }
156+ if err := tp.createMain(); err != nil {
157+ return err
158+ }
159+ if err := tp.createHeader(); err != nil {
160+ return err
161+ }
162+ if err := tp.createList(); err != nil {
163+ return err
164+ }
165+ if err := tp.createFooter(); err != nil {
166+ return err
167+ }
168+ tp.assemble()
169+ return nil
170+}
171+
172+func (tp *TabPicker) createOuter() error {
173+ tp.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
174+ if tp.outerBox == nil {
175+ return errNilWidget("tabPickerOuterBox")
176+ }
177+ tp.outerBox.AddCssClass("tab-picker-outer")
178+ tp.outerBox.SetHalign(gtk.AlignCenterValue)
179+ tp.outerBox.SetValign(gtk.AlignStartValue)
180+ tp.outerBox.SetVisible(false)
181+ return nil
182+}
183+
184+func (tp *TabPicker) createMain() error {
185+ tp.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
186+ if tp.mainBox == nil {
187+ return errNilWidget("tabPickerMainBox")
188+ }
189+ tp.mainBox.AddCssClass("tab-picker-container")
190+ return nil
191+}
192+
193+func (tp *TabPicker) createHeader() error {
194+ tp.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
195+ if tp.headerBox == nil {
196+ return errNilWidget("tabPickerHeaderBox")
197+ }
198+ tp.headerBox.AddCssClass("tab-picker-header")
199+
200+ title := "Move Pane To Tab"
201+ tp.titleLabel = gtk.NewLabel(&title)
202+ if tp.titleLabel == nil {
203+ return errNilWidget("tabPickerTitleLabel")
204+ }
205+ tp.titleLabel.AddCssClass("tab-picker-title")
206+ tp.titleLabel.SetHalign(gtk.AlignStartValue)
207+ tp.titleLabel.SetHexpand(true)
208+ tp.headerBox.Append(&tp.titleLabel.Widget)
209+
210+ shortcutText := "Ctrl+P m"
211+ shortcutLabel := gtk.NewLabel(&shortcutText)
212+ if shortcutLabel != nil {
213+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
214+ tp.headerBox.Append(&shortcutLabel.Widget)
215+ }
216+ return nil
217+}
218+
219+func (tp *TabPicker) createList() error {
220+ tp.scrolledWindow = gtk.NewScrolledWindow()
221+ if tp.scrolledWindow == nil {
222+ return errNilWidget("tabPickerScrolledWindow")
223+ }
224+ tp.scrolledWindow.AddCssClass("tab-picker-scrolled")
225+ tp.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
226+ tp.scrolledWindow.SetPropagateNaturalHeight(false)
227+
228+ tp.listBox = gtk.NewListBox()
229+ if tp.listBox == nil {
230+ return errNilWidget("tabPickerListBox")
231+ }
232+ tp.listBox.AddCssClass("tab-picker-list")
233+ tp.listBox.SetSelectionMode(gtk.SelectionSingleValue)
234+
235+ rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
236+ if rowPtr == 0 {
237+ return
238+ }
239+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
240+ if row == nil {
241+ return
242+ }
243+ idx := row.GetIndex()
244+ tp.mu.Lock()
245+ tp.selectedIndex = idx
246+ tp.mu.Unlock()
247+ }
248+ tp.retainedCallbacks = append(tp.retainedCallbacks, rowSelectedCb)
249+ tp.listBox.ConnectRowSelected(&rowSelectedCb)
250+
251+ tp.scrolledWindow.SetChild(&tp.listBox.Widget)
252+ return nil
253+}
254+
255+func (tp *TabPicker) createFooter() error {
256+ footerText := "↑↓/jk navigate Enter confirm 1-9 pick tab n new tab Esc close"
257+ tp.footerLabel = gtk.NewLabel(&footerText)
258+ if tp.footerLabel == nil {
259+ return errNilWidget("tabPickerFooterLabel")
260+ }
261+ tp.footerLabel.AddCssClass("tab-picker-footer")
262+ tp.footerLabel.SetHalign(gtk.AlignCenterValue)
263+ return nil
264+}
265+
266+func (tp *TabPicker) assemble() {
267+ if tp.outerBox == nil || tp.mainBox == nil {
268+ return
269+ }
270+ if tp.headerBox != nil {
271+ tp.mainBox.Append(&tp.headerBox.Widget)
272+ }
273+ if tp.scrolledWindow != nil {
274+ tp.mainBox.Append(&tp.scrolledWindow.Widget)
275+ }
276+ if tp.footerLabel != nil {
277+ tp.mainBox.Append(&tp.footerLabel.Widget)
278+ }
279+ tp.outerBox.Append(&tp.mainBox.Widget)
280+}
281+
282+func (tp *TabPicker) resizeAndCenter() {
283+ if tp.outerBox == nil || tp.mainBox == nil {
284+ return
285+ }
286+
287+ width, marginTop := CalculateModalDimensions(tp.parentOverlay, TabPickerSizeDefaults)
288+ tp.mainBox.SetSizeRequest(width, -1)
289+ tp.outerBox.SetMarginTop(marginTop)
290+
291+ // Height based on rows
292+ tp.mu.RLock()
293+ count := len(tp.items)
294+ tp.mu.RUnlock()
295+
296+ if tp.scrolledWindow != nil {
297+ rowH := ScaleValue(DefaultRowHeights.Standard, tp.uiScale)
298+ maxH := TabPickerListDefaults.MaxVisibleRows * rowH
299+ h := count * rowH
300+ if h > maxH {
301+ h = maxH
302+ }
303+ if h < rowH {
304+ h = rowH
305+ }
306+ cb := glib.SourceFunc(func(_ uintptr) bool {
307+ SetScrolledWindowHeight(tp.scrolledWindow, h)
308+ tp.outerBox.QueueResize()
309+ return false
310+ })
311+ glib.IdleAdd(&cb, 0)
312+ }
313+}
314+
315+func (tp *TabPicker) populateList() {
316+ if tp.listBox == nil {
317+ return
318+ }
319+ p := tp.listBox
320+ p.RemoveAll()
321+
322+ tp.mu.RLock()
323+ items := append([]TabPickerItem(nil), tp.items...)
324+ tp.mu.RUnlock()
325+
326+ for _, it := range items {
327+ row := gtk.NewListBoxRow()
328+ if row == nil {
329+ continue
330+ }
331+ row.AddCssClass("tab-picker-row")
332+
333+ const rowSpacing = 8
334+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
335+ if hbox == nil {
336+ continue
337+ }
338+ hbox.SetHexpand(true)
339+
340+ labelText := it.Title
341+ if it.IsNew {
342+ labelText = "+ New Tab"
343+ }
344+
345+ label := gtk.NewLabel(&labelText)
346+ if label != nil {
347+ label.AddCssClass("tab-picker-row-title")
348+ label.SetHalign(gtk.AlignStartValue)
349+ label.SetHexpand(true)
350+ hbox.Append(&label.Widget)
351+ }
352+
353+ row.SetChild(&hbox.Widget)
354+ p.Append(&row.Widget)
355+ }
356+
357+ // select first row
358+ if row := p.GetRowAtIndex(0); row != nil {
359+ p.SelectRow(row)
360+ }
361+}
362+
363+func (tp *TabPicker) attachKeyController() {
364+ controller := gtk.NewEventControllerKey()
365+ if controller == nil {
366+ return
367+ }
368+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
369+
370+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) bool {
371+ return tp.handleKeyPress(keyval)
372+ }
373+ tp.retainedCallbacks = append(tp.retainedCallbacks, keyPressedCb)
374+ controller.ConnectKeyPressed(&keyPressedCb)
375+ tp.outerBox.AddController(&controller.EventController)
376+}
377+
378+func (tp *TabPicker) handleKeyPress(keyval uint) bool {
379+ switch keyval {
380+ case uint(gdk.KEY_Escape):
381+ tp.Hide(tp.ctx)
382+ return true
383+ case uint(gdk.KEY_Up), uint(gdk.KEY_k):
384+ tp.selectRelative(-1)
385+ return true
386+ case uint(gdk.KEY_Down), uint(gdk.KEY_j):
387+ tp.selectRelative(1)
388+ return true
389+ case uint(gdk.KEY_n):
390+ tp.chooseNewTab()
391+ return true
392+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
393+ tp.confirmSelection()
394+ return true
395+ case uint(gdk.KEY_1), uint(gdk.KEY_2), uint(gdk.KEY_3), uint(gdk.KEY_4), uint(gdk.KEY_5),
396+ uint(gdk.KEY_6), uint(gdk.KEY_7), uint(gdk.KEY_8), uint(gdk.KEY_9):
397+ idx := int(keyval - uint(gdk.KEY_1))
398+ tp.selectIndex(idx)
399+ tp.confirmSelection()
400+ return true
401+ default:
402+ return false
403+ }
404+}
405+
406+func (tp *TabPicker) selectRelative(delta int) {
407+ tp.mu.Lock()
408+ defer tp.mu.Unlock()
409+
410+ count := len(tp.items)
411+ if count == 0 {
412+ return
413+ }
414+
415+ i := tp.selectedIndex + delta
416+ if i < 0 {
417+ i = count - 1
418+ } else if i >= count {
419+ i = 0
420+ }
421+ tp.selectedIndex = i
422+ if tp.listBox != nil {
423+ if row := tp.listBox.GetRowAtIndex(i); row != nil {
424+ tp.listBox.SelectRow(row)
425+ row.GrabFocus()
426+ }
427+ }
428+}
429+
430+func (tp *TabPicker) selectIndex(index int) {
431+ tp.mu.Lock()
432+ defer tp.mu.Unlock()
433+
434+ count := len(tp.items)
435+ if count == 0 {
436+ return
437+ }
438+ if index < 0 {
439+ index = 0
440+ }
441+ if index >= count {
442+ index = count - 1
443+ }
444+ tp.selectedIndex = index
445+ if tp.listBox != nil {
446+ if row := tp.listBox.GetRowAtIndex(index); row != nil {
447+ tp.listBox.SelectRow(row)
448+ row.GrabFocus()
449+ }
450+ }
451+}
452+
453+func (tp *TabPicker) chooseNewTab() {
454+ tp.mu.RLock()
455+ items := append([]TabPickerItem(nil), tp.items...)
456+ tp.mu.RUnlock()
457+
458+ for i := range items {
459+ if items[i].IsNew {
460+ tp.selectIndex(i)
461+ tp.confirmSelection()
462+ return
463+ }
464+ }
465+}
466+
467+func (tp *TabPicker) confirmSelection() {
468+ tp.mu.RLock()
469+ idx := tp.selectedIndex
470+ items := append([]TabPickerItem(nil), tp.items...)
471+ tp.mu.RUnlock()
472+
473+ if idx < 0 || idx >= len(items) {
474+ return
475+ }
476+ item := items[idx]
477+
478+ tp.Hide(tp.ctx)
479+ if tp.onSelect != nil {
480+ tp.onSelect(item)
481+ }
482+}
M internal/ui/component/toaster.go +239 -8
@@ -48,6 +48,89 @@ func (l ToastLevel) cssClass() string {
4848 }
4949 }
5050
51+// ToastPosition defines where the toast appears on screen.
52+type ToastPosition int
53+
54+const (
55+ // ToastPositionTopLeft positions toast in top-left corner.
56+ ToastPositionTopLeft ToastPosition = iota
57+ // ToastPositionTopCenter positions toast at top center.
58+ ToastPositionTopCenter
59+ // ToastPositionTopRight positions toast in top-right corner.
60+ ToastPositionTopRight
61+ // ToastPositionBottomLeft positions toast in bottom-left corner.
62+ ToastPositionBottomLeft
63+ // ToastPositionBottomCenter positions toast at bottom center.
64+ ToastPositionBottomCenter
65+ // ToastPositionBottomRight positions toast in bottom-right corner.
66+ ToastPositionBottomRight
67+)
68+
69+// ToastOptions configures toast appearance and behavior.
70+type ToastOptions struct {
71+ // Duration in milliseconds. 0 = persistent (no auto-dismiss), >0 = auto-dismiss after duration.
72+ Duration int
73+ // BackgroundColor overrides the default background color (CSS color value).
74+ // Empty string uses the level's default color.
75+ BackgroundColor string
76+ // TextColor overrides the default text color (CSS color value).
77+ // Empty string uses the default (auto-contrast with background).
78+ TextColor string
79+ // Position determines where the toast appears on screen.
80+ Position ToastPosition
81+ // ModeClass is a CSS class for mode-specific styling (e.g., "toast-pane-mode").
82+ // When set, this class is applied atomically with Show() to avoid visual flicker.
83+ ModeClass string
84+}
85+
86+// ToastOption is a functional option for configuring toast display.
87+type ToastOption func(*ToastOptions)
88+
89+// WithDuration sets the auto-dismiss duration in milliseconds.
90+// Use 0 for persistent toasts that require manual dismissal.
91+func WithDuration(ms int) ToastOption {
92+ return func(o *ToastOptions) {
93+ o.Duration = ms
94+ }
95+}
96+
97+// WithBackgroundColor sets a custom background color (CSS color value).
98+func WithBackgroundColor(color string) ToastOption {
99+ return func(o *ToastOptions) {
100+ o.BackgroundColor = color
101+ }
102+}
103+
104+// WithTextColor sets a custom text color (CSS color value).
105+func WithTextColor(color string) ToastOption {
106+ return func(o *ToastOptions) {
107+ o.TextColor = color
108+ }
109+}
110+
111+// WithPosition sets the toast position on screen.
112+func WithPosition(pos ToastPosition) ToastOption {
113+ return func(o *ToastOptions) {
114+ o.Position = pos
115+ }
116+}
117+
118+// WithModeClass sets a mode-specific CSS class for styling.
119+// This is applied atomically within Show() to avoid visual flicker.
120+func WithModeClass(class string) ToastOption {
121+ return func(o *ToastOptions) {
122+ o.ModeClass = class
123+ }
124+}
125+
126+// defaultToastOptions returns the default toast options.
127+func defaultToastOptions() ToastOptions {
128+ return ToastOptions{
129+ Duration: toastDismissTimeoutMs,
130+ Position: ToastPositionTopLeft,
131+ }
132+}
133+
51134 // Toaster displays toast notifications in an overlay.
52135 // It supports different notification levels and auto-dismissal.
53136 // When a new toast is shown while one is already visible, the text
@@ -60,18 +143,26 @@ type Toaster struct {
60143 visible bool
61144 dismissTimer uint // GLib timer source ID
62145
146+ // Track current options for cleanup
147+ currentOpts ToastOptions
148+ hasCustomStyle bool
149+ customBgColor string // Current custom background color
150+ customTextColor string // Current custom text color
151+ customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode")
152+ customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors
153+
63154 mu sync.Mutex
64155 }
65156
66157 // NewToaster creates a new toaster component for overlay display.
67-// The toaster is positioned in the top-left corner with margin.
158+// The toaster is positioned in the top-left corner with margin by default.
68159 func NewToaster(factory layout.WidgetFactory) *Toaster {
69160 // Create container box for styling and positioning
70161 container := factory.NewBox(layout.OrientationHorizontal, 0)
71162 container.AddCssClass("toast")
72163 container.AddCssClass("toast-info") // Default level
73164
74- // Position in top-left with margin
165+ // Position in top-left with margin (default)
75166 container.SetHalign(gtk.AlignStartValue)
76167 container.SetValign(gtk.AlignStartValue)
77168
@@ -98,17 +189,28 @@ func NewToaster(factory layout.WidgetFactory) *Toaster {
98189 label: label,
99190 currentLevel: ToastInfo,
100191 visible: false,
192+ currentOpts: defaultToastOptions(),
101193 }
102194 }
103195
104196 // Show displays a toast notification with the given message and level.
105197 // If a toast is already visible, updates the text and resets the dismiss timer.
106-func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
198+// Optional ToastOption arguments can customize duration, colors, and position.
199+func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, opts ...ToastOption) {
107200 log := logging.FromContext(ctx)
108201
202+ // Build options from defaults and overrides
203+ options := defaultToastOptions()
204+ for _, opt := range opts {
205+ opt(&options)
206+ }
207+
109208 t.mu.Lock()
110209 defer t.mu.Unlock()
111210
211+ // Clear previous custom styles if any
212+ t.clearCustomStyle()
213+
112214 // Update level CSS class if changed
113215 if t.currentLevel != level {
114216 t.container.RemoveCssClass(t.currentLevel.cssClass())
@@ -116,6 +218,17 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
116218 t.currentLevel = level
117219 }
118220
221+ // Apply custom colors if specified
222+ t.applyCustomStyle(options)
223+
224+ // Apply mode class if specified (atomically to avoid flicker)
225+ if options.ModeClass != "" {
226+ t.applyModeClassInternal(options.ModeClass)
227+ }
228+
229+ // Apply position
230+ t.applyPosition(options.Position)
231+
119232 // Update message text
120233 t.label.SetText(message)
121234
@@ -131,12 +244,18 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
131244 t.container.SetVisible(true)
132245 }
133246
134- // Start new dismiss timer
135- t.startDismissTimer(ctx)
247+ // Start new dismiss timer (if duration > 0)
248+ if options.Duration > 0 {
249+ t.startDismissTimerWithDuration(ctx, options.Duration)
250+ }
251+ // Duration == 0 means persistent, no timer
252+
253+ t.currentOpts = options
136254
137255 log.Debug().
138256 Str("toast_message", message).
139257 Int("toast_level", int(level)).
258+ Int("duration", options.Duration).
140259 Msg("toast shown")
141260 }
142261
@@ -167,13 +286,125 @@ func (t *Toaster) hide() {
167286 t.dismissTimer = 0
168287 }
169288
289+ // Clear custom styles
290+ t.clearCustomStyle()
291+
170292 t.visible = false
171293 t.container.SetVisible(false)
172294 }
173295
174-// startDismissTimer starts the auto-dismiss timer.
296+// applyPosition sets the widget alignment based on the position.
297+func (t *Toaster) applyPosition(pos ToastPosition) {
298+ var halign, valign gtk.Align
299+
300+ switch pos {
301+ case ToastPositionTopLeft:
302+ halign = gtk.AlignStartValue
303+ valign = gtk.AlignStartValue
304+ case ToastPositionTopCenter:
305+ halign = gtk.AlignCenterValue
306+ valign = gtk.AlignStartValue
307+ case ToastPositionTopRight:
308+ halign = gtk.AlignEndValue
309+ valign = gtk.AlignStartValue
310+ case ToastPositionBottomLeft:
311+ halign = gtk.AlignStartValue
312+ valign = gtk.AlignEndValue
313+ case ToastPositionBottomCenter:
314+ halign = gtk.AlignCenterValue
315+ valign = gtk.AlignEndValue
316+ case ToastPositionBottomRight:
317+ halign = gtk.AlignEndValue
318+ valign = gtk.AlignEndValue
319+ default:
320+ halign = gtk.AlignStartValue
321+ valign = gtk.AlignStartValue
322+ }
323+
324+ t.container.SetHalign(halign)
325+ t.container.SetValign(valign)
326+}
327+
328+// applyCustomStyle applies custom background and text colors via inline CSS.
329+func (t *Toaster) applyCustomStyle(opts ToastOptions) {
330+ if opts.BackgroundColor == "" && opts.TextColor == "" {
331+ return
332+ }
333+
334+ // Add custom class to indicate custom styling is applied
335+ t.container.AddCssClass("toast-custom")
336+ t.hasCustomStyle = true
337+
338+ // Store the colors
339+ t.customBgColor = opts.BackgroundColor
340+ t.customTextColor = opts.TextColor
341+
342+ // Remove level-based styling when using custom colors
343+ if opts.BackgroundColor != "" {
344+ t.container.RemoveCssClass(t.currentLevel.cssClass())
345+ }
346+
347+ // Generate inline CSS for custom colors
348+ css := ".toast-custom {"
349+ if opts.BackgroundColor != "" {
350+ css += fmt.Sprintf(" background-color: %s;", opts.BackgroundColor)
351+ }
352+ if opts.TextColor != "" {
353+ css += fmt.Sprintf(" color: %s;", opts.TextColor)
354+ }
355+ css += " }"
356+
357+ // Create or reuse CSS provider for this widget
358+ if t.customCSSProvider == nil {
359+ t.customCSSProvider = gtk.NewCssProvider()
360+ }
361+ t.customCSSProvider.LoadFromString(css)
362+
363+ // Apply to widget's style context
364+ styleCtx := t.container.GtkWidget().GetStyleContext()
365+ if styleCtx != nil {
366+ styleCtx.AddProvider(t.customCSSProvider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION+1))
367+ }
368+}
369+
370+// clearCustomStyle removes any custom styling applied.
371+func (t *Toaster) clearCustomStyle() {
372+ if t.hasCustomStyle {
373+ t.container.RemoveCssClass("toast-custom")
374+ // Remove mode-specific class if one was applied
375+ if t.customModeClass != "" {
376+ t.container.RemoveCssClass(t.customModeClass)
377+ t.customModeClass = ""
378+ }
379+ // Remove custom CSS provider if one was added
380+ if t.customCSSProvider != nil {
381+ styleCtx := t.container.GtkWidget().GetStyleContext()
382+ if styleCtx != nil {
383+ styleCtx.RemoveProvider(t.customCSSProvider)
384+ }
385+ }
386+ // Re-add level class if it was removed due to custom background
387+ if t.customBgColor != "" {
388+ t.container.AddCssClass(t.currentLevel.cssClass())
389+ }
390+ t.hasCustomStyle = false
391+ t.customBgColor = ""
392+ t.customTextColor = ""
393+ }
394+}
395+
396+// applyModeClassInternal applies mode class without locking (must be called with lock held).
397+func (t *Toaster) applyModeClassInternal(modeClass string) {
398+ // Remove level class and add mode class
399+ t.container.RemoveCssClass(t.currentLevel.cssClass())
400+ t.container.AddCssClass(modeClass)
401+ t.hasCustomStyle = true
402+ t.customModeClass = modeClass // Store class name for cleanup tracking
403+}
404+
405+// startDismissTimerWithDuration starts the auto-dismiss timer with the given duration.
175406 // Must be called with lock held.
176-func (t *Toaster) startDismissTimer(ctx context.Context) {
407+func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) {
177408 log := logging.FromContext(ctx)
178409
179410 cb := glib.SourceFunc(func(_ uintptr) bool {
@@ -188,7 +419,7 @@ func (t *Toaster) startDismissTimer(ctx context.Context) {
188419 return false // Don't repeat
189420 })
190421
191- t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0)
422+ t.dismissTimer = glib.TimeoutAdd(uint(durationMs), &cb, 0)
192423 }
193424
194425 // Widget returns the underlying widget for embedding in overlays.
M internal/ui/component/workspace_view.go +14 -4
@@ -556,8 +556,8 @@ func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
556556
557557 // Create omnibox with pane-specific toast callback
558558 cfg := wv.omniboxCfg
559- cfg.OnToast = func(message string) {
560- pv.ShowToast(ctx, message, ToastSuccess)
559+ cfg.OnToast = func(toastCtx context.Context, message string, level ToastLevel) {
560+ pv.ShowToast(toastCtx, message, level)
561561 }
562562 omnibox := NewOmnibox(ctx, cfg)
563563 if omnibox == nil {
@@ -608,7 +608,13 @@ func (wv *WorkspaceView) hideOmniboxInternal() {
608608
609609 // Remove from pane overlay
610610 if pv := wv.paneViews[wv.omniboxPaneID]; pv != nil && wv.omniboxWidget != nil {
611- pv.RemoveOverlayWidget(wv.omniboxWidget)
611+ // The omnibox widget may have been reparented during a rebuild/move.
612+ // Avoid GTK criticals by only removing from the overlay that owns it.
613+ if parent := wv.omniboxWidget.GetParent(); parent == pv.Overlay() {
614+ pv.RemoveOverlayWidget(wv.omniboxWidget)
615+ } else if parent != nil {
616+ wv.omniboxWidget.Unparent()
617+ }
612618 }
613619
614620 // Clear references
@@ -715,7 +721,11 @@ func (wv *WorkspaceView) hideFindBarInternal() {
715721 wv.findBar.Hide()
716722
717723 if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {
718- pv.RemoveOverlayWidget(wv.findBarWidget)
724+ if parent := wv.findBarWidget.GetParent(); parent == pv.Overlay() {
725+ pv.RemoveOverlayWidget(wv.findBarWidget)
726+ } else if parent != nil {
727+ wv.findBarWidget.Unparent()
728+ }
719729 }
720730
721731 wv.findBar = nil
M internal/ui/component/workspace_view_test.go +60 -0
@@ -14,6 +14,59 @@ import (
1414 "github.com/bnema/dumber/internal/ui/layout/mocks"
1515 )
1616
17+func setupLoadingSkeletonMocksWorkspace(
18+ mockFactory *mocks.MockWidgetFactory,
19+ mockOverlay *mocks.MockOverlayWidget,
20+ mockLoadingContainer *mocks.MockBoxWidget,
21+ mockLoadingContent *mocks.MockBoxWidget,
22+ mockLoadingSpinner *mocks.MockSpinnerWidget,
23+ mockLoadingLogo *mocks.MockImageWidget,
24+) {
25+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockLoadingContainer).Once()
26+ mockLoadingContainer.EXPECT().SetHexpand(true).Maybe()
27+ mockLoadingContainer.EXPECT().SetVexpand(true).Maybe()
28+ mockLoadingContainer.EXPECT().SetHalign(mock.Anything).Maybe()
29+ mockLoadingContainer.EXPECT().SetValign(mock.Anything).Maybe()
30+ mockLoadingContainer.EXPECT().SetCanFocus(false).Maybe()
31+ mockLoadingContainer.EXPECT().SetCanTarget(false).Maybe()
32+ mockLoadingContainer.EXPECT().AddCssClass("loading-skeleton").Maybe()
33+ mockLoadingContainer.EXPECT().SetVisible(true).Maybe()
34+
35+ mockFactory.EXPECT().NewBox(layout.OrientationVertical, 6).Return(mockLoadingContent).Once()
36+ mockLoadingContent.EXPECT().SetHalign(mock.Anything).Maybe()
37+ mockLoadingContent.EXPECT().SetValign(mock.Anything).Maybe()
38+ mockLoadingContent.EXPECT().SetCanFocus(false).Maybe()
39+ mockLoadingContent.EXPECT().SetCanTarget(false).Maybe()
40+ mockLoadingContent.EXPECT().AddCssClass("loading-skeleton-content").Maybe()
41+
42+ mockFactory.EXPECT().NewSpinner().Return(mockLoadingSpinner).Once()
43+ mockLoadingSpinner.EXPECT().SetHalign(mock.Anything).Maybe()
44+ mockLoadingSpinner.EXPECT().SetValign(mock.Anything).Maybe()
45+ mockLoadingSpinner.EXPECT().SetCanFocus(false).Maybe()
46+ mockLoadingSpinner.EXPECT().SetCanTarget(false).Maybe()
47+ mockLoadingSpinner.EXPECT().SetSizeRequest(32, 32).Maybe()
48+ mockLoadingSpinner.EXPECT().AddCssClass("loading-skeleton-spinner").Maybe()
49+ mockLoadingSpinner.EXPECT().Start().Maybe()
50+
51+ mockFactory.EXPECT().NewImage().Return(mockLoadingLogo).Once()
52+ mockLoadingLogo.EXPECT().SetHalign(mock.Anything).Maybe()
53+ mockLoadingLogo.EXPECT().SetValign(mock.Anything).Maybe()
54+ mockLoadingLogo.EXPECT().SetCanFocus(false).Maybe()
55+ mockLoadingLogo.EXPECT().SetCanTarget(false).Maybe()
56+ mockLoadingLogo.EXPECT().SetSizeRequest(512, 512).Maybe()
57+ mockLoadingLogo.EXPECT().SetPixelSize(512).Maybe()
58+ mockLoadingLogo.EXPECT().AddCssClass("loading-skeleton-logo").Maybe()
59+ mockLoadingLogo.EXPECT().SetFromPaintable(mock.Anything).Maybe()
60+
61+ mockLoadingContent.EXPECT().Append(mockLoadingLogo).Maybe()
62+ mockLoadingContent.EXPECT().Append(mockLoadingSpinner).Maybe()
63+ mockLoadingContainer.EXPECT().Append(mockLoadingContent).Maybe()
64+
65+ mockOverlay.EXPECT().AddOverlay(mockLoadingContainer).Once()
66+ mockOverlay.EXPECT().SetClipOverlay(mockLoadingContainer, false).Once()
67+ mockOverlay.EXPECT().SetMeasureOverlay(mockLoadingContainer, false).Once()
68+}
69+
1770 func setupWorkspaceViewBase(t *testing.T, mockFactory *mocks.MockWidgetFactory) (context.Context, *mocks.MockBoxWidget) {
1871 ctx := context.Background()
1972 mockContainer := mocks.NewMockBoxWidget(t)
@@ -37,12 +90,19 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact
3790 mockOverlay := mocks.NewMockOverlayWidget(t)
3891 mockBorderBox := mocks.NewMockBoxWidget(t)
3992
93+ mockLoadingContainer := mocks.NewMockBoxWidget(t)
94+ mockLoadingContent := mocks.NewMockBoxWidget(t)
95+ mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
96+ mockLoadingLogo := mocks.NewMockImageWidget(t)
97+
4098 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
4199 mockOverlay.EXPECT().SetHexpand(true).Once()
42100 mockOverlay.EXPECT().SetVexpand(true).Once()
43101 mockOverlay.EXPECT().SetVisible(true).Once()
44102 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()
45103
104+ setupLoadingSkeletonMocksWorkspace(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
105+
46106 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
47107 mockBorderBox.EXPECT().SetCanFocus(false).Once()
48108 mockBorderBox.EXPECT().SetCanTarget(false).Once()
A internal/ui/config_migration.go +38 -0
@@ -0,0 +1,38 @@
1+package ui
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/logging"
9+ "github.com/bnema/dumber/internal/ui/component"
10+)
11+
12+// checkConfigMigration checks if user config needs migration and shows a toast.
13+func (a *App) checkConfigMigration(ctx context.Context) {
14+ log := logging.FromContext(ctx)
15+ cfg := config.Get()
16+
17+ migrator := config.NewMigrator()
18+ result, err := migrator.CheckMigration()
19+ if err != nil {
20+ log.Debug().Err(err).Msg("config migration check failed")
21+ return
22+ }
23+
24+ if result == nil || len(result.MissingKeys) == 0 {
25+ return // No migration needed
26+ }
27+
28+ // Log is always shown for debugging purposes
29+ log.Info().
30+ Int("missing_keys", len(result.MissingKeys)).
31+ Msg("config migration available")
32+
33+ // Show toast notification if enabled
34+ if cfg.Update.NotifyOnNewSettings && a.appToaster != nil {
35+ msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys))
36+ a.appToaster.Show(ctx, msg, component.ToastInfo)
37+ }
38+}
M internal/ui/coordinator/content.go +108 -5
@@ -52,6 +52,12 @@ type ContentCoordinator struct {
5252 // Callback when active pane title changes (for window title updates)
5353 onWindowTitleChanged func(title string)
5454
55+ // Callback when the WebView becomes visible (first real commit)
56+ onWebViewShown func(paneID entity.PaneID)
57+
58+ revealMu sync.Mutex
59+ pendingReveal map[entity.PaneID]bool
60+
5561 // Gesture action handler for mouse button navigation
5662 gestureActionHandler input.ActionHandler
5763
@@ -72,6 +78,9 @@ type ContentCoordinator struct {
7278
7379 // Idle inhibitor for fullscreen video playback
7480 idleInhibitor port.IdleInhibitor
81+
82+ // Callback when fullscreen state changes (for hiding/showing tab bar)
83+ onFullscreenChanged func(entering bool)
7584 }
7685
7786 // NewContentCoordinator creates a new ContentCoordinator.
@@ -94,6 +103,7 @@ func NewContentCoordinator(
94103 webViews: make(map[entity.PaneID]*webkit.WebView),
95104 paneTitles: make(map[entity.PaneID]string),
96105 navOrigins: make(map[entity.PaneID]string),
106+ pendingReveal: make(map[entity.PaneID]bool),
97107 getActiveWS: getActiveWS,
98108 pendingPopups: make(map[port.WebViewID]*PendingPopup),
99109 }
@@ -119,6 +129,11 @@ func (c *ContentCoordinator) SetOnWindowTitleChanged(fn func(title string)) {
119129 c.onWindowTitleChanged = fn
120130 }
121131
132+// SetOnWebViewShown sets a callback that fires when a pane's WebView is shown.
133+func (c *ContentCoordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) {
134+ c.onWebViewShown = fn
135+}
136+
122137 // SetGestureActionHandler sets the callback for mouse button navigation gestures.
123138 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
124139 c.gestureActionHandler = handler
@@ -129,6 +144,11 @@ func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
129144 c.idleInhibitor = inhibitor
130145 }
131146
147+// SetOnFullscreenChanged sets the callback for fullscreen state changes.
148+func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) {
149+ c.onFullscreenChanged = fn
150+}
151+
132152 // EnsureWebView acquires or reuses a WebView for the given pane.
133153 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
134154 log := logging.FromContext(ctx)
@@ -674,18 +694,46 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en
674694 func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
675695 log := logging.FromContext(ctx)
676696
697+ url := wv.URI()
698+ if url == "" {
699+ return
700+ }
701+
677702 // Show the WebView now that content is being painted
678703 // (WebViews are hidden on creation to avoid white flash)
679- if inner := wv.Widget(); inner != nil {
680- inner.SetVisible(true)
681- log.Debug().Str("pane_id", string(paneID)).Msg("webview shown on load committed")
704+ // Skip showing if this is about:blank but the pane is loading a different URL
705+ // This prevents the brief flash of about:blank during initial navigation
706+ shouldShow := true
707+ if url == "about:blank" {
708+ // Get the pane's intended URI from the workspace
709+ ws, _ := c.getActiveWS()
710+ if ws != nil {
711+ if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
712+ // Don't show about:blank if the pane is supposed to load a different URL
713+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != "about:blank" {
714+ shouldShow = false
715+ log.Debug().
716+ Str("pane_id", string(paneID)).
717+ Str("pane_uri", paneNode.Pane.URI).
718+ Msg("skipping webview show for about:blank (pane loading different URL)")
719+ }
720+ }
721+ }
682722 }
683723
684- url := wv.URI()
685- if url == "" {
724+ if !shouldShow {
725+ // Avoid updating UI/domain state to about:blank when we know the pane is
726+ // navigating to a different URL. This prevents the omnibox/window title from
727+ // briefly showing about:blank on cold start.
728+ c.clearPendingReveal(paneID)
686729 return
687730 }
688731
732+ c.markPendingReveal(paneID)
733+ if wv.EstimatedProgress() > 0 {
734+ c.revealIfPending(ctx, paneID, url, "progress-after-commit")
735+ }
736+
689737 // Update domain model with current URI for session snapshots
690738 c.updatePaneURI(paneID, url)
691739
@@ -754,10 +802,16 @@ func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
754802 if paneView != nil {
755803 paneView.SetLoading(false)
756804 }
805+
806+ c.revealIfPending(context.Background(), paneID, "", "load-finished")
757807 }
758808
759809 // onProgressChanged updates the progress bar with current load progress.
760810 func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
811+ if progress > 0 {
812+ c.revealIfPending(context.Background(), paneID, "", "progress")
813+ }
814+
761815 _, wsView := c.getActiveWS()
762816 if wsView == nil {
763817 return
@@ -769,6 +823,49 @@ func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress fl
769823 }
770824 }
771825
826+func (c *ContentCoordinator) markPendingReveal(paneID entity.PaneID) {
827+ c.revealMu.Lock()
828+ c.pendingReveal[paneID] = true
829+ c.revealMu.Unlock()
830+}
831+
832+func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) {
833+ c.revealMu.Lock()
834+ delete(c.pendingReveal, paneID)
835+ c.revealMu.Unlock()
836+}
837+
838+func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, url, reason string) {
839+ c.revealMu.Lock()
840+ pending := c.pendingReveal[paneID]
841+ if pending {
842+ delete(c.pendingReveal, paneID)
843+ }
844+ c.revealMu.Unlock()
845+
846+ if !pending {
847+ return
848+ }
849+
850+ wv := c.webViews[paneID]
851+ if wv == nil || wv.IsDestroyed() {
852+ return
853+ }
854+
855+ if inner := wv.Widget(); inner != nil {
856+ inner.SetVisible(true)
857+ logging.FromContext(ctx).
858+ Debug().
859+ Str("pane_id", string(paneID)).
860+ Str("url", url).
861+ Str("reason", reason).
862+ Msg("webview revealed")
863+ }
864+ if c.onWebViewShown != nil {
865+ c.onWebViewShown(paneID)
866+ }
867+}
868+
772869 // onLinkHover updates the link status overlay when hovering over links.
773870 func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) {
774871 _, wsView := c.getActiveWS()
@@ -1251,6 +1348,9 @@ func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, pa
12511348 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
12521349 }
12531350 }
1351+ if c.onFullscreenChanged != nil {
1352+ c.onFullscreenChanged(true)
1353+ }
12541354 return false // Allow fullscreen
12551355 }
12561356
@@ -1260,6 +1360,9 @@ func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, pa
12601360 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
12611361 }
12621362 }
1363+ if c.onFullscreenChanged != nil {
1364+ c.onFullscreenChanged(false)
1365+ }
12631366 return false // Allow leaving fullscreen
12641367 }
12651368
A internal/ui/coordinator/content_reveal_test.go +179 -0
@@ -0,0 +1,179 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+)
13+
14+// TestPendingReveal tests the pending reveal state management.
15+// These are internal package tests that verify the state transitions
16+// without needing to mock WebKit dependencies.
17+
18+func TestMarkPendingReveal_SetsState(t *testing.T) {
19+ c := &ContentCoordinator{
20+ pendingReveal: make(map[entity.PaneID]bool),
21+ }
22+ paneID := entity.PaneID("pane-1")
23+
24+ c.markPendingReveal(paneID)
25+
26+ c.revealMu.Lock()
27+ defer c.revealMu.Unlock()
28+ assert.True(t, c.pendingReveal[paneID])
29+}
30+
31+func TestClearPendingReveal_RemovesState(t *testing.T) {
32+ c := &ContentCoordinator{
33+ pendingReveal: make(map[entity.PaneID]bool),
34+ }
35+ paneID := entity.PaneID("pane-1")
36+
37+ // First mark as pending
38+ c.markPendingReveal(paneID)
39+
40+ // Then clear
41+ c.clearPendingReveal(paneID)
42+
43+ c.revealMu.Lock()
44+ defer c.revealMu.Unlock()
45+ assert.False(t, c.pendingReveal[paneID])
46+}
47+
48+func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {
49+ callbackCalled := false
50+ c := &ContentCoordinator{
51+ pendingReveal: make(map[entity.PaneID]bool),
52+ webViews: make(map[entity.PaneID]*webkit.WebView),
53+ onWebViewShown: func(paneID entity.PaneID) {
54+ callbackCalled = true
55+ },
56+ }
57+ paneID := entity.PaneID("pane-1")
58+
59+ // Call reveal without marking pending first
60+ c.revealIfPending(context.Background(), paneID, "http://example.com", "test")
61+
62+ assert.False(t, callbackCalled, "callback should not be called when not pending")
63+}
64+
65+func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {
66+ c := &ContentCoordinator{
67+ pendingReveal: make(map[entity.PaneID]bool),
68+ webViews: make(map[entity.PaneID]*webkit.WebView),
69+ }
70+ paneID := entity.PaneID("pane-1")
71+
72+ // Mark as pending
73+ c.markPendingReveal(paneID)
74+
75+ // Reveal (will return early since webview is nil, but should still clear state)
76+ c.revealIfPending(context.Background(), paneID, "http://example.com", "test")
77+
78+ c.revealMu.Lock()
79+ defer c.revealMu.Unlock()
80+ assert.False(t, c.pendingReveal[paneID], "pending state should be cleared after reveal attempt")
81+}
82+
83+func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
84+ revealCount := 0
85+ c := &ContentCoordinator{
86+ pendingReveal: make(map[entity.PaneID]bool),
87+ webViews: make(map[entity.PaneID]*webkit.WebView),
88+ onWebViewShown: func(paneID entity.PaneID) {
89+ revealCount++
90+ },
91+ }
92+ paneID := entity.PaneID("pane-1")
93+
94+ // Mark as pending
95+ c.markPendingReveal(paneID)
96+
97+ // Call reveal multiple times
98+ c.revealIfPending(context.Background(), paneID, "", "first")
99+ c.revealIfPending(context.Background(), paneID, "", "second")
100+ c.revealIfPending(context.Background(), paneID, "", "third")
101+
102+ // Callback should not be called since webview is nil, but state should be cleared
103+ assert.Equal(t, 0, revealCount, "callback not called when webview is nil")
104+
105+ // Verify state is cleared
106+ c.revealMu.Lock()
107+ defer c.revealMu.Unlock()
108+ assert.False(t, c.pendingReveal[paneID])
109+}
110+
111+func TestPendingReveal_ConcurrentAccess(t *testing.T) {
112+ c := &ContentCoordinator{
113+ pendingReveal: make(map[entity.PaneID]bool),
114+ webViews: make(map[entity.PaneID]*webkit.WebView),
115+ }
116+
117+ var wg sync.WaitGroup
118+ paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3", "pane-4", "pane-5"}
119+
120+ // Concurrently mark and clear panes
121+ for i := 0; i < 100; i++ {
122+ for _, paneID := range paneIDs {
123+ wg.Add(3)
124+
125+ go func(id entity.PaneID) {
126+ defer wg.Done()
127+ c.markPendingReveal(id)
128+ }(paneID)
129+
130+ go func(id entity.PaneID) {
131+ defer wg.Done()
132+ c.clearPendingReveal(id)
133+ }(paneID)
134+
135+ go func(id entity.PaneID) {
136+ defer wg.Done()
137+ c.revealIfPending(context.Background(), id, "", "concurrent")
138+ }(paneID)
139+ }
140+ }
141+
142+ wg.Wait()
143+ // Test passes if no race conditions occur
144+}
145+
146+func TestMarkPendingReveal_MultiplePanes(t *testing.T) {
147+ c := &ContentCoordinator{
148+ pendingReveal: make(map[entity.PaneID]bool),
149+ }
150+
151+ pane1 := entity.PaneID("pane-1")
152+ pane2 := entity.PaneID("pane-2")
153+
154+ c.markPendingReveal(pane1)
155+ c.markPendingReveal(pane2)
156+
157+ c.revealMu.Lock()
158+ defer c.revealMu.Unlock()
159+ assert.True(t, c.pendingReveal[pane1])
160+ assert.True(t, c.pendingReveal[pane2])
161+}
162+
163+func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) {
164+ c := &ContentCoordinator{
165+ pendingReveal: make(map[entity.PaneID]bool),
166+ }
167+
168+ pane1 := entity.PaneID("pane-1")
169+ pane2 := entity.PaneID("pane-2")
170+
171+ c.markPendingReveal(pane1)
172+ c.markPendingReveal(pane2)
173+ c.clearPendingReveal(pane1)
174+
175+ c.revealMu.Lock()
176+ defer c.revealMu.Unlock()
177+ assert.False(t, c.pendingReveal[pane1], "pane1 should be cleared")
178+ assert.True(t, c.pendingReveal[pane2], "pane2 should still be pending")
179+}
M internal/ui/deps.go +5 -4
@@ -19,10 +19,11 @@ import (
1919 // This struct is created once at startup and passed to UI components.
2020 type Dependencies struct {
2121 // Core context and configuration
22- Ctx context.Context
23- Config *config.Config
24- InitialURL string // URL to open on startup (optional)
25- RestoreSessionID string // Session ID to restore on startup (optional)
22+ Ctx context.Context
23+ Config *config.Config
24+ InitialURL string // URL to open on startup (optional)
25+ RestoreSessionID string // Session ID to restore on startup (optional)
26+ OnFirstWebViewShown func(context.Context)
2627
2728 // Theme management
2829 Theme *theme.Manager
M internal/ui/dispatcher/keyboard.go +44 -12
@@ -17,18 +17,20 @@ import (
1717
1818 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
1919 type KeyboardDispatcher struct {
20- tabCoord *coordinator.TabCoordinator
21- wsCoord *coordinator.WorkspaceCoordinator
22- navCoord *coordinator.NavigationCoordinator
23- zoomUC *usecase.ManageZoomUseCase
24- copyURLUC *usecase.CopyURLUseCase
25- actionHandlers map[input.Action]func(ctx context.Context) error
26- onQuit func()
27- onFindOpen func(ctx context.Context) error
28- onFindNext func(ctx context.Context) error
29- onFindPrev func(ctx context.Context) error
30- onFindClose func(ctx context.Context) error
31- onSessionOpen func(ctx context.Context) error
20+ tabCoord *coordinator.TabCoordinator
21+ wsCoord *coordinator.WorkspaceCoordinator
22+ navCoord *coordinator.NavigationCoordinator
23+ zoomUC *usecase.ManageZoomUseCase
24+ copyURLUC *usecase.CopyURLUseCase
25+ actionHandlers map[input.Action]func(ctx context.Context) error
26+ onQuit func()
27+ onFindOpen func(ctx context.Context) error
28+ onFindNext func(ctx context.Context) error
29+ onFindPrev func(ctx context.Context) error
30+ onFindClose func(ctx context.Context) error
31+ onSessionOpen func(ctx context.Context) error
32+ onMovePaneToTab func(ctx context.Context) error
33+ onMovePaneToNext func(ctx context.Context) error
3234 }
3335
3436 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -84,6 +86,14 @@ func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error
8486 d.onSessionOpen = fn
8587 }
8688
89+func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) {
90+ d.onMovePaneToTab = fn
91+}
92+
93+func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) {
94+ d.onMovePaneToNext = fn
95+}
96+
8797 func (d *KeyboardDispatcher) initActionHandlers() {
8898 const (
8999 firstTabIndex = 0
@@ -128,6 +138,12 @@ func (d *KeyboardDispatcher) initActionHandlers() {
128138 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
129139 input.ActionClosePane: d.wsCoord.ClosePane,
130140 input.ActionStackPane: d.wsCoord.StackPane,
141+ input.ActionMovePaneToTab: func(ctx context.Context) error {
142+ return d.handleMovePaneToTab(ctx)
143+ },
144+ input.ActionMovePaneToNextTab: func(ctx context.Context) error {
145+ return d.handleMovePaneToNextTab(ctx)
146+ },
131147 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
132148 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
133149 },
@@ -239,6 +255,22 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
239255 return nil
240256 }
241257
258+func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
259+ if d.onMovePaneToTab != nil {
260+ return d.onMovePaneToTab(ctx)
261+ }
262+ logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
263+ return nil
264+}
265+
266+func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
267+ if d.onMovePaneToNext != nil {
268+ return d.onMovePaneToNext(ctx)
269+ }
270+ logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
271+ return nil
272+}
273+
242274 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
243275 if ctx == nil {
244276 return fmt.Errorf("missing context")
M internal/ui/input/keyboard.go +8 -0
@@ -139,6 +139,14 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
139139
140140 // Build KeyBinding from event
141141 modifiers := Modifier(state) & modifierMask
142+
143+ // GTK reports shifted letters as uppercase keyvals (e.g. Shift+M -> gdk.KEY_M).
144+ // Normalize ASCII A-Z to lowercase so bindings can consistently use lowercase
145+ // keyvals and rely on ModShift to represent Shift.
146+ if keyval >= uint('A') && keyval <= uint('Z') {
147+ keyval += (uint('a') - uint('A'))
148+ }
149+
142150 binding := KeyBinding{
143151 Keyval: keyval,
144152 Modifiers: modifiers,
M internal/ui/input/modal.go +16 -0
@@ -42,6 +42,22 @@ func (m Mode) String() string {
4242 }
4343 }
4444
45+// DisplayName returns the mode name for display in UI (e.g., toaster).
46+func (m Mode) DisplayName() string {
47+ switch m {
48+ case ModeTab:
49+ return "TAB MODE"
50+ case ModePane:
51+ return "PANE MODE"
52+ case ModeSession:
53+ return "SESSION MODE"
54+ case ModeResize:
55+ return "RESIZE MODE"
56+ default:
57+ return ""
58+ }
59+}
60+
4561 // ModalState manages the current input mode with optional timeout.
4662 type ModalState struct {
4763 mode Mode
M internal/ui/input/shortcuts.go +32 -11
@@ -134,6 +134,9 @@ const (
134134 ActionClosePane Action = "close_pane"
135135 ActionStackPane Action = "stack_pane"
136136
137+ ActionMovePaneToTab Action = "move_pane_to_tab"
138+ ActionMovePaneToNextTab Action = "move_pane_to_next_tab"
139+
137140 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"
138141 ActionConsumeOrExpelRight Action = "consume_or_expel_right"
139142 ActionConsumeOrExpelUp Action = "consume_or_expel_up"
@@ -353,7 +356,7 @@ func (s *ShortcutSet) registerStandardShortcuts() {
353356 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext
354357 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev
355358 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
356- s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload
359+ s.Global[KeyBinding{uint('r'), ModCtrl | ModShift}] = ActionHardReload
357360 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
358361 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
359362 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
@@ -365,7 +368,7 @@ func (s *ShortcutSet) registerStandardShortcuts() {
365368 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
366369 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
367370 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
368- s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL
371+ s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL
369372 // Session management - direct shortcut to open session manager
370373 s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
371374 }
@@ -429,12 +432,14 @@ var configActionToAction = map[string]Action{
429432 "rename-tab": ActionRenameTab,
430433
431434 // Pane actions
432- "split-right": ActionSplitRight,
433- "split-left": ActionSplitLeft,
434- "split-up": ActionSplitUp,
435- "split-down": ActionSplitDown,
436- "close-pane": ActionClosePane,
437- "stack-pane": ActionStackPane,
435+ "split-right": ActionSplitRight,
436+ "split-left": ActionSplitLeft,
437+ "split-up": ActionSplitUp,
438+ "split-down": ActionSplitDown,
439+ "close-pane": ActionClosePane,
440+ "stack-pane": ActionStackPane,
441+ "move-pane-to-tab": ActionMovePaneToTab,
442+ "move-pane-to-next-tab": ActionMovePaneToNextTab,
438443
439444 "consume-or-expel-left": ActionConsumeOrExpelLeft,
440445 "consume-or-expel-right": ActionConsumeOrExpelRight,
@@ -484,7 +489,10 @@ func ParseKeyString(s string) (KeyBinding, bool) {
484489 return KeyBinding{}, false
485490 }
486491
487- s = strings.ToLower(strings.TrimSpace(s))
492+ s = strings.TrimSpace(s)
493+ if s == "" {
494+ return KeyBinding{}, false
495+ }
488496 if s == "+" {
489497 return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
490498 }
@@ -499,7 +507,9 @@ func ParseKeyString(s string) (KeyBinding, bool) {
499507 if part == "" {
500508 continue
501509 }
502- switch part {
510+
511+ lower := strings.ToLower(part)
512+ switch lower {
503513 case "ctrl", "control":
504514 modifiers |= ModCtrl
505515 case "shift":
@@ -523,6 +533,12 @@ func ParseKeyString(s string) (KeyBinding, bool) {
523533 return KeyBinding{}, false
524534 }
525535
536+ // Treat uppercase single-letter keys as Shift+<letter>.
537+ if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
538+ modifiers |= ModShift
539+ keyPart = strings.ToLower(keyPart)
540+ }
541+
526542 keyval, ok := stringToKeyval(keyPart)
527543 if !ok {
528544 return KeyBinding{}, false
@@ -536,7 +552,11 @@ func ParseKeyString(s string) (KeyBinding, bool) {
536552
537553 // stringToKeyval converts a key name to its GDK keyval.
538554 func stringToKeyval(s string) (uint, bool) {
539- if keyval, ok := keyvalByName[s]; ok {
555+ if s == "" {
556+ return 0, false
557+ }
558+
559+ if keyval, ok := keyvalByName[strings.ToLower(s)]; ok {
540560 return keyval, true
541561 }
542562
@@ -588,6 +608,7 @@ func ShouldAutoExitMode(action Action) bool {
588608 case ActionNewTab, ActionCloseTab, ActionRenameTab,
589609 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
590610 ActionClosePane, ActionStackPane,
611+ ActionMovePaneToTab, ActionMovePaneToNextTab,
591612 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
592613 ActionOpenSessionManager:
593614 return true
M internal/ui/input/shortcuts_test.go +16 -1
@@ -207,9 +207,11 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) {
207207 input1 string
208208 input2 string
209209 }{
210- {"ctrl+t", "CTRL+T"},
210+ // Modifiers and named keys are case-insensitive.
211+ {"ctrl+t", "CTRL+t"},
211212 {"Escape", "escape"},
212213 {"Tab", "TAB"},
214+ // If shift is explicitly present, the key letter case shouldn't matter.
213215 {"Ctrl+Shift+T", "ctrl+shift+t"},
214216 }
215217
@@ -230,6 +232,17 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) {
230232 }
231233 }
232234
235+func TestParseKeyString_UppercaseSingleLetterAddsShift(t *testing.T) {
236+ got1, ok1 := ParseKeyString("M")
237+ got2, ok2 := ParseKeyString("shift+m")
238+ if !ok1 || !ok2 {
239+ t.Fatalf("ParseKeyString should succeed for M and shift+m")
240+ }
241+ if got1 != got2 {
242+ t.Fatalf("ParseKeyString uppercase should imply shift: got %v want %v", got1, got2)
243+ }
244+}
245+
233246 func TestShortcutSet_Lookup(t *testing.T) {
234247 set := &ShortcutSet{
235248 Global: ShortcutTable{
@@ -336,6 +349,8 @@ func TestShouldAutoExitMode(t *testing.T) {
336349 ActionSplitDown,
337350 ActionClosePane,
338351 ActionStackPane,
352+ ActionMovePaneToTab,
353+ ActionMovePaneToNextTab,
339354 }
340355
341356 stayActions := []Action{
M internal/ui/layout/gtk_widgets.go +66 -0
@@ -18,6 +18,7 @@ var (
1818 _ ButtonWidget = (*gtkButton)(nil)
1919 _ ImageWidget = (*gtkImage)(nil)
2020 _ ProgressBarWidget = (*gtkProgressBar)(nil)
21+ _ SpinnerWidget = (*gtkSpinner)(nil)
2122 _ WidgetFactory = (*GtkWidgetFactory)(nil)
2223 )
2324
@@ -687,6 +688,66 @@ func (p *gtkProgressBar) ComputePoint(target Widget) (x, y float64, ok bool) {
687688 func (p *gtkProgressBar) SetFraction(fraction float64) { p.inner.SetFraction(fraction) }
688689 func (p *gtkProgressBar) GetFraction() float64 { return p.inner.GetFraction() }
689690
691+// gtkSpinner wraps gtk.Spinner to implement SpinnerWidget.
692+type gtkSpinner struct {
693+ inner *gtk.Spinner
694+}
695+
696+func (s *gtkSpinner) Show() { s.inner.Show() }
697+func (s *gtkSpinner) Hide() { s.inner.Hide() }
698+func (s *gtkSpinner) SetVisible(visible bool) { s.inner.SetVisible(visible) }
699+func (s *gtkSpinner) IsVisible() bool { return s.inner.GetVisible() }
700+func (s *gtkSpinner) GrabFocus() bool { return s.inner.GrabFocus() }
701+func (s *gtkSpinner) HasFocus() bool { return s.inner.HasFocus() }
702+func (s *gtkSpinner) SetCanFocus(canFocus bool) { s.inner.SetCanFocus(canFocus) }
703+func (s *gtkSpinner) SetFocusOnClick(focus bool) { s.inner.SetFocusOnClick(focus) }
704+func (s *gtkSpinner) SetCanTarget(canTarget bool) { s.inner.SetCanTarget(canTarget) }
705+func (s *gtkSpinner) SetHexpand(expand bool) { s.inner.SetHexpand(expand) }
706+func (s *gtkSpinner) SetVexpand(expand bool) { s.inner.SetVexpand(expand) }
707+func (s *gtkSpinner) GetHexpand() bool { return s.inner.GetHexpand() }
708+func (s *gtkSpinner) GetVexpand() bool { return s.inner.GetVexpand() }
709+func (s *gtkSpinner) SetHalign(align gtk.Align) { s.inner.SetHalign(align) }
710+func (s *gtkSpinner) SetValign(align gtk.Align) { s.inner.SetValign(align) }
711+func (s *gtkSpinner) SetSizeRequest(w, h int) { s.inner.SetSizeRequest(w, h) }
712+func (s *gtkSpinner) AddCssClass(class string) { s.inner.AddCssClass(class) }
713+func (s *gtkSpinner) RemoveCssClass(class string) { s.inner.RemoveCssClass(class) }
714+func (s *gtkSpinner) HasCssClass(class string) bool { return s.inner.HasCssClass(class) }
715+func (s *gtkSpinner) Unparent() { s.inner.Unparent() }
716+func (s *gtkSpinner) GtkWidget() *gtk.Widget { return &s.inner.Widget }
717+
718+func (s *gtkSpinner) GetParent() Widget {
719+ parent := s.inner.GetParent()
720+ if parent == nil {
721+ return nil
722+ }
723+ return &gtkWidget{inner: parent}
724+}
725+
726+func (s *gtkSpinner) GetAllocatedWidth() int { return s.inner.GetAllocatedWidth() }
727+func (s *gtkSpinner) GetAllocatedHeight() int { return s.inner.GetAllocatedHeight() }
728+func (s *gtkSpinner) AddController(c *gtk.EventController) { s.inner.AddController(c) }
729+
730+func (s *gtkSpinner) ComputePoint(target Widget) (x, y float64, ok bool) {
731+ srcPoint := &graphene.Point{X: 0, Y: 0}
732+ outPoint := &graphene.Point{}
733+
734+ var targetGtk *gtk.Widget
735+ if target != nil {
736+ targetGtk = target.GtkWidget()
737+ }
738+
739+ ok = s.inner.ComputePoint(targetGtk, srcPoint, outPoint)
740+ if !ok {
741+ return 0, 0, false
742+ }
743+ return float64(outPoint.X), float64(outPoint.Y), true
744+}
745+
746+func (s *gtkSpinner) Start() { s.inner.Start() }
747+func (s *gtkSpinner) Stop() { s.inner.Stop() }
748+func (s *gtkSpinner) SetSpinning(spinning bool) { s.inner.SetSpinning(spinning) }
749+func (s *gtkSpinner) GetSpinning() bool { return s.inner.GetSpinning() }
750+
690751 // GtkWidgetFactory creates real GTK widgets.
691752 type GtkWidgetFactory struct{}
692753
@@ -736,6 +797,11 @@ func (f *GtkWidgetFactory) NewProgressBar() ProgressBarWidget {
736797 return &gtkProgressBar{inner: progressBar}
737798 }
738799
800+func (f *GtkWidgetFactory) NewSpinner() SpinnerWidget {
801+ spinner := gtk.NewSpinner()
802+ return &gtkSpinner{inner: spinner}
803+}
804+
739805 func (f *GtkWidgetFactory) WrapWidget(w *gtk.Widget) Widget {
740806 if w == nil {
741807 return nil
M internal/ui/layout/mocks/mocks.go +1325 -0
@@ -11518,6 +11518,1285 @@ func (_c *MockProgressBarWidget_Unparent_Call) RunAndReturn(run func()) *MockPro
1151811518 return _c
1151911519 }
1152011520
11521+// NewMockSpinnerWidget creates a new instance of MockSpinnerWidget. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
11522+// The first argument is typically a *testing.T value.
11523+func NewMockSpinnerWidget(t interface {
11524+ mock.TestingT
11525+ Cleanup(func())
11526+}) *MockSpinnerWidget {
11527+ mock := &MockSpinnerWidget{}
11528+ mock.Mock.Test(t)
11529+
11530+ t.Cleanup(func() { mock.AssertExpectations(t) })
11531+
11532+ return mock
11533+}
11534+
11535+// MockSpinnerWidget is an autogenerated mock type for the SpinnerWidget type
11536+type MockSpinnerWidget struct {
11537+ mock.Mock
11538+}
11539+
11540+type MockSpinnerWidget_Expecter struct {
11541+ mock *mock.Mock
11542+}
11543+
11544+func (_m *MockSpinnerWidget) EXPECT() *MockSpinnerWidget_Expecter {
11545+ return &MockSpinnerWidget_Expecter{mock: &_m.Mock}
11546+}
11547+
11548+// AddController provides a mock function for the type MockSpinnerWidget
11549+func (_mock *MockSpinnerWidget) AddController(controller *gtk.EventController) {
11550+ _mock.Called(controller)
11551+ return
11552+}
11553+
11554+// MockSpinnerWidget_AddController_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddController'
11555+type MockSpinnerWidget_AddController_Call struct {
11556+ *mock.Call
11557+}
11558+
11559+// AddController is a helper method to define mock.On call
11560+// - controller *gtk.EventController
11561+func (_e *MockSpinnerWidget_Expecter) AddController(controller interface{}) *MockSpinnerWidget_AddController_Call {
11562+ return &MockSpinnerWidget_AddController_Call{Call: _e.mock.On("AddController", controller)}
11563+}
11564+
11565+func (_c *MockSpinnerWidget_AddController_Call) Run(run func(controller *gtk.EventController)) *MockSpinnerWidget_AddController_Call {
11566+ _c.Call.Run(func(args mock.Arguments) {
11567+ var arg0 *gtk.EventController
11568+ if args[0] != nil {
11569+ arg0 = args[0].(*gtk.EventController)
11570+ }
11571+ run(
11572+ arg0,
11573+ )
11574+ })
11575+ return _c
11576+}
11577+
11578+func (_c *MockSpinnerWidget_AddController_Call) Return() *MockSpinnerWidget_AddController_Call {
11579+ _c.Call.Return()
11580+ return _c
11581+}
11582+
11583+func (_c *MockSpinnerWidget_AddController_Call) RunAndReturn(run func(controller *gtk.EventController)) *MockSpinnerWidget_AddController_Call {
11584+ _c.Run(run)
11585+ return _c
11586+}
11587+
11588+// AddCssClass provides a mock function for the type MockSpinnerWidget
11589+func (_mock *MockSpinnerWidget) AddCssClass(cssClass string) {
11590+ _mock.Called(cssClass)
11591+ return
11592+}
11593+
11594+// MockSpinnerWidget_AddCssClass_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddCssClass'
11595+type MockSpinnerWidget_AddCssClass_Call struct {
11596+ *mock.Call
11597+}
11598+
11599+// AddCssClass is a helper method to define mock.On call
11600+// - cssClass string
11601+func (_e *MockSpinnerWidget_Expecter) AddCssClass(cssClass interface{}) *MockSpinnerWidget_AddCssClass_Call {
11602+ return &MockSpinnerWidget_AddCssClass_Call{Call: _e.mock.On("AddCssClass", cssClass)}
11603+}
11604+
11605+func (_c *MockSpinnerWidget_AddCssClass_Call) Run(run func(cssClass string)) *MockSpinnerWidget_AddCssClass_Call {
11606+ _c.Call.Run(func(args mock.Arguments) {
11607+ var arg0 string
11608+ if args[0] != nil {
11609+ arg0 = args[0].(string)
11610+ }
11611+ run(
11612+ arg0,
11613+ )
11614+ })
11615+ return _c
11616+}
11617+
11618+func (_c *MockSpinnerWidget_AddCssClass_Call) Return() *MockSpinnerWidget_AddCssClass_Call {
11619+ _c.Call.Return()
11620+ return _c
11621+}
11622+
11623+func (_c *MockSpinnerWidget_AddCssClass_Call) RunAndReturn(run func(cssClass string)) *MockSpinnerWidget_AddCssClass_Call {
11624+ _c.Run(run)
11625+ return _c
11626+}
11627+
11628+// ComputePoint provides a mock function for the type MockSpinnerWidget
11629+func (_mock *MockSpinnerWidget) ComputePoint(target layout.Widget) (float64, float64, bool) {
11630+ ret := _mock.Called(target)
11631+
11632+ if len(ret) == 0 {
11633+ panic("no return value specified for ComputePoint")
11634+ }
11635+
11636+ var r0 float64
11637+ var r1 float64
11638+ var r2 bool
11639+ if returnFunc, ok := ret.Get(0).(func(layout.Widget) (float64, float64, bool)); ok {
11640+ return returnFunc(target)
11641+ }
11642+ if returnFunc, ok := ret.Get(0).(func(layout.Widget) float64); ok {
11643+ r0 = returnFunc(target)
11644+ } else {
11645+ r0 = ret.Get(0).(float64)
11646+ }
11647+ if returnFunc, ok := ret.Get(1).(func(layout.Widget) float64); ok {
11648+ r1 = returnFunc(target)
11649+ } else {
11650+ r1 = ret.Get(1).(float64)
11651+ }
11652+ if returnFunc, ok := ret.Get(2).(func(layout.Widget) bool); ok {
11653+ r2 = returnFunc(target)
11654+ } else {
11655+ r2 = ret.Get(2).(bool)
11656+ }
11657+ return r0, r1, r2
11658+}
11659+
11660+// MockSpinnerWidget_ComputePoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ComputePoint'
11661+type MockSpinnerWidget_ComputePoint_Call struct {
11662+ *mock.Call
11663+}
11664+
11665+// ComputePoint is a helper method to define mock.On call
11666+// - target layout.Widget
11667+func (_e *MockSpinnerWidget_Expecter) ComputePoint(target interface{}) *MockSpinnerWidget_ComputePoint_Call {
11668+ return &MockSpinnerWidget_ComputePoint_Call{Call: _e.mock.On("ComputePoint", target)}
11669+}
11670+
11671+func (_c *MockSpinnerWidget_ComputePoint_Call) Run(run func(target layout.Widget)) *MockSpinnerWidget_ComputePoint_Call {
11672+ _c.Call.Run(func(args mock.Arguments) {
11673+ var arg0 layout.Widget
11674+ if args[0] != nil {
11675+ arg0 = args[0].(layout.Widget)
11676+ }
11677+ run(
11678+ arg0,
11679+ )
11680+ })
11681+ return _c
11682+}
11683+
11684+func (_c *MockSpinnerWidget_ComputePoint_Call) Return(x float64, y float64, ok bool) *MockSpinnerWidget_ComputePoint_Call {
11685+ _c.Call.Return(x, y, ok)
11686+ return _c
11687+}
11688+
11689+func (_c *MockSpinnerWidget_ComputePoint_Call) RunAndReturn(run func(target layout.Widget) (float64, float64, bool)) *MockSpinnerWidget_ComputePoint_Call {
11690+ _c.Call.Return(run)
11691+ return _c
11692+}
11693+
11694+// GetAllocatedHeight provides a mock function for the type MockSpinnerWidget
11695+func (_mock *MockSpinnerWidget) GetAllocatedHeight() int {
11696+ ret := _mock.Called()
11697+
11698+ if len(ret) == 0 {
11699+ panic("no return value specified for GetAllocatedHeight")
11700+ }
11701+
11702+ var r0 int
11703+ if returnFunc, ok := ret.Get(0).(func() int); ok {
11704+ r0 = returnFunc()
11705+ } else {
11706+ r0 = ret.Get(0).(int)
11707+ }
11708+ return r0
11709+}
11710+
11711+// MockSpinnerWidget_GetAllocatedHeight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllocatedHeight'
11712+type MockSpinnerWidget_GetAllocatedHeight_Call struct {
11713+ *mock.Call
11714+}
11715+
11716+// GetAllocatedHeight is a helper method to define mock.On call
11717+func (_e *MockSpinnerWidget_Expecter) GetAllocatedHeight() *MockSpinnerWidget_GetAllocatedHeight_Call {
11718+ return &MockSpinnerWidget_GetAllocatedHeight_Call{Call: _e.mock.On("GetAllocatedHeight")}
11719+}
11720+
11721+func (_c *MockSpinnerWidget_GetAllocatedHeight_Call) Run(run func()) *MockSpinnerWidget_GetAllocatedHeight_Call {
11722+ _c.Call.Run(func(args mock.Arguments) {
11723+ run()
11724+ })
11725+ return _c
11726+}
11727+
11728+func (_c *MockSpinnerWidget_GetAllocatedHeight_Call) Return(n int) *MockSpinnerWidget_GetAllocatedHeight_Call {
11729+ _c.Call.Return(n)
11730+ return _c
11731+}
11732+
11733+func (_c *MockSpinnerWidget_GetAllocatedHeight_Call) RunAndReturn(run func() int) *MockSpinnerWidget_GetAllocatedHeight_Call {
11734+ _c.Call.Return(run)
11735+ return _c
11736+}
11737+
11738+// GetAllocatedWidth provides a mock function for the type MockSpinnerWidget
11739+func (_mock *MockSpinnerWidget) GetAllocatedWidth() int {
11740+ ret := _mock.Called()
11741+
11742+ if len(ret) == 0 {
11743+ panic("no return value specified for GetAllocatedWidth")
11744+ }
11745+
11746+ var r0 int
11747+ if returnFunc, ok := ret.Get(0).(func() int); ok {
11748+ r0 = returnFunc()
11749+ } else {
11750+ r0 = ret.Get(0).(int)
11751+ }
11752+ return r0
11753+}
11754+
11755+// MockSpinnerWidget_GetAllocatedWidth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllocatedWidth'
11756+type MockSpinnerWidget_GetAllocatedWidth_Call struct {
11757+ *mock.Call
11758+}
11759+
11760+// GetAllocatedWidth is a helper method to define mock.On call
11761+func (_e *MockSpinnerWidget_Expecter) GetAllocatedWidth() *MockSpinnerWidget_GetAllocatedWidth_Call {
11762+ return &MockSpinnerWidget_GetAllocatedWidth_Call{Call: _e.mock.On("GetAllocatedWidth")}
11763+}
11764+
11765+func (_c *MockSpinnerWidget_GetAllocatedWidth_Call) Run(run func()) *MockSpinnerWidget_GetAllocatedWidth_Call {
11766+ _c.Call.Run(func(args mock.Arguments) {
11767+ run()
11768+ })
11769+ return _c
11770+}
11771+
11772+func (_c *MockSpinnerWidget_GetAllocatedWidth_Call) Return(n int) *MockSpinnerWidget_GetAllocatedWidth_Call {
11773+ _c.Call.Return(n)
11774+ return _c
11775+}
11776+
11777+func (_c *MockSpinnerWidget_GetAllocatedWidth_Call) RunAndReturn(run func() int) *MockSpinnerWidget_GetAllocatedWidth_Call {
11778+ _c.Call.Return(run)
11779+ return _c
11780+}
11781+
11782+// GetHexpand provides a mock function for the type MockSpinnerWidget
11783+func (_mock *MockSpinnerWidget) GetHexpand() bool {
11784+ ret := _mock.Called()
11785+
11786+ if len(ret) == 0 {
11787+ panic("no return value specified for GetHexpand")
11788+ }
11789+
11790+ var r0 bool
11791+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
11792+ r0 = returnFunc()
11793+ } else {
11794+ r0 = ret.Get(0).(bool)
11795+ }
11796+ return r0
11797+}
11798+
11799+// MockSpinnerWidget_GetHexpand_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetHexpand'
11800+type MockSpinnerWidget_GetHexpand_Call struct {
11801+ *mock.Call
11802+}
11803+
11804+// GetHexpand is a helper method to define mock.On call
11805+func (_e *MockSpinnerWidget_Expecter) GetHexpand() *MockSpinnerWidget_GetHexpand_Call {
11806+ return &MockSpinnerWidget_GetHexpand_Call{Call: _e.mock.On("GetHexpand")}
11807+}
11808+
11809+func (_c *MockSpinnerWidget_GetHexpand_Call) Run(run func()) *MockSpinnerWidget_GetHexpand_Call {
11810+ _c.Call.Run(func(args mock.Arguments) {
11811+ run()
11812+ })
11813+ return _c
11814+}
11815+
11816+func (_c *MockSpinnerWidget_GetHexpand_Call) Return(b bool) *MockSpinnerWidget_GetHexpand_Call {
11817+ _c.Call.Return(b)
11818+ return _c
11819+}
11820+
11821+func (_c *MockSpinnerWidget_GetHexpand_Call) RunAndReturn(run func() bool) *MockSpinnerWidget_GetHexpand_Call {
11822+ _c.Call.Return(run)
11823+ return _c
11824+}
11825+
11826+// GetParent provides a mock function for the type MockSpinnerWidget
11827+func (_mock *MockSpinnerWidget) GetParent() layout.Widget {
11828+ ret := _mock.Called()
11829+
11830+ if len(ret) == 0 {
11831+ panic("no return value specified for GetParent")
11832+ }
11833+
11834+ var r0 layout.Widget
11835+ if returnFunc, ok := ret.Get(0).(func() layout.Widget); ok {
11836+ r0 = returnFunc()
11837+ } else {
11838+ if ret.Get(0) != nil {
11839+ r0 = ret.Get(0).(layout.Widget)
11840+ }
11841+ }
11842+ return r0
11843+}
11844+
11845+// MockSpinnerWidget_GetParent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetParent'
11846+type MockSpinnerWidget_GetParent_Call struct {
11847+ *mock.Call
11848+}
11849+
11850+// GetParent is a helper method to define mock.On call
11851+func (_e *MockSpinnerWidget_Expecter) GetParent() *MockSpinnerWidget_GetParent_Call {
11852+ return &MockSpinnerWidget_GetParent_Call{Call: _e.mock.On("GetParent")}
11853+}
11854+
11855+func (_c *MockSpinnerWidget_GetParent_Call) Run(run func()) *MockSpinnerWidget_GetParent_Call {
11856+ _c.Call.Run(func(args mock.Arguments) {
11857+ run()
11858+ })
11859+ return _c
11860+}
11861+
11862+func (_c *MockSpinnerWidget_GetParent_Call) Return(widget layout.Widget) *MockSpinnerWidget_GetParent_Call {
11863+ _c.Call.Return(widget)
11864+ return _c
11865+}
11866+
11867+func (_c *MockSpinnerWidget_GetParent_Call) RunAndReturn(run func() layout.Widget) *MockSpinnerWidget_GetParent_Call {
11868+ _c.Call.Return(run)
11869+ return _c
11870+}
11871+
11872+// GetSpinning provides a mock function for the type MockSpinnerWidget
11873+func (_mock *MockSpinnerWidget) GetSpinning() bool {
11874+ ret := _mock.Called()
11875+
11876+ if len(ret) == 0 {
11877+ panic("no return value specified for GetSpinning")
11878+ }
11879+
11880+ var r0 bool
11881+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
11882+ r0 = returnFunc()
11883+ } else {
11884+ r0 = ret.Get(0).(bool)
11885+ }
11886+ return r0
11887+}
11888+
11889+// MockSpinnerWidget_GetSpinning_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSpinning'
11890+type MockSpinnerWidget_GetSpinning_Call struct {
11891+ *mock.Call
11892+}
11893+
11894+// GetSpinning is a helper method to define mock.On call
11895+func (_e *MockSpinnerWidget_Expecter) GetSpinning() *MockSpinnerWidget_GetSpinning_Call {
11896+ return &MockSpinnerWidget_GetSpinning_Call{Call: _e.mock.On("GetSpinning")}
11897+}
11898+
11899+func (_c *MockSpinnerWidget_GetSpinning_Call) Run(run func()) *MockSpinnerWidget_GetSpinning_Call {
11900+ _c.Call.Run(func(args mock.Arguments) {
11901+ run()
11902+ })
11903+ return _c
11904+}
11905+
11906+func (_c *MockSpinnerWidget_GetSpinning_Call) Return(b bool) *MockSpinnerWidget_GetSpinning_Call {
11907+ _c.Call.Return(b)
11908+ return _c
11909+}
11910+
11911+func (_c *MockSpinnerWidget_GetSpinning_Call) RunAndReturn(run func() bool) *MockSpinnerWidget_GetSpinning_Call {
11912+ _c.Call.Return(run)
11913+ return _c
11914+}
11915+
11916+// GetVexpand provides a mock function for the type MockSpinnerWidget
11917+func (_mock *MockSpinnerWidget) GetVexpand() bool {
11918+ ret := _mock.Called()
11919+
11920+ if len(ret) == 0 {
11921+ panic("no return value specified for GetVexpand")
11922+ }
11923+
11924+ var r0 bool
11925+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
11926+ r0 = returnFunc()
11927+ } else {
11928+ r0 = ret.Get(0).(bool)
11929+ }
11930+ return r0
11931+}
11932+
11933+// MockSpinnerWidget_GetVexpand_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetVexpand'
11934+type MockSpinnerWidget_GetVexpand_Call struct {
11935+ *mock.Call
11936+}
11937+
11938+// GetVexpand is a helper method to define mock.On call
11939+func (_e *MockSpinnerWidget_Expecter) GetVexpand() *MockSpinnerWidget_GetVexpand_Call {
11940+ return &MockSpinnerWidget_GetVexpand_Call{Call: _e.mock.On("GetVexpand")}
11941+}
11942+
11943+func (_c *MockSpinnerWidget_GetVexpand_Call) Run(run func()) *MockSpinnerWidget_GetVexpand_Call {
11944+ _c.Call.Run(func(args mock.Arguments) {
11945+ run()
11946+ })
11947+ return _c
11948+}
11949+
11950+func (_c *MockSpinnerWidget_GetVexpand_Call) Return(b bool) *MockSpinnerWidget_GetVexpand_Call {
11951+ _c.Call.Return(b)
11952+ return _c
11953+}
11954+
11955+func (_c *MockSpinnerWidget_GetVexpand_Call) RunAndReturn(run func() bool) *MockSpinnerWidget_GetVexpand_Call {
11956+ _c.Call.Return(run)
11957+ return _c
11958+}
11959+
11960+// GrabFocus provides a mock function for the type MockSpinnerWidget
11961+func (_mock *MockSpinnerWidget) GrabFocus() bool {
11962+ ret := _mock.Called()
11963+
11964+ if len(ret) == 0 {
11965+ panic("no return value specified for GrabFocus")
11966+ }
11967+
11968+ var r0 bool
11969+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
11970+ r0 = returnFunc()
11971+ } else {
11972+ r0 = ret.Get(0).(bool)
11973+ }
11974+ return r0
11975+}
11976+
11977+// MockSpinnerWidget_GrabFocus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GrabFocus'
11978+type MockSpinnerWidget_GrabFocus_Call struct {
11979+ *mock.Call
11980+}
11981+
11982+// GrabFocus is a helper method to define mock.On call
11983+func (_e *MockSpinnerWidget_Expecter) GrabFocus() *MockSpinnerWidget_GrabFocus_Call {
11984+ return &MockSpinnerWidget_GrabFocus_Call{Call: _e.mock.On("GrabFocus")}
11985+}
11986+
11987+func (_c *MockSpinnerWidget_GrabFocus_Call) Run(run func()) *MockSpinnerWidget_GrabFocus_Call {
11988+ _c.Call.Run(func(args mock.Arguments) {
11989+ run()
11990+ })
11991+ return _c
11992+}
11993+
11994+func (_c *MockSpinnerWidget_GrabFocus_Call) Return(b bool) *MockSpinnerWidget_GrabFocus_Call {
11995+ _c.Call.Return(b)
11996+ return _c
11997+}
11998+
11999+func (_c *MockSpinnerWidget_GrabFocus_Call) RunAndReturn(run func() bool) *MockSpinnerWidget_GrabFocus_Call {
12000+ _c.Call.Return(run)
12001+ return _c
12002+}
12003+
12004+// GtkWidget provides a mock function for the type MockSpinnerWidget
12005+func (_mock *MockSpinnerWidget) GtkWidget() *gtk.Widget {
12006+ ret := _mock.Called()
12007+
12008+ if len(ret) == 0 {
12009+ panic("no return value specified for GtkWidget")
12010+ }
12011+
12012+ var r0 *gtk.Widget
12013+ if returnFunc, ok := ret.Get(0).(func() *gtk.Widget); ok {
12014+ r0 = returnFunc()
12015+ } else {
12016+ if ret.Get(0) != nil {
12017+ r0 = ret.Get(0).(*gtk.Widget)
12018+ }
12019+ }
12020+ return r0
12021+}
12022+
12023+// MockSpinnerWidget_GtkWidget_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GtkWidget'
12024+type MockSpinnerWidget_GtkWidget_Call struct {
12025+ *mock.Call
12026+}
12027+
12028+// GtkWidget is a helper method to define mock.On call
12029+func (_e *MockSpinnerWidget_Expecter) GtkWidget() *MockSpinnerWidget_GtkWidget_Call {
12030+ return &MockSpinnerWidget_GtkWidget_Call{Call: _e.mock.On("GtkWidget")}
12031+}
12032+
12033+func (_c *MockSpinnerWidget_GtkWidget_Call) Run(run func()) *MockSpinnerWidget_GtkWidget_Call {
12034+ _c.Call.Run(func(args mock.Arguments) {
12035+ run()
12036+ })
12037+ return _c
12038+}
12039+
12040+func (_c *MockSpinnerWidget_GtkWidget_Call) Return(widget *gtk.Widget) *MockSpinnerWidget_GtkWidget_Call {
12041+ _c.Call.Return(widget)
12042+ return _c
12043+}
12044+
12045+func (_c *MockSpinnerWidget_GtkWidget_Call) RunAndReturn(run func() *gtk.Widget) *MockSpinnerWidget_GtkWidget_Call {
12046+ _c.Call.Return(run)
12047+ return _c
12048+}
12049+
12050+// HasCssClass provides a mock function for the type MockSpinnerWidget
12051+func (_mock *MockSpinnerWidget) HasCssClass(cssClass string) bool {
12052+ ret := _mock.Called(cssClass)
12053+
12054+ if len(ret) == 0 {
12055+ panic("no return value specified for HasCssClass")
12056+ }
12057+
12058+ var r0 bool
12059+ if returnFunc, ok := ret.Get(0).(func(string) bool); ok {
12060+ r0 = returnFunc(cssClass)
12061+ } else {
12062+ r0 = ret.Get(0).(bool)
12063+ }
12064+ return r0
12065+}
12066+
12067+// MockSpinnerWidget_HasCssClass_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasCssClass'
12068+type MockSpinnerWidget_HasCssClass_Call struct {
12069+ *mock.Call
12070+}
12071+
12072+// HasCssClass is a helper method to define mock.On call
12073+// - cssClass string
12074+func (_e *MockSpinnerWidget_Expecter) HasCssClass(cssClass interface{}) *MockSpinnerWidget_HasCssClass_Call {
12075+ return &MockSpinnerWidget_HasCssClass_Call{Call: _e.mock.On("HasCssClass", cssClass)}
12076+}
12077+
12078+func (_c *MockSpinnerWidget_HasCssClass_Call) Run(run func(cssClass string)) *MockSpinnerWidget_HasCssClass_Call {
12079+ _c.Call.Run(func(args mock.Arguments) {
12080+ var arg0 string
12081+ if args[0] != nil {
12082+ arg0 = args[0].(string)
12083+ }
12084+ run(
12085+ arg0,
12086+ )
12087+ })
12088+ return _c
12089+}
12090+
12091+func (_c *MockSpinnerWidget_HasCssClass_Call) Return(b bool) *MockSpinnerWidget_HasCssClass_Call {
12092+ _c.Call.Return(b)
12093+ return _c
12094+}
12095+
12096+func (_c *MockSpinnerWidget_HasCssClass_Call) RunAndReturn(run func(cssClass string) bool) *MockSpinnerWidget_HasCssClass_Call {
12097+ _c.Call.Return(run)
12098+ return _c
12099+}
12100+
12101+// HasFocus provides a mock function for the type MockSpinnerWidget
12102+func (_mock *MockSpinnerWidget) HasFocus() bool {
12103+ ret := _mock.Called()
12104+
12105+ if len(ret) == 0 {
12106+ panic("no return value specified for HasFocus")
12107+ }
12108+
12109+ var r0 bool
12110+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
12111+ r0 = returnFunc()
12112+ } else {
12113+ r0 = ret.Get(0).(bool)
12114+ }
12115+ return r0
12116+}
12117+
12118+// MockSpinnerWidget_HasFocus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasFocus'
12119+type MockSpinnerWidget_HasFocus_Call struct {
12120+ *mock.Call
12121+}
12122+
12123+// HasFocus is a helper method to define mock.On call
12124+func (_e *MockSpinnerWidget_Expecter) HasFocus() *MockSpinnerWidget_HasFocus_Call {
12125+ return &MockSpinnerWidget_HasFocus_Call{Call: _e.mock.On("HasFocus")}
12126+}
12127+
12128+func (_c *MockSpinnerWidget_HasFocus_Call) Run(run func()) *MockSpinnerWidget_HasFocus_Call {
12129+ _c.Call.Run(func(args mock.Arguments) {
12130+ run()
12131+ })
12132+ return _c
12133+}
12134+
12135+func (_c *MockSpinnerWidget_HasFocus_Call) Return(b bool) *MockSpinnerWidget_HasFocus_Call {
12136+ _c.Call.Return(b)
12137+ return _c
12138+}
12139+
12140+func (_c *MockSpinnerWidget_HasFocus_Call) RunAndReturn(run func() bool) *MockSpinnerWidget_HasFocus_Call {
12141+ _c.Call.Return(run)
12142+ return _c
12143+}
12144+
12145+// Hide provides a mock function for the type MockSpinnerWidget
12146+func (_mock *MockSpinnerWidget) Hide() {
12147+ _mock.Called()
12148+ return
12149+}
12150+
12151+// MockSpinnerWidget_Hide_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Hide'
12152+type MockSpinnerWidget_Hide_Call struct {
12153+ *mock.Call
12154+}
12155+
12156+// Hide is a helper method to define mock.On call
12157+func (_e *MockSpinnerWidget_Expecter) Hide() *MockSpinnerWidget_Hide_Call {
12158+ return &MockSpinnerWidget_Hide_Call{Call: _e.mock.On("Hide")}
12159+}
12160+
12161+func (_c *MockSpinnerWidget_Hide_Call) Run(run func()) *MockSpinnerWidget_Hide_Call {
12162+ _c.Call.Run(func(args mock.Arguments) {
12163+ run()
12164+ })
12165+ return _c
12166+}
12167+
12168+func (_c *MockSpinnerWidget_Hide_Call) Return() *MockSpinnerWidget_Hide_Call {
12169+ _c.Call.Return()
12170+ return _c
12171+}
12172+
12173+func (_c *MockSpinnerWidget_Hide_Call) RunAndReturn(run func()) *MockSpinnerWidget_Hide_Call {
12174+ _c.Run(run)
12175+ return _c
12176+}
12177+
12178+// IsVisible provides a mock function for the type MockSpinnerWidget
12179+func (_mock *MockSpinnerWidget) IsVisible() bool {
12180+ ret := _mock.Called()
12181+
12182+ if len(ret) == 0 {
12183+ panic("no return value specified for IsVisible")
12184+ }
12185+
12186+ var r0 bool
12187+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
12188+ r0 = returnFunc()
12189+ } else {
12190+ r0 = ret.Get(0).(bool)
12191+ }
12192+ return r0
12193+}
12194+
12195+// MockSpinnerWidget_IsVisible_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsVisible'
12196+type MockSpinnerWidget_IsVisible_Call struct {
12197+ *mock.Call
12198+}
12199+
12200+// IsVisible is a helper method to define mock.On call
12201+func (_e *MockSpinnerWidget_Expecter) IsVisible() *MockSpinnerWidget_IsVisible_Call {
12202+ return &MockSpinnerWidget_IsVisible_Call{Call: _e.mock.On("IsVisible")}
12203+}
12204+
12205+func (_c *MockSpinnerWidget_IsVisible_Call) Run(run func()) *MockSpinnerWidget_IsVisible_Call {
12206+ _c.Call.Run(func(args mock.Arguments) {
12207+ run()
12208+ })
12209+ return _c
12210+}
12211+
12212+func (_c *MockSpinnerWidget_IsVisible_Call) Return(b bool) *MockSpinnerWidget_IsVisible_Call {
12213+ _c.Call.Return(b)
12214+ return _c
12215+}
12216+
12217+func (_c *MockSpinnerWidget_IsVisible_Call) RunAndReturn(run func() bool) *MockSpinnerWidget_IsVisible_Call {
12218+ _c.Call.Return(run)
12219+ return _c
12220+}
12221+
12222+// RemoveCssClass provides a mock function for the type MockSpinnerWidget
12223+func (_mock *MockSpinnerWidget) RemoveCssClass(cssClass string) {
12224+ _mock.Called(cssClass)
12225+ return
12226+}
12227+
12228+// MockSpinnerWidget_RemoveCssClass_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveCssClass'
12229+type MockSpinnerWidget_RemoveCssClass_Call struct {
12230+ *mock.Call
12231+}
12232+
12233+// RemoveCssClass is a helper method to define mock.On call
12234+// - cssClass string
12235+func (_e *MockSpinnerWidget_Expecter) RemoveCssClass(cssClass interface{}) *MockSpinnerWidget_RemoveCssClass_Call {
12236+ return &MockSpinnerWidget_RemoveCssClass_Call{Call: _e.mock.On("RemoveCssClass", cssClass)}
12237+}
12238+
12239+func (_c *MockSpinnerWidget_RemoveCssClass_Call) Run(run func(cssClass string)) *MockSpinnerWidget_RemoveCssClass_Call {
12240+ _c.Call.Run(func(args mock.Arguments) {
12241+ var arg0 string
12242+ if args[0] != nil {
12243+ arg0 = args[0].(string)
12244+ }
12245+ run(
12246+ arg0,
12247+ )
12248+ })
12249+ return _c
12250+}
12251+
12252+func (_c *MockSpinnerWidget_RemoveCssClass_Call) Return() *MockSpinnerWidget_RemoveCssClass_Call {
12253+ _c.Call.Return()
12254+ return _c
12255+}
12256+
12257+func (_c *MockSpinnerWidget_RemoveCssClass_Call) RunAndReturn(run func(cssClass string)) *MockSpinnerWidget_RemoveCssClass_Call {
12258+ _c.Run(run)
12259+ return _c
12260+}
12261+
12262+// SetCanFocus provides a mock function for the type MockSpinnerWidget
12263+func (_mock *MockSpinnerWidget) SetCanFocus(canFocus bool) {
12264+ _mock.Called(canFocus)
12265+ return
12266+}
12267+
12268+// MockSpinnerWidget_SetCanFocus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetCanFocus'
12269+type MockSpinnerWidget_SetCanFocus_Call struct {
12270+ *mock.Call
12271+}
12272+
12273+// SetCanFocus is a helper method to define mock.On call
12274+// - canFocus bool
12275+func (_e *MockSpinnerWidget_Expecter) SetCanFocus(canFocus interface{}) *MockSpinnerWidget_SetCanFocus_Call {
12276+ return &MockSpinnerWidget_SetCanFocus_Call{Call: _e.mock.On("SetCanFocus", canFocus)}
12277+}
12278+
12279+func (_c *MockSpinnerWidget_SetCanFocus_Call) Run(run func(canFocus bool)) *MockSpinnerWidget_SetCanFocus_Call {
12280+ _c.Call.Run(func(args mock.Arguments) {
12281+ var arg0 bool
12282+ if args[0] != nil {
12283+ arg0 = args[0].(bool)
12284+ }
12285+ run(
12286+ arg0,
12287+ )
12288+ })
12289+ return _c
12290+}
12291+
12292+func (_c *MockSpinnerWidget_SetCanFocus_Call) Return() *MockSpinnerWidget_SetCanFocus_Call {
12293+ _c.Call.Return()
12294+ return _c
12295+}
12296+
12297+func (_c *MockSpinnerWidget_SetCanFocus_Call) RunAndReturn(run func(canFocus bool)) *MockSpinnerWidget_SetCanFocus_Call {
12298+ _c.Run(run)
12299+ return _c
12300+}
12301+
12302+// SetCanTarget provides a mock function for the type MockSpinnerWidget
12303+func (_mock *MockSpinnerWidget) SetCanTarget(canTarget bool) {
12304+ _mock.Called(canTarget)
12305+ return
12306+}
12307+
12308+// MockSpinnerWidget_SetCanTarget_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetCanTarget'
12309+type MockSpinnerWidget_SetCanTarget_Call struct {
12310+ *mock.Call
12311+}
12312+
12313+// SetCanTarget is a helper method to define mock.On call
12314+// - canTarget bool
12315+func (_e *MockSpinnerWidget_Expecter) SetCanTarget(canTarget interface{}) *MockSpinnerWidget_SetCanTarget_Call {
12316+ return &MockSpinnerWidget_SetCanTarget_Call{Call: _e.mock.On("SetCanTarget", canTarget)}
12317+}
12318+
12319+func (_c *MockSpinnerWidget_SetCanTarget_Call) Run(run func(canTarget bool)) *MockSpinnerWidget_SetCanTarget_Call {
12320+ _c.Call.Run(func(args mock.Arguments) {
12321+ var arg0 bool
12322+ if args[0] != nil {
12323+ arg0 = args[0].(bool)
12324+ }
12325+ run(
12326+ arg0,
12327+ )
12328+ })
12329+ return _c
12330+}
12331+
12332+func (_c *MockSpinnerWidget_SetCanTarget_Call) Return() *MockSpinnerWidget_SetCanTarget_Call {
12333+ _c.Call.Return()
12334+ return _c
12335+}
12336+
12337+func (_c *MockSpinnerWidget_SetCanTarget_Call) RunAndReturn(run func(canTarget bool)) *MockSpinnerWidget_SetCanTarget_Call {
12338+ _c.Run(run)
12339+ return _c
12340+}
12341+
12342+// SetFocusOnClick provides a mock function for the type MockSpinnerWidget
12343+func (_mock *MockSpinnerWidget) SetFocusOnClick(focusOnClick bool) {
12344+ _mock.Called(focusOnClick)
12345+ return
12346+}
12347+
12348+// MockSpinnerWidget_SetFocusOnClick_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFocusOnClick'
12349+type MockSpinnerWidget_SetFocusOnClick_Call struct {
12350+ *mock.Call
12351+}
12352+
12353+// SetFocusOnClick is a helper method to define mock.On call
12354+// - focusOnClick bool
12355+func (_e *MockSpinnerWidget_Expecter) SetFocusOnClick(focusOnClick interface{}) *MockSpinnerWidget_SetFocusOnClick_Call {
12356+ return &MockSpinnerWidget_SetFocusOnClick_Call{Call: _e.mock.On("SetFocusOnClick", focusOnClick)}
12357+}
12358+
12359+func (_c *MockSpinnerWidget_SetFocusOnClick_Call) Run(run func(focusOnClick bool)) *MockSpinnerWidget_SetFocusOnClick_Call {
12360+ _c.Call.Run(func(args mock.Arguments) {
12361+ var arg0 bool
12362+ if args[0] != nil {
12363+ arg0 = args[0].(bool)
12364+ }
12365+ run(
12366+ arg0,
12367+ )
12368+ })
12369+ return _c
12370+}
12371+
12372+func (_c *MockSpinnerWidget_SetFocusOnClick_Call) Return() *MockSpinnerWidget_SetFocusOnClick_Call {
12373+ _c.Call.Return()
12374+ return _c
12375+}
12376+
12377+func (_c *MockSpinnerWidget_SetFocusOnClick_Call) RunAndReturn(run func(focusOnClick bool)) *MockSpinnerWidget_SetFocusOnClick_Call {
12378+ _c.Run(run)
12379+ return _c
12380+}
12381+
12382+// SetHalign provides a mock function for the type MockSpinnerWidget
12383+func (_mock *MockSpinnerWidget) SetHalign(align gtk.Align) {
12384+ _mock.Called(align)
12385+ return
12386+}
12387+
12388+// MockSpinnerWidget_SetHalign_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetHalign'
12389+type MockSpinnerWidget_SetHalign_Call struct {
12390+ *mock.Call
12391+}
12392+
12393+// SetHalign is a helper method to define mock.On call
12394+// - align gtk.Align
12395+func (_e *MockSpinnerWidget_Expecter) SetHalign(align interface{}) *MockSpinnerWidget_SetHalign_Call {
12396+ return &MockSpinnerWidget_SetHalign_Call{Call: _e.mock.On("SetHalign", align)}
12397+}
12398+
12399+func (_c *MockSpinnerWidget_SetHalign_Call) Run(run func(align gtk.Align)) *MockSpinnerWidget_SetHalign_Call {
12400+ _c.Call.Run(func(args mock.Arguments) {
12401+ var arg0 gtk.Align
12402+ if args[0] != nil {
12403+ arg0 = args[0].(gtk.Align)
12404+ }
12405+ run(
12406+ arg0,
12407+ )
12408+ })
12409+ return _c
12410+}
12411+
12412+func (_c *MockSpinnerWidget_SetHalign_Call) Return() *MockSpinnerWidget_SetHalign_Call {
12413+ _c.Call.Return()
12414+ return _c
12415+}
12416+
12417+func (_c *MockSpinnerWidget_SetHalign_Call) RunAndReturn(run func(align gtk.Align)) *MockSpinnerWidget_SetHalign_Call {
12418+ _c.Run(run)
12419+ return _c
12420+}
12421+
12422+// SetHexpand provides a mock function for the type MockSpinnerWidget
12423+func (_mock *MockSpinnerWidget) SetHexpand(expand bool) {
12424+ _mock.Called(expand)
12425+ return
12426+}
12427+
12428+// MockSpinnerWidget_SetHexpand_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetHexpand'
12429+type MockSpinnerWidget_SetHexpand_Call struct {
12430+ *mock.Call
12431+}
12432+
12433+// SetHexpand is a helper method to define mock.On call
12434+// - expand bool
12435+func (_e *MockSpinnerWidget_Expecter) SetHexpand(expand interface{}) *MockSpinnerWidget_SetHexpand_Call {
12436+ return &MockSpinnerWidget_SetHexpand_Call{Call: _e.mock.On("SetHexpand", expand)}
12437+}
12438+
12439+func (_c *MockSpinnerWidget_SetHexpand_Call) Run(run func(expand bool)) *MockSpinnerWidget_SetHexpand_Call {
12440+ _c.Call.Run(func(args mock.Arguments) {
12441+ var arg0 bool
12442+ if args[0] != nil {
12443+ arg0 = args[0].(bool)
12444+ }
12445+ run(
12446+ arg0,
12447+ )
12448+ })
12449+ return _c
12450+}
12451+
12452+func (_c *MockSpinnerWidget_SetHexpand_Call) Return() *MockSpinnerWidget_SetHexpand_Call {
12453+ _c.Call.Return()
12454+ return _c
12455+}
12456+
12457+func (_c *MockSpinnerWidget_SetHexpand_Call) RunAndReturn(run func(expand bool)) *MockSpinnerWidget_SetHexpand_Call {
12458+ _c.Run(run)
12459+ return _c
12460+}
12461+
12462+// SetSizeRequest provides a mock function for the type MockSpinnerWidget
12463+func (_mock *MockSpinnerWidget) SetSizeRequest(width int, height int) {
12464+ _mock.Called(width, height)
12465+ return
12466+}
12467+
12468+// MockSpinnerWidget_SetSizeRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetSizeRequest'
12469+type MockSpinnerWidget_SetSizeRequest_Call struct {
12470+ *mock.Call
12471+}
12472+
12473+// SetSizeRequest is a helper method to define mock.On call
12474+// - width int
12475+// - height int
12476+func (_e *MockSpinnerWidget_Expecter) SetSizeRequest(width interface{}, height interface{}) *MockSpinnerWidget_SetSizeRequest_Call {
12477+ return &MockSpinnerWidget_SetSizeRequest_Call{Call: _e.mock.On("SetSizeRequest", width, height)}
12478+}
12479+
12480+func (_c *MockSpinnerWidget_SetSizeRequest_Call) Run(run func(width int, height int)) *MockSpinnerWidget_SetSizeRequest_Call {
12481+ _c.Call.Run(func(args mock.Arguments) {
12482+ var arg0 int
12483+ if args[0] != nil {
12484+ arg0 = args[0].(int)
12485+ }
12486+ var arg1 int
12487+ if args[1] != nil {
12488+ arg1 = args[1].(int)
12489+ }
12490+ run(
12491+ arg0,
12492+ arg1,
12493+ )
12494+ })
12495+ return _c
12496+}
12497+
12498+func (_c *MockSpinnerWidget_SetSizeRequest_Call) Return() *MockSpinnerWidget_SetSizeRequest_Call {
12499+ _c.Call.Return()
12500+ return _c
12501+}
12502+
12503+func (_c *MockSpinnerWidget_SetSizeRequest_Call) RunAndReturn(run func(width int, height int)) *MockSpinnerWidget_SetSizeRequest_Call {
12504+ _c.Run(run)
12505+ return _c
12506+}
12507+
12508+// SetSpinning provides a mock function for the type MockSpinnerWidget
12509+func (_mock *MockSpinnerWidget) SetSpinning(spinning bool) {
12510+ _mock.Called(spinning)
12511+ return
12512+}
12513+
12514+// MockSpinnerWidget_SetSpinning_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetSpinning'
12515+type MockSpinnerWidget_SetSpinning_Call struct {
12516+ *mock.Call
12517+}
12518+
12519+// SetSpinning is a helper method to define mock.On call
12520+// - spinning bool
12521+func (_e *MockSpinnerWidget_Expecter) SetSpinning(spinning interface{}) *MockSpinnerWidget_SetSpinning_Call {
12522+ return &MockSpinnerWidget_SetSpinning_Call{Call: _e.mock.On("SetSpinning", spinning)}
12523+}
12524+
12525+func (_c *MockSpinnerWidget_SetSpinning_Call) Run(run func(spinning bool)) *MockSpinnerWidget_SetSpinning_Call {
12526+ _c.Call.Run(func(args mock.Arguments) {
12527+ var arg0 bool
12528+ if args[0] != nil {
12529+ arg0 = args[0].(bool)
12530+ }
12531+ run(
12532+ arg0,
12533+ )
12534+ })
12535+ return _c
12536+}
12537+
12538+func (_c *MockSpinnerWidget_SetSpinning_Call) Return() *MockSpinnerWidget_SetSpinning_Call {
12539+ _c.Call.Return()
12540+ return _c
12541+}
12542+
12543+func (_c *MockSpinnerWidget_SetSpinning_Call) RunAndReturn(run func(spinning bool)) *MockSpinnerWidget_SetSpinning_Call {
12544+ _c.Run(run)
12545+ return _c
12546+}
12547+
12548+// SetValign provides a mock function for the type MockSpinnerWidget
12549+func (_mock *MockSpinnerWidget) SetValign(align gtk.Align) {
12550+ _mock.Called(align)
12551+ return
12552+}
12553+
12554+// MockSpinnerWidget_SetValign_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetValign'
12555+type MockSpinnerWidget_SetValign_Call struct {
12556+ *mock.Call
12557+}
12558+
12559+// SetValign is a helper method to define mock.On call
12560+// - align gtk.Align
12561+func (_e *MockSpinnerWidget_Expecter) SetValign(align interface{}) *MockSpinnerWidget_SetValign_Call {
12562+ return &MockSpinnerWidget_SetValign_Call{Call: _e.mock.On("SetValign", align)}
12563+}
12564+
12565+func (_c *MockSpinnerWidget_SetValign_Call) Run(run func(align gtk.Align)) *MockSpinnerWidget_SetValign_Call {
12566+ _c.Call.Run(func(args mock.Arguments) {
12567+ var arg0 gtk.Align
12568+ if args[0] != nil {
12569+ arg0 = args[0].(gtk.Align)
12570+ }
12571+ run(
12572+ arg0,
12573+ )
12574+ })
12575+ return _c
12576+}
12577+
12578+func (_c *MockSpinnerWidget_SetValign_Call) Return() *MockSpinnerWidget_SetValign_Call {
12579+ _c.Call.Return()
12580+ return _c
12581+}
12582+
12583+func (_c *MockSpinnerWidget_SetValign_Call) RunAndReturn(run func(align gtk.Align)) *MockSpinnerWidget_SetValign_Call {
12584+ _c.Run(run)
12585+ return _c
12586+}
12587+
12588+// SetVexpand provides a mock function for the type MockSpinnerWidget
12589+func (_mock *MockSpinnerWidget) SetVexpand(expand bool) {
12590+ _mock.Called(expand)
12591+ return
12592+}
12593+
12594+// MockSpinnerWidget_SetVexpand_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetVexpand'
12595+type MockSpinnerWidget_SetVexpand_Call struct {
12596+ *mock.Call
12597+}
12598+
12599+// SetVexpand is a helper method to define mock.On call
12600+// - expand bool
12601+func (_e *MockSpinnerWidget_Expecter) SetVexpand(expand interface{}) *MockSpinnerWidget_SetVexpand_Call {
12602+ return &MockSpinnerWidget_SetVexpand_Call{Call: _e.mock.On("SetVexpand", expand)}
12603+}
12604+
12605+func (_c *MockSpinnerWidget_SetVexpand_Call) Run(run func(expand bool)) *MockSpinnerWidget_SetVexpand_Call {
12606+ _c.Call.Run(func(args mock.Arguments) {
12607+ var arg0 bool
12608+ if args[0] != nil {
12609+ arg0 = args[0].(bool)
12610+ }
12611+ run(
12612+ arg0,
12613+ )
12614+ })
12615+ return _c
12616+}
12617+
12618+func (_c *MockSpinnerWidget_SetVexpand_Call) Return() *MockSpinnerWidget_SetVexpand_Call {
12619+ _c.Call.Return()
12620+ return _c
12621+}
12622+
12623+func (_c *MockSpinnerWidget_SetVexpand_Call) RunAndReturn(run func(expand bool)) *MockSpinnerWidget_SetVexpand_Call {
12624+ _c.Run(run)
12625+ return _c
12626+}
12627+
12628+// SetVisible provides a mock function for the type MockSpinnerWidget
12629+func (_mock *MockSpinnerWidget) SetVisible(visible bool) {
12630+ _mock.Called(visible)
12631+ return
12632+}
12633+
12634+// MockSpinnerWidget_SetVisible_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetVisible'
12635+type MockSpinnerWidget_SetVisible_Call struct {
12636+ *mock.Call
12637+}
12638+
12639+// SetVisible is a helper method to define mock.On call
12640+// - visible bool
12641+func (_e *MockSpinnerWidget_Expecter) SetVisible(visible interface{}) *MockSpinnerWidget_SetVisible_Call {
12642+ return &MockSpinnerWidget_SetVisible_Call{Call: _e.mock.On("SetVisible", visible)}
12643+}
12644+
12645+func (_c *MockSpinnerWidget_SetVisible_Call) Run(run func(visible bool)) *MockSpinnerWidget_SetVisible_Call {
12646+ _c.Call.Run(func(args mock.Arguments) {
12647+ var arg0 bool
12648+ if args[0] != nil {
12649+ arg0 = args[0].(bool)
12650+ }
12651+ run(
12652+ arg0,
12653+ )
12654+ })
12655+ return _c
12656+}
12657+
12658+func (_c *MockSpinnerWidget_SetVisible_Call) Return() *MockSpinnerWidget_SetVisible_Call {
12659+ _c.Call.Return()
12660+ return _c
12661+}
12662+
12663+func (_c *MockSpinnerWidget_SetVisible_Call) RunAndReturn(run func(visible bool)) *MockSpinnerWidget_SetVisible_Call {
12664+ _c.Run(run)
12665+ return _c
12666+}
12667+
12668+// Show provides a mock function for the type MockSpinnerWidget
12669+func (_mock *MockSpinnerWidget) Show() {
12670+ _mock.Called()
12671+ return
12672+}
12673+
12674+// MockSpinnerWidget_Show_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Show'
12675+type MockSpinnerWidget_Show_Call struct {
12676+ *mock.Call
12677+}
12678+
12679+// Show is a helper method to define mock.On call
12680+func (_e *MockSpinnerWidget_Expecter) Show() *MockSpinnerWidget_Show_Call {
12681+ return &MockSpinnerWidget_Show_Call{Call: _e.mock.On("Show")}
12682+}
12683+
12684+func (_c *MockSpinnerWidget_Show_Call) Run(run func()) *MockSpinnerWidget_Show_Call {
12685+ _c.Call.Run(func(args mock.Arguments) {
12686+ run()
12687+ })
12688+ return _c
12689+}
12690+
12691+func (_c *MockSpinnerWidget_Show_Call) Return() *MockSpinnerWidget_Show_Call {
12692+ _c.Call.Return()
12693+ return _c
12694+}
12695+
12696+func (_c *MockSpinnerWidget_Show_Call) RunAndReturn(run func()) *MockSpinnerWidget_Show_Call {
12697+ _c.Run(run)
12698+ return _c
12699+}
12700+
12701+// Start provides a mock function for the type MockSpinnerWidget
12702+func (_mock *MockSpinnerWidget) Start() {
12703+ _mock.Called()
12704+ return
12705+}
12706+
12707+// MockSpinnerWidget_Start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Start'
12708+type MockSpinnerWidget_Start_Call struct {
12709+ *mock.Call
12710+}
12711+
12712+// Start is a helper method to define mock.On call
12713+func (_e *MockSpinnerWidget_Expecter) Start() *MockSpinnerWidget_Start_Call {
12714+ return &MockSpinnerWidget_Start_Call{Call: _e.mock.On("Start")}
12715+}
12716+
12717+func (_c *MockSpinnerWidget_Start_Call) Run(run func()) *MockSpinnerWidget_Start_Call {
12718+ _c.Call.Run(func(args mock.Arguments) {
12719+ run()
12720+ })
12721+ return _c
12722+}
12723+
12724+func (_c *MockSpinnerWidget_Start_Call) Return() *MockSpinnerWidget_Start_Call {
12725+ _c.Call.Return()
12726+ return _c
12727+}
12728+
12729+func (_c *MockSpinnerWidget_Start_Call) RunAndReturn(run func()) *MockSpinnerWidget_Start_Call {
12730+ _c.Run(run)
12731+ return _c
12732+}
12733+
12734+// Stop provides a mock function for the type MockSpinnerWidget
12735+func (_mock *MockSpinnerWidget) Stop() {
12736+ _mock.Called()
12737+ return
12738+}
12739+
12740+// MockSpinnerWidget_Stop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stop'
12741+type MockSpinnerWidget_Stop_Call struct {
12742+ *mock.Call
12743+}
12744+
12745+// Stop is a helper method to define mock.On call
12746+func (_e *MockSpinnerWidget_Expecter) Stop() *MockSpinnerWidget_Stop_Call {
12747+ return &MockSpinnerWidget_Stop_Call{Call: _e.mock.On("Stop")}
12748+}
12749+
12750+func (_c *MockSpinnerWidget_Stop_Call) Run(run func()) *MockSpinnerWidget_Stop_Call {
12751+ _c.Call.Run(func(args mock.Arguments) {
12752+ run()
12753+ })
12754+ return _c
12755+}
12756+
12757+func (_c *MockSpinnerWidget_Stop_Call) Return() *MockSpinnerWidget_Stop_Call {
12758+ _c.Call.Return()
12759+ return _c
12760+}
12761+
12762+func (_c *MockSpinnerWidget_Stop_Call) RunAndReturn(run func()) *MockSpinnerWidget_Stop_Call {
12763+ _c.Run(run)
12764+ return _c
12765+}
12766+
12767+// Unparent provides a mock function for the type MockSpinnerWidget
12768+func (_mock *MockSpinnerWidget) Unparent() {
12769+ _mock.Called()
12770+ return
12771+}
12772+
12773+// MockSpinnerWidget_Unparent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Unparent'
12774+type MockSpinnerWidget_Unparent_Call struct {
12775+ *mock.Call
12776+}
12777+
12778+// Unparent is a helper method to define mock.On call
12779+func (_e *MockSpinnerWidget_Expecter) Unparent() *MockSpinnerWidget_Unparent_Call {
12780+ return &MockSpinnerWidget_Unparent_Call{Call: _e.mock.On("Unparent")}
12781+}
12782+
12783+func (_c *MockSpinnerWidget_Unparent_Call) Run(run func()) *MockSpinnerWidget_Unparent_Call {
12784+ _c.Call.Run(func(args mock.Arguments) {
12785+ run()
12786+ })
12787+ return _c
12788+}
12789+
12790+func (_c *MockSpinnerWidget_Unparent_Call) Return() *MockSpinnerWidget_Unparent_Call {
12791+ _c.Call.Return()
12792+ return _c
12793+}
12794+
12795+func (_c *MockSpinnerWidget_Unparent_Call) RunAndReturn(run func()) *MockSpinnerWidget_Unparent_Call {
12796+ _c.Run(run)
12797+ return _c
12798+}
12799+
1152112800 // NewMockWidgetFactory creates a new instance of MockWidgetFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1152212801 // The first argument is typically a *testing.T value.
1152312802 func NewMockWidgetFactory(t interface {
@@ -11894,6 +13173,52 @@ func (_c *MockWidgetFactory_NewProgressBar_Call) RunAndReturn(run func() layout.
1189413173 return _c
1189513174 }
1189613175
13176+// NewSpinner provides a mock function for the type MockWidgetFactory
13177+func (_mock *MockWidgetFactory) NewSpinner() layout.SpinnerWidget {
13178+ ret := _mock.Called()
13179+
13180+ if len(ret) == 0 {
13181+ panic("no return value specified for NewSpinner")
13182+ }
13183+
13184+ var r0 layout.SpinnerWidget
13185+ if returnFunc, ok := ret.Get(0).(func() layout.SpinnerWidget); ok {
13186+ r0 = returnFunc()
13187+ } else {
13188+ if ret.Get(0) != nil {
13189+ r0 = ret.Get(0).(layout.SpinnerWidget)
13190+ }
13191+ }
13192+ return r0
13193+}
13194+
13195+// MockWidgetFactory_NewSpinner_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewSpinner'
13196+type MockWidgetFactory_NewSpinner_Call struct {
13197+ *mock.Call
13198+}
13199+
13200+// NewSpinner is a helper method to define mock.On call
13201+func (_e *MockWidgetFactory_Expecter) NewSpinner() *MockWidgetFactory_NewSpinner_Call {
13202+ return &MockWidgetFactory_NewSpinner_Call{Call: _e.mock.On("NewSpinner")}
13203+}
13204+
13205+func (_c *MockWidgetFactory_NewSpinner_Call) Run(run func()) *MockWidgetFactory_NewSpinner_Call {
13206+ _c.Call.Run(func(args mock.Arguments) {
13207+ run()
13208+ })
13209+ return _c
13210+}
13211+
13212+func (_c *MockWidgetFactory_NewSpinner_Call) Return(spinnerWidget layout.SpinnerWidget) *MockWidgetFactory_NewSpinner_Call {
13213+ _c.Call.Return(spinnerWidget)
13214+ return _c
13215+}
13216+
13217+func (_c *MockWidgetFactory_NewSpinner_Call) RunAndReturn(run func() layout.SpinnerWidget) *MockWidgetFactory_NewSpinner_Call {
13218+ _c.Call.Return(run)
13219+ return _c
13220+}
13221+
1189713222 // WrapWidget provides a mock function for the type MockWidgetFactory
1189813223 func (_mock *MockWidgetFactory) WrapWidget(w *gtk.Widget) layout.Widget {
1189913224 ret := _mock.Called(w)
M internal/ui/layout/widgets.go +11 -0
@@ -211,6 +211,16 @@ type ProgressBarWidget interface {
211211 GetFraction() float64
212212 }
213213
214+// SpinnerWidget wraps gtk.Spinner for displaying indefinite loading.
215+type SpinnerWidget interface {
216+ Widget
217+
218+ Start()
219+ Stop()
220+ SetSpinning(spinning bool)
221+ GetSpinning() bool
222+}
223+
214224 // WidgetFactory creates widget instances.
215225 // This abstraction allows tests to inject mock factories.
216226 type WidgetFactory interface {
@@ -224,6 +234,7 @@ type WidgetFactory interface {
224234 NewButton() ButtonWidget
225235 NewImage() ImageWidget
226236 NewProgressBar() ProgressBarWidget
237+ NewSpinner() SpinnerWidget
227238
228239 // Wrap existing GTK widget
229240 WrapWidget(w *gtk.Widget) Widget
M internal/ui/theme/css.go +114 -13
@@ -50,7 +50,14 @@ window, tooltip, popover {
5050
5151 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
5252 // Scale affects font sizes and widget padding/margins proportionally.
53+// Uses default mode colors.
5354 func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {
55+ return GenerateCSSFull(p, scale, fonts, DefaultModeColors())
56+}
57+
58+// GenerateCSSFull creates GTK4 CSS using all provided configuration.
59+// Scale affects font sizes and widget padding/margins proportionally.
60+func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors ModeColors) string {
5461 if scale <= 0 {
5562 scale = 1.0
5663 }
@@ -68,6 +75,7 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
6875 sb.WriteString(":root {\n")
6976 sb.WriteString(p.ToCSSVars())
7077 sb.WriteString(FontCSSVars(fonts))
78+ sb.WriteString(modeColors.ToCSSVars())
7179 sb.WriteString("}\n\n")
7280
7381 // Global font styling
@@ -114,6 +122,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
114122
115123 // Session manager styling
116124 sb.WriteString(generateSessionManagerCSS(p))
125+ sb.WriteString("\n")
126+
127+ // Tab picker styling
128+ sb.WriteString(generateTabPickerCSS(p))
117129
118130 return sb.String()
119131 }
@@ -287,6 +299,22 @@ entry.omnibox-entry > *:focus-visible {
287299 border-left: 0.1875em solid var(--accent);
288300 }
289301
302+/* Favorite indicator in history mode - yellowish accent */
303+.omnibox-row.omnibox-row-favorite {
304+ background-color: alpha(var(--warning), 0.08);
305+ border-left: 0.1875em solid var(--warning);
306+}
307+
308+.omnibox-row.omnibox-row-favorite:hover {
309+ background-color: alpha(var(--warning), 0.15);
310+ border-left: 0.1875em solid var(--warning);
311+}
312+
313+.omnibox-row.omnibox-row-favorite:selected {
314+ background-color: alpha(var(--warning), 0.2);
315+ border-left: 0.1875em solid var(--warning);
316+}
317+
290318 /* Favicon in omnibox rows */
291319 .omnibox-favicon {
292320 min-width: 1em;
@@ -311,15 +339,15 @@ entry.omnibox-entry > *:focus-visible {
311339 font-weight: 500;
312340 }
313341
314-/* URL text below title */
315-.omnibox-suggestion-url {
342+/* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */
343+.omnibox-row .omnibox-suggestion-url {
316344 font-size: 0.75em;
317- color: var(--muted);
345+ color: alpha(var(--muted), 0.65);
318346 font-weight: 400;
319347 }
320348
321349 .omnibox-row:selected .omnibox-suggestion-url {
322- color: var(--muted);
350+ color: alpha(var(--muted), 0.75);
323351 }
324352
325353 /* Keyboard shortcut badge */
@@ -344,6 +372,16 @@ entry.omnibox-entry > *:focus-visible {
344372 color: var(--accent);
345373 }
346374
375+/* Bang indicator badge in omnibox header */
376+.omnibox-bang-badge {
377+ background-color: alpha(var(--accent), 0.18);
378+ color: var(--accent);
379+ border-radius: 0.25em;
380+ padding: 0.125em 0.5em;
381+ font-size: 0.6875em;
382+ font-weight: 600;
383+}
384+
347385 /* Zoom indicator in omnibox header */
348386 .omnibox-zoom-indicator {
349387 background-color: alpha(var(--accent), 0.2);
@@ -354,6 +392,19 @@ entry.omnibox-entry > *:focus-visible {
354392 font-weight: 600;
355393 /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */
356394 }
395+
396+entry.omnibox-entry.omnibox-entry-bang-active,
397+entry.omnibox-entry.omnibox-entry-bang-active:focus,
398+entry.omnibox-entry.omnibox-entry-bang-active:focus-within,
399+entry.omnibox-entry.omnibox-entry-bang-active:focus-visible {
400+ border-color: var(--accent);
401+ background-color: shade(var(--bg), 1.05);
402+}
403+
404+.omnibox-row.omnibox-row-bang .omnibox-suggestion-title {
405+ color: var(--accent);
406+ font-weight: 600;
407+}
357408 `
358409 }
359410
@@ -447,6 +498,29 @@ func generatePaneCSS(p Palette) string {
447498 background-color: var(--bg);
448499 }
449500
501+/* WebView widget - theme background prevents white flash during page load */
502+.webview-themed {
503+ background-color: var(--bg);
504+}
505+
506+/* Loading skeleton - shown until WebView paints */
507+.loading-skeleton {
508+ background-color: var(--bg);
509+}
510+
511+.loading-skeleton-spinner {
512+ -gtk-icon-size: 32px;
513+ min-width: 32px;
514+ min-height: 32px;
515+ color: var(--muted);
516+}
517+
518+.loading-skeleton-logo {
519+ opacity: 0.08;
520+ min-width: 512px;
521+ min-height: 512px;
522+}
523+
450524 /* Pane border - default transparent */
451525 .pane-border {
452526 border: 0.0625em solid transparent;
@@ -462,26 +536,26 @@ func generatePaneCSS(p Palette) string {
462536 border-color: transparent;
463537 }
464538
465-/* Pane mode active - thick blue inset border (for overlay) */
539+/* Pane mode active - thick inset border (for overlay) */
466540 .pane-mode-active {
467541 background-color: transparent;
468- box-shadow: inset 0 0 0 0.25em #4A90E2;
542+ box-shadow: inset 0 0 0 0.25em var(--pane-mode-color);
469543 border-radius: 0;
470544 }
471545
472-/* Tab mode active - thick orange inset border (for overlay) */
546+/* Tab mode active - thick inset border (for overlay) */
473547 .tab-mode-active {
474548 background-color: transparent;
475- box-shadow: inset 0 0 0 0.25em #FFA500;
549+ box-shadow: inset 0 0 0 0.25em var(--tab-mode-color);
476550 border-radius: 0;
477551 }
478552
479-/* Resize mode active - thick teal inset border */
553+/* Resize mode active - thick inset border */
480554 .resize-mode-active {
481555 background-color: transparent;
482556 /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */
483- box-shadow: inset 0 0 0 0.25em #00D4AA;
484- outline: 0.25em solid #00D4AA;
557+ box-shadow: inset 0 0 0 0.25em var(--resize-mode-color);
558+ outline: 0.25em solid var(--resize-mode-color);
485559 outline-offset: -0.25em;
486560 border-radius: 0;
487561 }
@@ -578,6 +652,7 @@ progressbar.osd progress {
578652 // generateSessionManagerCSS creates session manager modal styles.
579653 // Uses em units for scalable UI, matches omnibox styling patterns.
580654 func generateSessionManagerCSS(p Palette) string {
655+ _ = p
581656 return `/* ===== Session Manager Styling ===== */
582657
583658 /* Session manager outer container - for positioning in overlay */
@@ -728,10 +803,10 @@ row:selected .session-manager-row {
728803 font-family: var(--font-mono);
729804 }
730805
731-/* Session mode border (purple) */
806+/* Session mode border */
732807 .session-mode-active {
733808 background-color: transparent;
734- box-shadow: inset 0 0 0 0.25em #9B59B6;
809+ box-shadow: inset 0 0 0 0.25em var(--session-mode-color);
735810 border-radius: 0;
736811 }
737812 `
@@ -777,6 +852,32 @@ func generateToasterCSS(p Palette) string {
777852 background-color: alpha(var(--destructive), 0.9);
778853 color: var(--bg);
779854 }
855+
856+/* Toast with custom styling (mode indicator toasters) */
857+.toast-custom {
858+ color: #ffffff;
859+}
860+
861+/* Mode-specific toast colors */
862+.toast-pane-mode {
863+ background-color: var(--pane-mode-color);
864+ color: #ffffff;
865+}
866+
867+.toast-tab-mode {
868+ background-color: var(--tab-mode-color);
869+ color: #ffffff;
870+}
871+
872+.toast-session-mode {
873+ background-color: var(--session-mode-color);
874+ color: #ffffff;
875+}
876+
877+.toast-resize-mode {
878+ background-color: var(--resize-mode-color);
879+ color: #ffffff;
880+}
780881 `
781882 }
782883
M internal/ui/theme/manager.go +30 -2
@@ -18,6 +18,7 @@ type Manager struct {
1818 darkPalette Palette // Dark theme colors
1919 uiScale float64 // UI scaling factor (1.0 = 100%)
2020 fonts FontConfig
21+ modeColors ModeColors // Modal mode indicator colors
2122 cssProvider *gtk.CssProvider
2223 }
2324
@@ -56,6 +57,9 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
5657 fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont)
5758 }
5859
60+ // Build mode colors from config
61+ modeColors := modeColorsFromConfig(cfg)
62+
5963 log.Debug().
6064 Str("scheme", scheme).
6165 Bool("prefers_dark", prefersDark).
@@ -71,6 +75,22 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
7175 darkPalette: darkPalette,
7276 uiScale: uiScale,
7377 fonts: fonts,
78+ modeColors: modeColors,
79+ }
80+}
81+
82+// modeColorsFromConfig extracts mode colors from config, using defaults for missing values.
83+func modeColorsFromConfig(cfg *config.Config) ModeColors {
84+ defaults := DefaultModeColors()
85+ if cfg == nil {
86+ return defaults
87+ }
88+ styling := &cfg.Workspace.Styling
89+ return ModeColors{
90+ PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode),
91+ TabMode: Coalesce(styling.TabModeColor, defaults.TabMode),
92+ SessionMode: Coalesce(styling.SessionModeColor, defaults.SessionMode),
93+ ResizeMode: Coalesce(styling.ResizeModeColor, defaults.ResizeMode),
7494 }
7595 }
7696
@@ -104,6 +124,11 @@ func (m *Manager) GetDarkPalette() Palette {
104124 return m.darkPalette
105125 }
106126
127+// GetModeColors returns the modal mode indicator colors.
128+func (m *Manager) GetModeColors() ModeColors {
129+ return m.modeColors
130+}
131+
107132 // GetWebUIThemeCSS returns CSS text that defines both light and dark variables.
108133 // WebUI uses `:root` for light and `.dark` overrides for dark.
109134 func (m *Manager) GetWebUIThemeCSS() string {
@@ -121,9 +146,9 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
121146 return
122147 }
123148
124- // Generate CSS with current palette, UI scale and fonts
149+ // Generate CSS with current palette, UI scale, fonts, and mode colors
125150 palette := m.GetCurrentPalette()
126- css := GenerateCSSWithScaleAndFonts(palette, m.uiScale, m.fonts)
151+ css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors)
127152
128153 // Create CSS provider if needed
129154 if m.cssProvider == nil {
@@ -197,6 +222,9 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp
197222 MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont),
198223 }
199224
225+ // Update mode colors
226+ m.modeColors = modeColorsFromConfig(cfg)
227+
200228 log.Info().
201229 Str("scheme", m.scheme).
202230 Bool("prefers_dark", m.prefersDark).
M internal/ui/theme/palette.go +28 -0
@@ -24,6 +24,34 @@ type Palette struct {
2424 Destructive string // Error/destructive actions
2525 }
2626
27+// ModeColors holds colors for modal mode indicators (borders and toasters).
28+type ModeColors struct {
29+ PaneMode string // Color for pane mode (default: #4A90E2 blue)
30+ TabMode string // Color for tab mode (default: #FFA500 orange)
31+ SessionMode string // Color for session mode (default: #9B59B6 purple)
32+ ResizeMode string // Color for resize mode (default: #00D4AA teal)
33+}
34+
35+// DefaultModeColors returns the default mode indicator colors.
36+func DefaultModeColors() ModeColors {
37+ return ModeColors{
38+ PaneMode: "#4A90E2",
39+ TabMode: "#FFA500",
40+ SessionMode: "#9B59B6",
41+ ResizeMode: "#00D4AA",
42+ }
43+}
44+
45+// ToCSSVars generates CSS custom property declarations for mode colors.
46+func (m ModeColors) ToCSSVars() string {
47+ var sb strings.Builder
48+ sb.WriteString(" --pane-mode-color: " + m.PaneMode + ";\n")
49+ sb.WriteString(" --tab-mode-color: " + m.TabMode + ";\n")
50+ sb.WriteString(" --session-mode-color: " + m.SessionMode + ";\n")
51+ sb.WriteString(" --resize-mode-color: " + m.ResizeMode + ";\n")
52+ return sb.String()
53+}
54+
2755 // DefaultDarkPalette returns the default dark theme palette.
2856 func DefaultDarkPalette() Palette {
2957 return Palette{
A internal/ui/theme/tab_picker_css.go +81 -0
@@ -0,0 +1,81 @@
1+package theme
2+
3+// generateTabPickerCSS creates tab picker modal styles.
4+// Uses similar styling conventions as Omnibox/SessionManager.
5+func generateTabPickerCSS(p Palette) string {
6+ _ = p
7+ return `/* ===== Tab Picker Styling ===== */
8+
9+.tab-picker-outer {
10+ /* Positioning handled in Go */
11+}
12+
13+.tab-picker-container {
14+ background-color: var(--surface-variant);
15+ border: 0.0625em solid var(--border);
16+ border-radius: 0.1875em;
17+ padding: 0;
18+ min-width: 28em;
19+}
20+
21+.tab-picker-header {
22+ background-color: shade(var(--surface-variant), 1.1);
23+ border-bottom: 0.0625em solid var(--border);
24+ padding: 0.5em 0.75em;
25+}
26+
27+.tab-picker-title {
28+ font-size: 0.9375em;
29+ font-weight: 600;
30+ color: var(--text);
31+}
32+
33+.tab-picker-scrolled {
34+ background-color: shade(var(--surface-variant), 0.95);
35+}
36+
37+.tab-picker-list {
38+ background-color: transparent;
39+}
40+
41+.tab-picker-row {
42+ padding: 0.5em 0.75em;
43+ margin: 0;
44+ border-radius: 0;
45+ border-left: 0.1875em solid transparent;
46+ border-bottom: 0.0625em solid alpha(var(--border), 0.5);
47+ transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
48+ min-height: 2.75em;
49+}
50+
51+.tab-picker-row:last-child {
52+ border-bottom: none;
53+}
54+
55+.tab-picker-row:hover {
56+ background-color: alpha(var(--accent), 0.12);
57+ border-left: 0.1875em solid var(--accent);
58+}
59+
60+.tab-picker-row:selected,
61+row:selected .tab-picker-row {
62+ background-color: alpha(var(--accent), 0.2);
63+ border-left: 0.1875em solid var(--accent);
64+}
65+
66+.tab-picker-row-title {
67+ color: var(--text);
68+ font-size: 0.875em;
69+ font-weight: 400;
70+}
71+
72+.tab-picker-footer {
73+ background-color: shade(var(--surface-variant), 0.9);
74+ border-top: 0.0625em solid var(--border);
75+ padding: 0.375em 0.75em;
76+ font-size: 0.6875em;
77+ color: var(--muted);
78+ font-family: var(--font-mono);
79+}
80+`
81+}
A webui/public/favicon.png +0 -0 binary file: no text diff

binary file: no text diff

M webui/src/pages/homepage/favorites/FavoriteCard.svelte +4 -0
@@ -21,6 +21,10 @@
2121 const getFaviconUrl = (fav: Favorite): string => {
2222 // Use provided favicon_url if available
2323 if (fav.favicon_url) return fav.favicon_url;
24+ // Handle internal dumb:// URLs - use embedded favicon
25+ if (fav.url.startsWith('dumb://')) {
26+ return 'favicon.png';
27+ }
2428 // Construct DuckDuckGo favicon URL from domain
2529 const domain = getDomain(fav.url);
2630 return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
M webui/src/pages/homepage/history/HistoryItem.svelte +4 -0
@@ -23,6 +23,10 @@
2323 const getFaviconUrl = (item: HistoryEntry): string => {
2424 // Use provided favicon_url if available
2525 if (item.favicon_url) return item.favicon_url;
26+ // Handle internal dumb:// URLs - use embedded favicon
27+ if (item.url.startsWith('dumb://')) {
28+ return 'favicon.png';
29+ }
2630 // Construct DuckDuckGo favicon URL from domain
2731 const domain = getDomain(item.url).replace(/^www\./, '');
2832 return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
M webui/vite.config.ts +21 -0
@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
22 import { svelte } from "@sveltejs/vite-plugin-svelte";
33 import { resolve } from "path";
44 import { pageGenerator } from "./vite-plugin-pages";
5+import { copyFileSync, existsSync, mkdirSync } from "fs";
56
67 // Page configurations for dumb:// protocol pages
78 const pages = {
@@ -46,6 +47,20 @@ const pages = {
4647 // Get page from VITE_PAGE env var, default to building all
4748 const targetPage = process.env.VITE_PAGE as keyof typeof pages | undefined;
4849
50+// Copy public assets to output directory
51+function copyPublicAssets() {
52+ const outDir = resolve(__dirname, "../assets/webui");
53+ const publicDir = resolve(__dirname, "public");
54+ if (!existsSync(outDir)) {
55+ mkdirSync(outDir, { recursive: true });
56+ }
57+ // Copy favicon.png for dumb:// URL favicons
58+ const faviconSrc = resolve(publicDir, "favicon.png");
59+ if (existsSync(faviconSrc)) {
60+ copyFileSync(faviconSrc, resolve(outDir, "favicon.png"));
61+ }
62+}
63+
4964 // Build single page config
5065 function buildPageConfig(pageName: keyof typeof pages) {
5166 const page = pages[pageName];
@@ -55,6 +70,12 @@ function buildPageConfig(pageName: keyof typeof pages) {
5570 emitCss: false, // Inline component CSS for shadow DOM compatibility
5671 }),
5772 pageGenerator([page.html]),
73+ {
74+ name: "copy-public-assets",
75+ closeBundle() {
76+ copyPublicAssets();
77+ },
78+ },
5879 ],
5980 build: {
6081 rollupOptions: {

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