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:
8 filename: mocks.go8 filename: mocks.go
9 pkgname: mocks9 pkgname: mocks
10 interfaces:10 interfaces:
11+ ConfigMigrator: {}
12+ ConfigSchemaProvider: {}
13+ DatabaseProvider: {}
14+ DiffFormatter: {}
11 SessionLogger: {}15 SessionLogger: {}
12 SessionSpawner: {}16 SessionSpawner: {}
13 TabListProvider: {}17 TabListProvider: {}
@@ -51,5 +55,6 @@ packages:
51 PaneViewFactory: {}55 PaneViewFactory: {}
52 PanedWidget: {}56 PanedWidget: {}
53 ProgressBarWidget: {}57 ProgressBarWidget: {}
58+ SpinnerWidget: {}
54 Widget: {}59 Widget: {}
55 WidgetFactory: {}60 WidgetFactory: {}
M CHANGELOG.md +48 -0
@@ -4,7 +4,55 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
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+
7 ### Fixed47 ### 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.
8 - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag).56 - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag).
9 57
10 ## [0.21.0] - 2025-12-2658 ## [0.21.0] - 2025-12-26
M Makefile +6 -1
@@ -1,6 +1,6 @@
1 # Makefile for dumber (Clean Architecture - puregotk)1 # Makefile for dumber (Clean Architecture - puregotk)
2 2
3-.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs3+.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man
4 4
5 # Load local overrides from .env.local if present (Makefile syntax)5 # Load local overrides from .env.local if present (Makefile syntax)
6 ifneq (,$(wildcard .env.local))6 ifneq (,$(wildcard .env.local))
@@ -137,6 +137,11 @@ check: ## Check that all tools and dependencies are working
137 @$(MAKE) test137 @$(MAKE) test
138 @echo "\nAll checks passed!"138 @echo "\nAll checks passed!"
139 139
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+
140 # Native release targets145 # Native release targets
141 .PHONY: release-snapshot release146 .PHONY: release-snapshot release
142 147
M README.md +15 -2
@@ -77,6 +77,8 @@ Some features are very alpha/experimental (notably consume-or-expel panes) and m
77 | **↓** / **D** (in pane mode) | Split Down | Create new pane below |77 | **↓** / **D** (in pane mode) | Split Down | Create new pane below |
78 | **S** (in pane mode) | Stack Pane | Create stacked pane (Zellij-style) |78 | **S** (in pane mode) | Stack Pane | Create stacked pane (Zellij-style) |
79 | **X** (in pane mode) | Close Pane | Close current pane |79 | **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 |
80 | **Enter** (in pane mode) | Confirm Action | Confirm pane operation |82 | **Enter** (in pane mode) | Confirm Action | Confirm pane operation |
81 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |83 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |
82 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |84 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
@@ -242,6 +244,9 @@ sudo apt install va-driver-all
242 - `dumber browse "!g golang"` # search via bang shortcut244 - `dumber browse "!g golang"` # search via bang shortcut
243 - Show version information:245 - Show version information:
244 - `dumber about` # version, commit, and build date246 - `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)
245 - Launcher integration (dmenu‑style examples with favicon support):250 - Launcher integration (dmenu‑style examples with favicon support):
246 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`251 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`
247 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`252 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
@@ -256,6 +261,10 @@ sudo apt install va-driver-all
256 - Clean up data:261 - Clean up data:
257 - `dumber purge` # interactive selection (TUI)262 - `dumber purge` # interactive selection (TUI)
258 - `dumber purge --force` # remove everything (no prompts)263 - `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
259 - Manage sessions:268 - Manage sessions:
260 - `dumber sessions` # interactive session browser (TUI)269 - `dumber sessions` # interactive session browser (TUI)
261 - `dumber sessions list` # list saved sessions270 - `dumber sessions list` # list saved sessions
@@ -270,6 +279,10 @@ sudo apt install va-driver-all
270 - `dumber logs -n 200 <session>` # show last N lines279 - `dumber logs -n 200 <session>` # show last N lines
271 - `dumber logs clear` # clean up old log files280 - `dumber logs clear` # clean up old log files
272 - `dumber logs clear --all` # remove all log files281 - `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
273 286
274 ### Dmenu mode invocation287 ### Dmenu mode invocation
275 You can invoke dmenu mode in two ways:288 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
294 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode307 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode
295 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout308 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout
296 309
297-Tab mode provides visual feedback with an orange border indicator.310+
298 311
299 ## Pane Management312 ## Pane Management
300 313
@@ -322,7 +335,7 @@ Dumber automatically saves your browser session (tabs, panes, split ratios, URLs
322 4. **Restore**: Select an exited session to restore it in a new browser window335 4. **Restore**: Select an exited session to restore it in a new browser window
323 5. **CLI Access**: Use `dumber sessions` for an interactive TUI, or `dumber sessions list/restore/delete` for scripting336 5. **CLI Access**: Use `dumber sessions` for an interactive TUI, or `dumber sessions list/restore/delete` for scripting
324 337
325-Session mode provides visual feedback with a purple border indicator.338+
326 339
327 ## Configuration340 ## Configuration
328 341
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 @@
1 <?xml version="1.0" encoding="UTF-8"?>1 <?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">
3 <defs>3 <defs>
4 <!-- Background gradient - deeper dark -->4 <!-- Background gradient - deeper dark -->
5 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">5 <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
14 //14 //
15 //go:embed logo.svg15 //go:embed logo.svg
16 var LogoSVG []byte16 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 (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "os/signal"8 "os/signal"
9- "path/filepath"
10 "runtime"9 "runtime"
11 "syscall"10 "syscall"
12 11
@@ -23,16 +22,14 @@ import (
23 "github.com/bnema/dumber/internal/infrastructure/deps"22 "github.com/bnema/dumber/internal/infrastructure/deps"
24 "github.com/bnema/dumber/internal/infrastructure/favicon"23 "github.com/bnema/dumber/internal/infrastructure/favicon"
25 "github.com/bnema/dumber/internal/infrastructure/idle"24 "github.com/bnema/dumber/internal/infrastructure/idle"
26- "github.com/bnema/dumber/internal/infrastructure/media"
27 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"25 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
28 "github.com/bnema/dumber/internal/infrastructure/updater"26 "github.com/bnema/dumber/internal/infrastructure/updater"
29 "github.com/bnema/dumber/internal/logging"27 "github.com/bnema/dumber/internal/logging"
30 "github.com/bnema/dumber/internal/ui"28 "github.com/bnema/dumber/internal/ui"
31 "github.com/bnema/dumber/internal/ui/theme"29 "github.com/bnema/dumber/internal/ui/theme"
32- "github.com/rs/zerolog"
33 )30 )
34 31
35-// Build-time variables (set via ldflags)32+// Build-time variables (set via ldflags).
36 var (33 var (
37 version = "dev"34 version = "dev"
38 commit = "unknown"35 commit = "unknown"
@@ -48,13 +45,10 @@ var restoreSessionID string
48 func main() {45 func main() {
49 // Run GUI mode for browse command46 // Run GUI mode for browse command
50 if len(os.Args) > 1 && os.Args[1] == "browse" {47 if len(os.Args) > 1 && os.Args[1] == "browse" {
51- // Extract URL if provided
52 if len(os.Args) > 2 {48 if len(os.Args) > 2 {
53 initialURL = os.Args[2]49 initialURL = os.Args[2]
54 }50 }
55- // Check for session restoration via environment variable
56 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")51 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
57- // Strip "browse" and URL from args so GTK doesn't see them
58 os.Args = os.Args[:1]52 os.Args = os.Args[:1]
59 os.Exit(runGUI())53 os.Exit(runGUI())
60 return54 return
@@ -73,75 +67,58 @@ func main() {
73 }67 }
74 68
75 func runGUI() int {69 func runGUI() int {
76- // GTK requires all GTK calls to be made from the main thread
77 runtime.LockOSThread()70 runtime.LockOSThread()
71+ timer := bootstrap.NewStartupTimer()
78 72
79 cfg := initConfig()73 cfg := initConfig()
74+ timer.Mark("config")
80 75
81- // Apply optional /opt-style runtime prefix overrides (if configured)76+ ctx := initStartupContext(cfg)
82- deps.ApplyPrefixEnv(cfg.Runtime.Prefix)77+ timer.Mark("logger")
83-78+ bootstrapLog := logging.FromContext(ctx)
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")
90 79
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)
92 90
93- dataDir, cacheDir := resolveWebKitDirs(bootstrapLogger)91+ needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore
94 92
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+ }
96 if dbCleanup != nil {97 if dbCleanup != nil {
97 defer dbCleanup()98 defer dbCleanup()
98 }99 }
100+ timer.Mark("db_webkit_parallel")
99 101
100- browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)102+ browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB)
101 if err != nil {103 if err != nil {
102- bootstrapLogger.Fatal().Err(err).Msg("failed to start session")104+ bootstrapLog.Fatal().Err(err).Msg("failed to start session")
103 }105 }
104 if browserSession.LogCleanup != nil {106 if browserSession.LogCleanup != nil {
105 defer browserSession.LogCleanup()107 defer browserSession.LogCleanup()
106 }108 }
107- defer func() {109+ defer func() { _ = browserSession.End(sessionCtx) }()
108- _ = browserSession.End(sessionCtx)110+ timer.Mark("session")
109- }()
110 111
111 ctx = sessionCtx112 ctx = sessionCtx
112 log := logging.FromContext(ctx)113 log := logging.FromContext(ctx)
113-114+ if stack.MessageRouter != nil {
114- checkRuntimeRequirements(ctx, cfg, *log)115+ stack.MessageRouter.SetBaseContext(ctx)
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(),
127 }116 }
128- useCases := createUseCases(repos, cfg, stateDir, buildInfo)
129 117
130- // Auto-restore: find last restorable session if enabled and no explicit restore requested118+ // Repositories and use cases
131- if cfg.Session.AutoRestore && restoreSessionID == "" {119+ useCases := createUseCases(repos, cfg)
132- var out *usecase.GetLastRestorableSessionOutput120+ if needsEagerDB {
133- out, err = useCases.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{121+ handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
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- }
145 }122 }
146 123
147 idleInhibitor := idle.NewPortalInhibitor(ctx)124 idleInhibitor := idle.NewPortalInhibitor(ctx)
@@ -150,29 +127,99 @@ func runGUI() int {
150 _ = idleInhibitor.Close()127 _ = idleInhibitor.Close()
151 }128 }
152 }()129 }()
153- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)130+ timer.Mark("use_cases")
154 131
132+ // Build UI
133+ uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
134+ configureDeferredInit(uiDeps, cfg, browserSession)
155 app, err := ui.New(uiDeps)135 app, err := ui.New(uiDeps)
156 if err != nil {136 if err != nil {
157 log.Error().Err(err).Msg("failed to create application")137 log.Error().Err(err).Msg("failed to create application")
158 return 1138 return 1
159 }139 }
140+ timer.Mark("ui_deps")
141+ timer.Log(ctx)
160 142
161- // Ensure sessions are closed on Ctrl+C / SIGTERM.143+ // Signal handling
162- // Without this, the process may exit immediately and skip defers,144+ setupSignalHandler(ctx, app)
163- // leaving the DB session stuck in "active" state.145+
164- sigCh := make(chan os.Signal, 1)146+ return app.Run(ctx, os.Args)
165- signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)147+}
166- defer signal.Stop(sigCh)148+
167- go func() {149+func initStartupContext(cfg *config.Config) context.Context {
168- <-sigCh150+ deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
169- log.Info().Msg("received interrupt, quitting")151+ bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat(
170- app.Quit()152+ cfg.Logging.Level,
171- }()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+ }
172 185
173- // Run the application186+ log := logging.FromContext(ctx)
174- exitCode := app.Run(ctx, os.Args)187+ stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log)
175- return exitCode188+
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+ }
176 }223 }
177 224
178 func initConfig() *config.Config {225 func initConfig() *config.Config {
@@ -183,77 +230,74 @@ func initConfig() *config.Config {
183 return config.Get()230 return config.Get()
184 }231 }
185 232
186-func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {233+func handleParallelInitError(ctx context.Context, err error) {
187- probe := deps.NewPkgConfigProbe()234+ log := logging.FromContext(ctx)
188- checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)235+ if runtimeErr, ok := err.(*bootstrap.RuntimeRequirementsError); ok {
189- runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{236+ runtimeErr.LogDetails(ctx)
190- Prefix: cfg.Runtime.Prefix,237+ log.Fatal().Err(runtimeErr).
191- })238+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
192- if err != nil {239+ Msg("runtime requirements not met")
193- logger.Fatal().Err(err).Msg("runtime requirements check failed")
194 }240 }
195- if runtimeOut.OK {241+ log.Fatal().Err(err).Msg("initialization failed")
196- return242+}
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")
197 }248 }
198- for _, c := range runtimeOut.Checks {249+ if result.RuntimeErr != nil {
199- if c.Installed {250+ if runtimeErr, ok := result.RuntimeErr.(*bootstrap.RuntimeRequirementsError); ok {
200- logger.Error().251+ runtimeErr.LogDetails(ctx)
201- Str("dependency", c.PkgConfigName).252+ log.Warn().Err(runtimeErr).
202- Str("have", c.Version).253+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
203- Str("need", c.RequiredVersion).254+ Msg("runtime requirements not met")
204- Bool("ok", c.MeetsRequirement).
205- Msg("runtime dependency")
206 } else {255 } else {
207- logger.Error().256+ log.Warn().Err(result.RuntimeErr).Msg("runtime requirements check failed")
208- Str("dependency", c.PkgConfigName).
209- Str("need", c.RequiredVersion).
210- Msg("runtime dependency missing")
211 }257 }
212 }258 }
213- logger.Fatal().259+ if result.MediaErr != nil {
214- Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").260+ log.Warn().Err(result.MediaErr).Msg("media check failed")
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")
225 }261 }
262+ log.Debug().Dur("duration", result.Duration).Msg("deferred init complete")
226 }263 }
227 264
228-func resolveWebKitDirs(logger zerolog.Logger) (string, string) {265+func handleAutoRestore(
229- const cacheDirPerm = 0o755266+ ctx context.Context,
230- dataDir, err := config.GetDataDir()267+ cfg *config.Config,
231- if err != nil {268+ uc *useCases,
232- logger.Fatal().Err(err).Msg("failed to resolve data directory")269+ currentSessionID entity.SessionID,
270+) {
271+ if !cfg.Session.AutoRestore || restoreSessionID != "" {
272+ return
233 }273 }
234- stateDir, err := config.GetStateDir()274+ log := logging.FromContext(ctx)
275+ out, err := uc.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{
276+ ExcludeSessionID: currentSessionID,
277+ })
235 if err != nil {278 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
237 }281 }
238- cacheDir := filepath.Join(stateDir, "webkit-cache")282+ if out.SessionID != "" {
239- if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {283+ restoreSessionID = string(out.SessionID)
240- logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory")284+ log.Info().
285+ Str("session_id", restoreSessionID).
286+ Int("tabs", len(out.State.Tabs)).
287+ Msg("auto-restore: found last session")
241 }288 }
242- return dataDir, cacheDir
243 }289 }
244 290
245-func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (*sql.DB, func()) {291+func setupSignalHandler(ctx context.Context, app *ui.App) {
246- dbPath := filepath.Join(dataDir, "dumber.db")292+ log := logging.FromContext(ctx)
247- db, err := sqlite.NewConnection(ctx, dbPath)293+ sigCh := make(chan os.Signal, 1)
248- if err != nil {294+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
249- logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database")295+ go func() {
250- }296+ sig := <-sigCh
251- cleanup := func() {297+ signal.Stop(sigCh)
252- if err := sqlite.Close(db); err != nil {298+ log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting")
253- logger.Error().Err(err).Msg("failed to close database")299+ app.Quit()
254- }300+ }()
255- }
256- return db, cleanup
257 }301 }
258 302
259 // repositories groups infrastructure layer repository implementations.303 // repositories groups infrastructure layer repository implementations.
@@ -279,6 +323,18 @@ func createRepositories(db *sql.DB) *repositories {
279 }323 }
280 }324 }
281 325
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+
282 // useCases groups application layer use case implementations.338 // useCases groups application layer use case implementations.
283 type useCases struct {339 type useCases struct {
284 tabs *usecase.ManageTabsUseCase340 tabs *usecase.ManageTabsUseCase
@@ -296,7 +352,7 @@ type useCases struct {
296 favicon *favicon.Service352 favicon *favicon.Service
297 }353 }
298 354
299-func createUseCases(repos *repositories, cfg *config.Config, stateDir string, buildInfo build.Info) *useCases {355+func createUseCases(repos *repositories, cfg *config.Config) *useCases {
300 const idAlphabetSize = 26356 const idAlphabetSize = 26
301 idCounter := uint64(0)357 idCounter := uint64(0)
302 idGenerator := func() string {358 idGenerator := func() string {
@@ -306,13 +362,17 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu
306 362
307 clipboardAdapter := clipboard.New()363 clipboardAdapter := clipboard.New()
308 faviconCacheDir, _ := config.GetFaviconCacheDir()364 faviconCacheDir, _ := config.GetFaviconCacheDir()
309- cacheDir, _ := config.GetXDGDirs()365+ xdgDirs, _ := config.GetXDGDirs()
366+ stateDir, _ := config.GetStateDir()
310 defaultZoom := cfg.DefaultWebpageZoom367 defaultZoom := cfg.DefaultWebpageZoom
311-
312- // Create LRU cache for zoom levels to avoid database queries on repeat visits
313 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)368 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
314 369
315- // Create update infrastructure370+ buildInfo := build.Info{
371+ Version: version,
372+ Commit: commit,
373+ BuildDate: buildDate,
374+ GoVersion: runtime.Version(),
375+ }
316 updateChecker := updater.NewGitHubChecker()376 updateChecker := updater.NewGitHubChecker()
317 updateDownloader := updater.NewGitHubDownloader()377 updateDownloader := updater.NewGitHubDownloader()
318 updateApplier := updater.NewApplier(stateDir)378 updateApplier := updater.NewApplier(stateDir)
@@ -328,7 +388,7 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu
328 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),388 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
329 lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),389 lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),
330 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),390 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
331- applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, cacheDir.CacheHome),391+ applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),
332 clipboard: clipboardAdapter,392 clipboard: clipboardAdapter,
333 favicon: favicon.NewService(faviconCacheDir),393 favicon: favicon.NewService(faviconCacheDir),
334 }394 }
M docs/CONFIG.md +51 -8
@@ -201,6 +201,7 @@ border = "#363636"
201 | Key | Type | Default | Description |201 | Key | Type | Default | Description |
202 |-----|------|---------|-------------|202 |-----|------|---------|-------------|
203 | `workspace.new_pane_url` | string | `"about:blank"` | URL loaded for new panes/tabs (supports `http(s)://`, `dumb://`, `file://`, `about:`) |203 | `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 |
204 205
205 **Example:**206 **Example:**
206 ```toml207 ```toml
@@ -225,6 +226,8 @@ split-up = ["arrowup", "u"]
225 split-down = ["arrowdown", "d"]226 split-down = ["arrowdown", "d"]
226 stack-pane = ["s"]227 stack-pane = ["s"]
227 close-pane = ["x"]228 close-pane = ["x"]
229+move-pane-to-tab = ["m"]
230+move-pane-to-next-tab = ["M", "shift+m"]
228 231
229 # Consume-or-expel (niri-style) - very alpha232 # Consume-or-expel (niri-style) - very alpha
230 consume-or-expel-left = ["bracketleft", "["]233 consume-or-expel-left = ["bracketleft", "["]
@@ -330,16 +333,28 @@ Notes:
330 |-----|------|---------|-------------|333 |-----|------|---------|-------------|
331 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |334 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |
332 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |335 | `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 |336+| `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes |
334-| `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) |337+| `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue) - used for border and toaster |
335-| `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay |338+| `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster |
336-| `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |339+| `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |
337-| `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay |340+| `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster |
338-| `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) |341+| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show toaster notification when modal modes are active |
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) |
341 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |342 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
342 343
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+
343 ## Session358 ## Session
344 359
345 | Key | Type | Default | Description |360 | Key | Type | Default | Description |
@@ -448,6 +463,34 @@ Notes:
448 - Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases.463 - Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases.
449 - Domain whitelist is managed via the database (`content_whitelist` table), not the config file.464 - Domain whitelist is managed via the database (`content_whitelist` table), not the config file.
450 465
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+
451 ## Environment Variables494 ## Environment Variables
452 495
453 All config values can be overridden via environment variables with the prefix `DUMBER_`:496 All config values can be overridden via environment variables with the prefix `DUMBER_`:
M go.mod +5 -3
@@ -8,14 +8,17 @@ require (
8 github.com/charmbracelet/bubbletea v1.3.108 github.com/charmbracelet/bubbletea v1.3.10
9 github.com/charmbracelet/lipgloss v1.1.09 github.com/charmbracelet/lipgloss v1.1.0
10 github.com/fsnotify/fsnotify v1.9.010 github.com/fsnotify/fsnotify v1.9.0
11+ github.com/godbus/dbus/v5 v5.2.1
11 github.com/jwijenbergh/puregotk v0.0.0-20251222174431-73930527faa312 github.com/jwijenbergh/puregotk v0.0.0-20251222174431-73930527faa3
12 github.com/ncruces/go-sqlite3 v0.30.313 github.com/ncruces/go-sqlite3 v0.30.3
14+ github.com/pelletier/go-toml/v2 v2.2.4
13 github.com/pressly/goose/v3 v3.26.015 github.com/pressly/goose/v3 v3.26.0
14 github.com/rs/zerolog v1.33.016 github.com/rs/zerolog v1.33.0
15 github.com/spf13/cobra v1.10.217 github.com/spf13/cobra v1.10.2
16 github.com/spf13/viper v1.21.018 github.com/spf13/viper v1.21.0
17 github.com/stretchr/testify v1.11.119 github.com/stretchr/testify v1.11.1
18 golang.org/x/image v0.34.020 golang.org/x/image v0.34.0
21+ golang.org/x/sys v0.38.0
19 )22 )
20 23
21 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa324 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3
@@ -27,10 +30,10 @@ require (
27 github.com/charmbracelet/x/ansi v0.10.1 // indirect30 github.com/charmbracelet/x/ansi v0.10.1 // indirect
28 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect31 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
29 github.com/charmbracelet/x/term v0.2.1 // indirect32 github.com/charmbracelet/x/term v0.2.1 // indirect
33+ github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
30 github.com/davecgh/go-spew v1.1.1 // indirect34 github.com/davecgh/go-spew v1.1.1 // indirect
31 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect35 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
32 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect36 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
33- github.com/godbus/dbus/v5 v5.2.1 // indirect
34 github.com/inconshreveable/mousetrap v1.1.0 // indirect37 github.com/inconshreveable/mousetrap v1.1.0 // indirect
35 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect38 github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect
36 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect39 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
@@ -43,9 +46,9 @@ require (
43 github.com/muesli/cancelreader v0.2.2 // indirect46 github.com/muesli/cancelreader v0.2.2 // indirect
44 github.com/muesli/termenv v0.16.0 // indirect47 github.com/muesli/termenv v0.16.0 // indirect
45 github.com/ncruces/julianday v1.0.0 // indirect48 github.com/ncruces/julianday v1.0.0 // indirect
46- github.com/pelletier/go-toml/v2 v2.2.4 // indirect
47 github.com/pmezard/go-difflib v1.0.0 // indirect49 github.com/pmezard/go-difflib v1.0.0 // indirect
48 github.com/rivo/uniseg v0.4.7 // indirect50 github.com/rivo/uniseg v0.4.7 // indirect
51+ github.com/russross/blackfriday/v2 v2.1.0 // indirect
49 github.com/sagikazarmark/locafero v0.11.0 // indirect52 github.com/sagikazarmark/locafero v0.11.0 // indirect
50 github.com/sahilm/fuzzy v0.1.1 // indirect53 github.com/sahilm/fuzzy v0.1.1 // indirect
51 github.com/sethvargo/go-retry v0.3.0 // indirect54 github.com/sethvargo/go-retry v0.3.0 // indirect
@@ -60,7 +63,6 @@ require (
60 go.uber.org/multierr v1.11.0 // indirect63 go.uber.org/multierr v1.11.0 // indirect
61 go.yaml.in/yaml/v3 v3.0.4 // indirect64 go.yaml.in/yaml/v3 v3.0.4 // indirect
62 golang.org/x/sync v0.19.0 // indirect65 golang.org/x/sync v0.19.0 // indirect
63- golang.org/x/sys v0.38.0 // indirect
64 golang.org/x/text v0.32.0 // indirect66 golang.org/x/text v0.32.0 // indirect
65 gopkg.in/yaml.v3 v3.0.1 // indirect67 gopkg.in/yaml.v3 v3.0.1 // indirect
66 )68 )
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
6 6
7 import (7 import (
8 "context"8 "context"
9+ "database/sql"
9 10
10 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
@@ -13,6 +14,630 @@ import (
13 mock "github.com/stretchr/testify/mock"14 mock "github.com/stretchr/testify/mock"
14 )15 )
15 16
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+
16 // 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.641 // 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.
17 // The first argument is typically a *testing.T value.642 // The first argument is typically a *testing.T value.
18 func NewMockSessionLogger(t interface {643 func NewMockSessionLogger(t interface {
M internal/application/port/xdg.go +4 -0
@@ -10,4 +10,8 @@ type XDGPaths interface {
10 FilterJSONDir() (string, error)10 FilterJSONDir() (string, error)
11 FilterStoreDir() (string, error)11 FilterStoreDir() (string, error)
12 FilterCacheDir() (string, error)12 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)
13 }17 }
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
196 return fav != nil, nil196 return fav != nil, nil
197 }197 }
198 198
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+
199 // GetTree builds a complete hierarchical view of folders and favorites.270 // GetTree builds a complete hierarchical view of folders and favorites.
200 func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {271 func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {
201 log := logging.FromContext(ctx)272 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
2 2
3 import (3 import (
4 "context"4 "context"
5- "database/sql"
6 "errors"5 "errors"
7 "fmt"6 "fmt"
8 "os"7 "os"
9 "path/filepath"8 "path/filepath"
9+ "sync"
10 "syscall"10 "syscall"
11 "time"11 "time"
12 12
13 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/application/usecase"14 "github.com/bnema/dumber/internal/application/usecase"
15 "github.com/bnema/dumber/internal/domain/entity"15 "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/domain/repository"
16 "github.com/bnema/dumber/internal/infrastructure/config"17 "github.com/bnema/dumber/internal/infrastructure/config"
17 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"18 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
18- "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
19 corelogging "github.com/bnema/dumber/internal/logging"19 corelogging "github.com/bnema/dumber/internal/logging"
20 "github.com/rs/zerolog"20 "github.com/rs/zerolog"
21 )21 )
@@ -31,11 +31,20 @@ type BrowserSession struct {
31 Logger zerolog.Logger31 Logger zerolog.Logger
32 LogCleanup func()32 LogCleanup func()
33 33
34- End func(context.Context) error34+ Persist func(context.Context) error
35+ End func(context.Context) error
35 }36 }
36 37
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) {
38 log := corelogging.FromContext(ctx)44 log := corelogging.FromContext(ctx)
45+ if sessionRepo == nil {
46+ return nil, ctx, errors.New("session repository is nil")
47+ }
39 48
40 lockDir := cfg.Logging.LogDir49 lockDir := cfg.Logging.LogDir
41 if lockDir == "" {50 if lockDir == "" {
@@ -44,72 +53,112 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*
44 }53 }
45 }54 }
46 55
47- sessionRepo := sqlite.NewSessionRepository(db)
48 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()56 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
49 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)57 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
58+ cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
50 59
51- // If a previous browser process died without cleanup, it may have left sessions "active".60+ now := time.Now()
52- // Use per-session lock files to distinguish truly running sessions from stale ones.61+ session := &entity.Session{
53- if lockDir != "" {62+ ID: entity.SessionID(corelogging.GenerateSessionID()),
54- if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil {63+ Type: entity.SessionTypeBrowser,
55- log.Warn().Err(err).Msg("failed to mark stale sessions ended")64+ StartedAt: now.UTC(),
56- }
57 }65 }
58-66+ if err := session.Validate(); err != nil {
59- // Clean up old exited sessions based on config limits67+ return nil, ctx, err
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")
71 }68 }
72 69
73- now := time.Now()70+ logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
74- out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{71+ Level: cfg.Logging.Level,
75- Type: entity.SessionTypeBrowser,72+ Format: cfg.Logging.Format,
76- Now: now,73+ TimeFormat: corelogging.ConsoleTimeFormat,
77- LogConfig: port.SessionLogConfig{74+ LogDir: cfg.Logging.LogDir,
78- Level: cfg.Logging.Level,75+ WriteToStderr: true,
79- Format: cfg.Logging.Format,76+ EnableFileLog: cfg.Logging.EnableFileLog,
80- TimeFormat: "15:04:05",
81- LogDir: cfg.Logging.LogDir,
82- WriteToStderr: true,
83- EnableFileLog: cfg.Logging.EnableFileLog,
84- },
85 })77 })
86 if err != nil {78 if err != nil {
87- return nil, ctx, err79+ log.Warn().Err(err).Msg("failed to create session logger")
88 }80 }
89 81
90- lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID)82+ sessionCtx := corelogging.WithContext(context.Background(), logger)
91- if lockErr != nil {83+
92- // Don’t fail startup if locking fails; it only affects stale-session cleanup.84+ var (
93- out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock")85+ lockMu sync.Mutex
94- }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+ }
95 103
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+ }
97 126
98 endFn := func(endCtx context.Context) error {127 endFn := func(endCtx context.Context) error {
99- endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now())128+ _ = persistFn(endCtx)
100- if lockFile != nil {129+ endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
101- _ = unlockAndClose(lockFile)130+
102- _ = os.Remove(lockPath)131+ lockMu.Lock()
132+ lf := lockFile
133+ lp := lockPath
134+ lockMu.Unlock()
135+
136+ if lf != nil {
137+ _ = unlockAndClose(lf)
138+ _ = os.Remove(lp)
103 }139 }
104 return endErr140 return endErr
105 }141 }
106 142
107- return &BrowserSession{143+ browserSession := &BrowserSession{
108- Session: out.Session,144+ Session: session,
109- Logger: out.Logger,145+ Logger: logger,
110- LogCleanup: out.LogCleanup,146+ LogCleanup: cleanup,
111 End: endFn,147 End: endFn,
112- }, sessionCtx, nil148+ }
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
113 }162 }
114 163
115 func endStaleActiveBrowserSessions(164 func endStaleActiveBrowserSessions(
@@ -213,3 +262,49 @@ func unlockAndClose(f *os.File) error {
213 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)262 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
214 return f.Close()263 return f.Close()
215 }264 }
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(
95 settings := webkit.NewSettingsManager(ctx, cfg)95 settings := webkit.NewSettingsManager(ctx, cfg)
96 injector := webkit.NewContentInjector(themeManager.PrefersDark())96 injector := webkit.NewContentInjector(themeManager.PrefersDark())
97 97
98+ // Set background color on injector for early CSS injection (prevents white flash)
99+ injector.SetBackgroundColor(themeManager.GetCurrentPalette().Background)
100+
98 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)101 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
99 themeCSSText := themeManager.GetWebUIThemeCSS()102 themeCSSText := themeManager.GetWebUIThemeCSS()
100 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {103 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {
@@ -108,11 +111,21 @@ func BuildWebKitStack(
108 poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount111 poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount
109 }112 }
110 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)113 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)
111 117
112 if filterManager != nil {118 if filterManager != nil {
113 pool.SetFilterApplier(filterManager)119 pool.SetFilterApplier(filterManager)
114 }120 }
115 121
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+
116 return WebKitStack{129 return WebKitStack{
117 Context: wkCtx,130 Context: wkCtx,
118 Settings: settings,131 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 (
11 tea "github.com/charmbracelet/bubbletea"11 tea "github.com/charmbracelet/bubbletea"
12 "github.com/spf13/cobra"12 "github.com/spf13/cobra"
13 13
14+ "github.com/bnema/dumber/assets"
14 "github.com/bnema/dumber/internal/cli/model"15 "github.com/bnema/dumber/internal/cli/model"
15 domainurl "github.com/bnema/dumber/internal/domain/url"16 domainurl "github.com/bnema/dumber/internal/domain/url"
16 "github.com/bnema/dumber/internal/infrastructure/config"17 "github.com/bnema/dumber/internal/infrastructure/config"
18+ "github.com/bnema/dumber/internal/infrastructure/favicon"
17 "github.com/bnema/dumber/internal/logging"19 "github.com/bnema/dumber/internal/logging"
18 )20 )
19 21
@@ -87,6 +89,13 @@ func getFaviconPath(rawURL string) string {
87 return ""89 return ""
88 }90 }
89 91
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+
90 domain := domainurl.ExtractDomain(rawURL)99 domain := domainurl.ExtractDomain(rawURL)
91 if domain == "" {100 if domain == "" {
92 return ""101 return ""
@@ -94,7 +103,6 @@ func getFaviconPath(rawURL string) string {
94 103
95 ctx := app.Ctx()104 ctx := app.Ctx()
96 log := logging.FromContext(ctx)105 log := logging.FromContext(ctx)
97- const normalizedSize = 32
98 106
99 // Check if sized PNG already exists107 // Check if sized PNG already exists
100 if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) {108 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 (
16 buildInfo build.Info16 buildInfo build.Info
17 rootCmd = &cobra.Command{17 rootCmd = &cobra.Command{
18 Use: "dumber",18 Use: "dumber",
19- Short: "A minimal web browser",19+ Short: "A fully unfeatured unbloated browser for tiling WMs",
20- Long: `Dumber is a minimal, keyboard-driven web browser built with GTK4 and WebKitGTK.`,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.`,
21 PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {37 PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
22- // Skip initialization for help commands38+ // Skip initialization for commands that don't need app context
23- if cmd.Name() == "help" || cmd.Name() == "completion" {39+ switch cmd.Name() {
40+ case "help", "completion", "gen-docs":
24 return nil41 return nil
25 }42 }
26 43
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 (
53 IconRestore = "\uf0e2" // rotate-left (restore)53 IconRestore = "\uf0e2" // rotate-left (restore)
54 IconExpand = "\uf065" // expand54 IconExpand = "\uf065" // expand
55 IconCollapse = "\uf066" // compress55 IconCollapse = "\uf066" // compress
56+
57+ // Update / download
58+ IconDownload = "\uf019" // download
59+ IconUpdate = "\uf021" // refresh/sync
60+ IconRocket = "\uf135" // rocket (upgrade)
56 )61 )
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 {
79 tl.Tabs[j].Position = j79 tl.Tabs[j].Position = j
80 }80 }
81 // Update active tab if needed81 // Update active tab if needed
82- if tl.ActiveTabID == id && len(tl.Tabs) > 0 {82+ if tl.ActiveTabID == id {
83- if i < len(tl.Tabs) {83+ if len(tl.Tabs) > 0 {
84- tl.ActiveTabID = tl.Tabs[i].ID84+ 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+ }
85 } else {89 } else {
86- tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID90+ tl.ActiveTabID = ""
91+ tl.PreviousActiveTabID = ""
87 }92 }
88 }93 }
89 return true94 return true
M internal/infrastructure/config/defaults.go +31 -33
@@ -46,21 +46,17 @@ const (
46 defaultBorderWidth = 146 defaultBorderWidth = 1
47 defaultBorderColor = "@theme_selected_bg_color"47 defaultBorderColor = "@theme_selected_bg_color"
48 48
49- // Pane mode border (Ctrl+P N - overlay)49+ // Mode border width (consolidated for all modes)
50- defaultPaneModeBorderWidth = 450+ defaultModeBorderWidth = 4
51- defaultPaneModeBorderColor = "#4A90E2" // Blue for pane mode indicator
52 51
53- // Tab mode border (Ctrl+P T - overlay)52+ // Mode colors (used for both borders and toaster)
54- defaultTabModeBorderWidth = 453+ defaultPaneModeColor = "#4A90E2" // Blue for pane mode
55- defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator54+ defaultTabModeColor = "#FFA500" // Orange for tab mode
55+ defaultSessionModeColor = "#9B59B6" // Purple for session mode
56+ defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode
56 57
57- // Session mode border (Ctrl+O - overlay)58+ // Mode indicator toaster
58- defaultSessionModeBorderWidth = 459+ defaultModeIndicatorToasterEnabled = true
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
64 60
65 // Other styling61 // Other styling
66 defaultTransitionDuration = 12062 defaultTransitionDuration = 120
@@ -155,17 +151,20 @@ func DefaultConfig() *Config {
155 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability151 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
156 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%152 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
157 Workspace: WorkspaceConfig{153 Workspace: WorkspaceConfig{
158- NewPaneURL: defaultNewPaneURL,154+ NewPaneURL: defaultNewPaneURL,
155+ SwitchToTabOnMove: true,
159 PaneMode: PaneModeConfig{156 PaneMode: PaneModeConfig{
160 ActivationShortcut: defaultPaneActivationShortcut,157 ActivationShortcut: defaultPaneActivationShortcut,
161 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,158 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
162 Actions: map[string][]string{159 Actions: map[string][]string{
163- "split-right": {"arrowright", "r"},160+ "split-right": {"arrowright", "r"},
164- "split-left": {"arrowleft", "l"},161+ "split-left": {"arrowleft", "l"},
165- "split-up": {"arrowup", "u"},162+ "split-up": {"arrowup", "u"},
166- "split-down": {"arrowdown", "d"},163+ "split-down": {"arrowdown", "d"},
167- "stack-pane": {"s"},164+ "stack-pane": {"s"},
168- "close-pane": {"x"},165+ "close-pane": {"x"},
166+ "move-pane-to-tab": {"m"},
167+ "move-pane-to-next-tab": {"M", "shift+m"},
169 168
170 "consume-or-expel-left": {"bracketleft", "["},169 "consume-or-expel-left": {"bracketleft", "["},
171 "consume-or-expel-right": {"bracketright", "]"},170 "consume-or-expel-right": {"bracketright", "]"},
@@ -235,17 +234,15 @@ func DefaultConfig() *Config {
235 OAuthAutoClose: true, // Auto-close OAuth popups on success234 OAuthAutoClose: true, // Auto-close OAuth popups on success
236 },235 },
237 Styling: WorkspaceStylingConfig{236 Styling: WorkspaceStylingConfig{
238- BorderWidth: defaultBorderWidth,237+ BorderWidth: defaultBorderWidth,
239- BorderColor: defaultBorderColor,238+ BorderColor: defaultBorderColor,
240- PaneModeBorderWidth: defaultPaneModeBorderWidth,239+ ModeBorderWidth: defaultModeBorderWidth,
241- PaneModeBorderColor: defaultPaneModeBorderColor,240+ PaneModeColor: defaultPaneModeColor,
242- TabModeBorderWidth: defaultTabModeBorderWidth,241+ TabModeColor: defaultTabModeColor,
243- TabModeBorderColor: defaultTabModeBorderColor,242+ SessionModeColor: defaultSessionModeColor,
244- SessionModeBorderWidth: defaultSessionModeBorderWidth,243+ ResizeModeColor: defaultResizeModeColor,
245- SessionModeBorderColor: defaultSessionModeBorderColor,244+ ModeIndicatorToasterEnabled: defaultModeIndicatorToasterEnabled,
246- ResizeModeBorderWidth: defaultResizeModeBorderWidth,245+ TransitionDuration: defaultTransitionDuration,
247- ResizeModeBorderColor: defaultResizeModeBorderColor,
248- TransitionDuration: defaultTransitionDuration,
249 },246 },
250 },247 },
251 ContentFiltering: ContentFilteringConfig{248 ContentFiltering: ContentFilteringConfig{
@@ -289,8 +286,9 @@ func DefaultConfig() *Config {
289 WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,286 WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
290 },287 },
291 Update: UpdateConfig{288 Update: UpdateConfig{
292- EnableOnStartup: true, // Check for updates on startup by default289+ EnableOnStartup: true, // Check for updates on startup by default
293- AutoDownload: false, // Conservative: don't auto-download by default290+ AutoDownload: false, // Conservative: don't auto-download by default
291+ NotifyOnNewSettings: true, // Show toast when new config settings available
294 },292 },
295 }293 }
296 }294 }
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) {
401 m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown)401 m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown)
402 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)402 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
403 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)403 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)
404 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))405 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
405 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)406 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
406 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)407 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
@@ -410,14 +411,12 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
410 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)411 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
411 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)412 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
412 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)413 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.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth)
414- m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)415+ m.viper.SetDefault("workspace.styling.pane_mode_color", defaults.Workspace.Styling.PaneModeColor)
415- m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)416+ m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor)
416- m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)417+ m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor)
417- m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth)418+ m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor)
418- m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor)419+ m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled)
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)
421 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)420 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
422 }421 }
423 422
@@ -459,6 +458,7 @@ func (m *Manager) setSessionDefaults(defaults *Config) {
459 func (m *Manager) setUpdateDefaults(defaults *Config) {458 func (m *Manager) setUpdateDefaults(defaults *Config) {
460 m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup)459 m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup)
461 m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload)460 m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload)
461+ m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings)
462 }462 }
463 463
464 // New returns a new default configuration instance.464 // 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 {
294 // When enabled, updates are applied on browser exit.294 // When enabled, updates are applied on browser exit.
295 // Default: false295 // Default: false
296 AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"`296 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"`
297 }300 }
298 301
299 // WorkspaceConfig captures layout, pane, and tab behavior preferences.302 // WorkspaceConfig captures layout, pane, and tab behavior preferences.
@@ -313,6 +316,9 @@ type WorkspaceConfig struct {
313 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`316 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
314 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.317 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
315 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 line318 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
316 // Popups configures default popup placement rules.322 // Popups configures default popup placement rules.
317 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`323 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
318 // Styling configures workspace visual appearance.324 // Styling configures workspace visual appearance.
@@ -438,29 +444,21 @@ type WorkspaceStylingConfig struct {
438 // BorderColor for focused panes (CSS color value or theme variable)444 // BorderColor for focused panes (CSS color value or theme variable)
439 BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`445 BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
440 446
441- // PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay)447+ // ModeBorderWidth in pixels for all mode indicator borders (pane, tab, session, resize)
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 line448+ 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
443- // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)449+
444- // Defaults to "#4A90E2" (blue) if not set450+ // PaneModeColor for pane mode indicator (border and toaster). Defaults to "#4A90E2" (blue)
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 line451+ 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
446-452+ // TabModeColor for tab mode indicator (border and toaster). Defaults to "#FFA500" (orange)
447- // TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay)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
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 line454+ // SessionModeColor for session mode indicator (border and toaster). Defaults to "#9B59B6" (purple)
449- // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable)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
450- // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor456+ // ResizeModeColor for resize mode indicator (border and toaster). Defaults to "#00D4AA" (teal)
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 line457+ 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
452-458+
453- // SessionModeBorderWidth in pixels for session mode indicator border (Ctrl+O overlay)459+ // ModeIndicatorToasterEnabled shows a toaster notification when modal modes are active.
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 line460+ // Default: true
455- // SessionModeBorderColor for the session mode indicator border (CSS color value or theme variable)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
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
464 462
465 // TransitionDuration in milliseconds for border animations463 // TransitionDuration in milliseconds for border animations
466 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line464 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 {
146 if config.Workspace.Styling.BorderWidth < 0 {146 if config.Workspace.Styling.BorderWidth < 0 {
147 validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")147 validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
148 }148 }
149- if config.Workspace.Styling.PaneModeBorderWidth < 0 {149+ if config.Workspace.Styling.ModeBorderWidth < 0 {
150- validationErrors = append(validationErrors, "workspace.styling.pane_mode_border_width must be non-negative")150+ validationErrors = append(validationErrors, "workspace.styling.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")
154 }151 }
155 if config.Workspace.Styling.TransitionDuration < 0 {152 if config.Workspace.Styling.TransitionDuration < 0 {
156 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")153 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) {
172 }172 }
173 }173 }
174 174
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+
175 // HasOnDisk checks if a favicon exists on disk for the given domain.202 // HasOnDisk checks if a favicon exists on disk for the given domain.
176 func (c *Cache) HasOnDisk(domain string) bool {203 func (c *Cache) HasOnDisk(domain string) bool {
177 path := c.DiskPath(domain)204 path := c.DiskPath(domain)
M internal/infrastructure/favicon/service.go +36 -0
@@ -3,10 +3,18 @@ package favicon
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "strings"
6 7
8+ "github.com/bnema/dumber/assets"
7 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
8 )10 )
9 11
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+
10 // Service implements the domain FaviconService interface.18 // Service implements the domain FaviconService interface.
11 // It coordinates between the cache and fetcher components.19 // It coordinates between the cache and fetcher components.
12 type Service struct {20 type Service struct {
@@ -30,6 +38,11 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
30 return nil, nil38 return nil, nil
31 }39 }
32 40
41+ // Internal domain returns the app logo
42+ if domain == InternalDomain {
43+ return assets.LogoSVG, nil
44+ }
45+
33 // Check cache first (memory + disk)46 // Check cache first (memory + disk)
34 if data, ok := s.cache.Get(domain); ok {47 if data, ok := s.cache.Get(domain); ok {
35 return data, nil48 return data, nil
@@ -49,6 +62,17 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
49 return data, nil62 return data, nil
50 }63 }
51 64
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+
52 // GetCached returns favicon bytes only if already cached (no external fetch).76 // GetCached returns favicon bytes only if already cached (no external fetch).
53 func (s *Service) GetCached(domain string) ([]byte, bool) {77 func (s *Service) GetCached(domain string) ([]byte, bool) {
54 return s.cache.Get(domain)78 return s.cache.Get(domain)
@@ -149,3 +173,15 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
149 173
150 return ResizePNG(srcPath, dstPath, size)174 return ResizePNG(srcPath, dstPath, size)
151 }175 }
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) {
33 return nil, fmt.Errorf("failed to open database: %w", err)33 return nil, fmt.Errorf("failed to open database: %w", err)
34 }34 }
35 35
36+ // Configure connection pool (must be done before any queries)
37+ configurePool(db)
38+
36 // Test the connection39 // Test the connection
37 if err := db.Ping(); err != nil {40 if err := db.Ping(); err != nil {
38 _ = db.Close()41 _ = db.Close()
@@ -59,12 +62,13 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) {
59 // applyPragmas configures SQLite for optimal performance.62 // applyPragmas configures SQLite for optimal performance.
60 func applyPragmas(db *sql.DB) error {63 func applyPragmas(db *sql.DB) error {
61 pragmas := []string{64 pragmas := []string{
62- "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access65+ "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access
63- "PRAGMA synchronous = NORMAL", // Safe in WAL mode66+ "PRAGMA synchronous = NORMAL", // Safe in WAL mode
64- "PRAGMA cache_size = -64000", // 64MB cache67+ "PRAGMA cache_size = -64000", // 64MB cache
65- "PRAGMA temp_store = MEMORY", // Temporary tables in RAM68+ "PRAGMA temp_store = MEMORY", // Temporary tables in RAM
66- "PRAGMA mmap_size = 30000000000", // 30GB memory-mapped I/O69+ "PRAGMA mmap_size = 268435456", // 256MB memory-mapped I/O (reasonable for browser history)
67- "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention70+ "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention
71+ "PRAGMA foreign_keys = ON", // Enable referential integrity
68 }72 }
69 73
70 for _, pragma := range pragmas {74 for _, pragma := range pragmas {
@@ -76,6 +80,19 @@ func applyPragmas(db *sql.DB) error {
76 return nil80 return nil
77 }81 }
78 82
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+
79 // Close closes the database connection gracefully.96 // Close closes the database connection gracefully.
80 func Close(db *sql.DB) error {97 func Close(db *sql.DB) error {
81 if db == nil {98 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
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "sync"
7 6
8 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
@@ -21,8 +20,7 @@ type WebViewFactory struct {
21 filterApplier FilterApplier // Optional content filter applier20 filterApplier FilterApplier // Optional content filter applier
22 21
23 // Background color for WebViews (eliminates white flash)22 // Background color for WebViews (eliminates white flash)
24- bgR, bgG, bgB, bgA float3223+ bg bgColor
25- bgMu sync.RWMutex
26 }24 }
27 25
28 // NewWebViewFactory creates a new WebViewFactory.26 // NewWebViewFactory creates a new WebViewFactory.
@@ -56,9 +54,7 @@ func (f *WebViewFactory) SetFilterApplier(applier FilterApplier) {
56 // SetBackgroundColor sets the background color for newly created WebViews.54 // SetBackgroundColor sets the background color for newly created WebViews.
57 // This color is shown before content is painted, eliminating white flash.55 // This color is shown before content is painted, eliminating white flash.
58 func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) {56 func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) {
59- f.bgMu.Lock()57+ f.bg.set(r, g, b, a)
60- f.bgR, f.bgG, f.bgB, f.bgA = r, g, b, a
61- f.bgMu.Unlock()
62 // Also propagate to the pool if present58 // Also propagate to the pool if present
63 if f.pool != nil {59 if f.pool != nil {
64 f.pool.SetBackgroundColor(r, g, b, a)60 f.pool.SetBackgroundColor(r, g, b, a)
@@ -106,10 +102,7 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
106 }102 }
107 103
108 // Set background color to match theme (eliminates white flash)104 // Set background color to match theme (eliminates white flash)
109- f.bgMu.RLock()105+ if r, g, b, a := f.bg.get(); a > 0 {
110- r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA
111- f.bgMu.RUnlock()
112- if a > 0 {
113 wv.SetBackgroundColor(r, g, b, a)106 wv.SetBackgroundColor(r, g, b, a)
114 }107 }
115 108
@@ -138,18 +131,13 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
138 func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {131 func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
139 log := logging.FromContext(ctx)132 log := logging.FromContext(ctx)
140 133
141- wv, err := NewWebView(ctx, f.wkCtx, f.settings)134+ wv, err := NewWebView(ctx, f.wkCtx, f.settings, f.bg.toGdkRGBA())
142 if err != nil {135 if err != nil {
143 return nil, err136 return nil, err
144 }137 }
145 138
146- // Set background color to match theme (eliminates white flash)139+ // Add CSS class for theme background styling (prevents white flash)
147- f.bgMu.RLock()140+ wv.inner.AddCssClass("webview-themed")
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- }
153 141
154 // Keep hidden until content is painted142 // Keep hidden until content is painted
155 wv.inner.SetVisible(false)143 wv.inner.SetVisible(false)
M internal/infrastructure/webkit/injector.go +56 -5
@@ -25,6 +25,34 @@ const themeCSSScript = `(function() {
25 (document.head || document.documentElement).appendChild(style);25 (document.head || document.documentElement).appendChild(style);
26 })();`26 })();`
27 27
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+
28 // darkModeScript patches window.matchMedia and sets theme class on <html>.56 // darkModeScript patches window.matchMedia and sets theme class on <html>.
29 // It must be injected at document start, after __dumber_gtk_prefers_dark is set.57 // It must be injected at document start, after __dumber_gtk_prefers_dark is set.
30 // This script:58 // This script:
@@ -132,6 +160,7 @@ type ContentInjector struct {
132 prefersDark bool160 prefersDark bool
133 themeCSSVars string // CSS custom property declarations for WebUI161 themeCSSVars string // CSS custom property declarations for WebUI
134 findCSS string // CSS for find-in-page highlight styling162 findCSS string // CSS for find-in-page highlight styling
163+ bgColor string // Background color hex for early injection (prevents white flash)
135 }164 }
136 165
137 // NewContentInjector creates a new injector instance.166 // NewContentInjector creates a new injector instance.
@@ -171,6 +200,13 @@ func (ci *ContentInjector) SetPrefersDark(prefersDark bool) {
171 ci.prefersDark = prefersDark200 ci.prefersDark = prefersDark
172 }201 }
173 202
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+
174 // InjectScripts adds the minimal dark mode detection scripts to the given content manager.210 // InjectScripts adds the minimal dark mode detection scripts to the given content manager.
175 // Only injects:211 // Only injects:
176 // - window.__dumber_gtk_prefers_dark flag212 // - window.__dumber_gtk_prefers_dark flag
@@ -193,7 +229,22 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
193 log.Debug().Str("script", label).Msg("injected user script")229 log.Debug().Str("script", label).Msg("injected user script")
194 }230 }
195 231
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)
197 darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)248 darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
198 addScript(249 addScript(
199 webkit.NewUserScript(250 webkit.NewUserScript(
@@ -206,7 +257,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
206 "gtk-dark-mode-pref",257 "gtk-dark-mode-pref",
207 )258 )
208 259
209- // 2. Inject WebView ID for debugging260+ // 3. Inject WebView ID for debugging
210 if webviewID != 0 {261 if webviewID != 0 {
211 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))262 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
212 addScript(263 addScript(
@@ -221,7 +272,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
221 )272 )
222 }273 }
223 274
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)
225 addScript(276 addScript(
226 webkit.NewUserScript(277 webkit.NewUserScript(
227 darkModeScript,278 darkModeScript,
@@ -233,7 +284,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
233 "dark-mode-handler",284 "dark-mode-handler",
234 )285 )
235 286
236- // 4. Inject theme CSS for internal pages (dumb://* only)287+ // 5. Inject theme CSS for internal pages (dumb://* only)
237 if ci.themeCSSVars != "" {288 if ci.themeCSSVars != "" {
238 // Escape for JS string literal289 // Escape for JS string literal
239 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")290 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
@@ -253,7 +304,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
253 log.Debug().Msg("theme CSS vars injection configured")304 log.Debug().Msg("theme CSS vars injection configured")
254 }305 }
255 306
256- // 5. Inject find highlight CSS for all pages307+ // 6. Inject find highlight CSS for all pages
257 if ci.findCSS != "" {308 if ci.findCSS != "" {
258 stylesheet := webkit.NewUserStyleSheet(309 stylesheet := webkit.NewUserStyleSheet(
259 ci.findCSS,310 ci.findCSS,
M internal/infrastructure/webkit/messaging.go +11 -0
@@ -62,6 +62,17 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
62 }62 }
63 }63 }
64 64
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+
65 // RegisterHandler registers a handler for a message type.76 // RegisterHandler registers a handler for a message type.
66 func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {77 func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
67 if msgType == "" {78 if msgType == "" {
M internal/infrastructure/webkit/pool.go +125 -14
@@ -2,14 +2,19 @@ package webkit
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "sync"6 "sync"
6 "sync/atomic"7 "sync/atomic"
7 "time"8 "time"
8 9
9 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/puregotk-webkit/webkit"11 "github.com/bnema/puregotk-webkit/webkit"
12+ "github.com/jwijenbergh/puregotk/v4/glib"
11 )13 )
12 14
15+// ErrPoolClosed is returned when operations are attempted on a closed pool.
16+var ErrPoolClosed = errors.New("webview pool is closed")
17+
13 // FilterApplier applies content filters to a UserContentManager.18 // FilterApplier applies content filters to a UserContentManager.
14 // This interface decouples the pool from the filtering package.19 // This interface decouples the pool from the filtering package.
15 type FilterApplier interface {20 type FilterApplier interface {
@@ -50,8 +55,7 @@ type WebViewPool struct {
50 filterApplier FilterApplier // Optional content filter applier55 filterApplier FilterApplier // Optional content filter applier
51 56
52 // Background color for WebViews (eliminates white flash)57 // Background color for WebViews (eliminates white flash)
53- bgR, bgG, bgB, bgA float3258+ bg bgColor
54- bgMu sync.RWMutex
55 59
56 closed atomic.Bool60 closed atomic.Bool
57 wg sync.WaitGroup61 wg sync.WaitGroup
@@ -103,9 +107,7 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
103 // SetBackgroundColor sets the background color for newly created WebViews.107 // SetBackgroundColor sets the background color for newly created WebViews.
104 // This color is shown before content is painted, eliminating white flash.108 // This color is shown before content is painted, eliminating white flash.
105 func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {109 func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
106- p.bgMu.Lock()110+ p.bg.set(r, g, b, a)
107- p.bgR, p.bgG, p.bgB, p.bgA = r, g, b, a
108- p.bgMu.Unlock()
109 }111 }
110 112
111 // Acquire gets a WebView from the pool or creates a new one.113 // 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) {
113 log := logging.FromContext(ctx)115 log := logging.FromContext(ctx)
114 116
115 if p.closed.Load() {117 if p.closed.Load() {
116- return nil, context.Canceled118+ return nil, ErrPoolClosed
117 }119 }
118 120
119 // Try to get from pool first (non-blocking)121 // Try to get from pool first (non-blocking)
120 select {122 select {
121 case wv := <-p.pool:123 case wv := <-p.pool:
122 if wv != nil && !wv.IsDestroyed() {124 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+
123 // Ensure frontend is attached even if this WebView was pooled before injection was configured.132 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
124 _ = wv.AttachFrontend(ctx, p.injector, p.router)133 _ = wv.AttachFrontend(ctx, p.injector, p.router)
125 // Apply filters if available (may not have been applied during prewarm)134 // Apply filters if available (may not have been applied during prewarm)
@@ -142,18 +151,13 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
142 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {151 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
143 log := logging.FromContext(ctx)152 log := logging.FromContext(ctx)
144 153
145- wv, err := NewWebView(ctx, p.wkCtx, p.settings)154+ wv, err := NewWebView(ctx, p.wkCtx, p.settings, p.bg.toGdkRGBA())
146 if err != nil {155 if err != nil {
147 return nil, err156 return nil, err
148 }157 }
149 158
150- // Set background color to match theme (eliminates white flash)159+ // Add CSS class for theme background styling (prevents white flash)
151- p.bgMu.RLock()160+ wv.inner.AddCssClass("webview-themed")
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- }
157 161
158 // Keep WebView hidden until content is painted (see content.go:onLoadCommitted)162 // Keep WebView hidden until content is painted (see content.go:onLoadCommitted)
159 wv.inner.SetVisible(false)163 wv.inner.SetVisible(false)
@@ -195,6 +199,34 @@ func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
195 wv.Destroy()199 wv.Destroy()
196 }200 }
197 201
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+
198 // Prewarm creates WebViews synchronously to populate the pool.230 // Prewarm creates WebViews synchronously to populate the pool.
199 // Must be called from the GTK main thread (after GTK application is initialized).231 // Must be called from the GTK main thread (after GTK application is initialized).
200 func (p *WebViewPool) Prewarm(ctx context.Context, count int) {232 func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
@@ -207,6 +239,85 @@ func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
207 p.prewarmSync(ctx, count)239 p.prewarmSync(ctx, count)
208 }240 }
209 241
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+
210 // prewarmSync creates WebViews synchronously.321 // prewarmSync creates WebViews synchronously.
211 func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {322 func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
212 log := logging.FromContext(ctx)323 log := logging.FromContext(ctx)
M internal/infrastructure/webkit/webview.go +18 -3
@@ -88,6 +88,7 @@ type WebView struct {
88 88
89 // State (protected by mutex)89 // State (protected by mutex)
90 destroyed atomic.Bool90 destroyed atomic.Bool
91+ isRelated bool // true if created via NewWebViewWithRelated (shares web process with parent)
91 uri string92 uri string
92 title string93 title string
93 progress float6494 progress float64
@@ -239,7 +240,8 @@ func (a *findControllerAdapter) DisconnectSignal(id uint32) {
239 240
240 // NewWebView creates a new WebView with the given context and settings.241 // NewWebView creates a new WebView with the given context and settings.
241 // Uses the persistent NetworkSession from wkCtx for cookie/data persistence.242 // 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) {
243 log := logging.FromContext(ctx)245 log := logging.FromContext(ctx)
244 246
245 if wkCtx == nil || !wkCtx.IsInitialized() {247 if wkCtx == nil || !wkCtx.IsInitialized() {
@@ -252,6 +254,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan
252 return nil, fmt.Errorf("failed to create webkit webview with network session")254 return nil, fmt.Errorf("failed to create webkit webview with network session")
253 }255 }
254 256
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+
255 wv := &WebView{263 wv := &WebView{
256 inner: inner,264 inner: inner,
257 ucm: inner.GetUserContentManager(),265 ucm: inner.GetUserContentManager(),
@@ -294,6 +302,7 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti
294 302
295 wv := &WebView{303 wv := &WebView{
296 inner: inner,304 inner: inner,
305+ isRelated: true, // Shares web process with parent - must not terminate process on destroy
297 ucm: inner.GetUserContentManager(),306 ucm: inner.GetUserContentManager(),
298 logger: log.With().Str("component", "webview-popup").Logger(),307 logger: log.With().Str("component", "webview-popup").Logger(),
299 signalIDs: make([]uint32, 0, 6),308 signalIDs: make([]uint32, 0, 6),
@@ -981,7 +990,9 @@ func (wv *WebView) Destroy() {
981 990
982 // 5. Terminate the web process to free GPU resources (VA-API, DMA-BUF, GL contexts)991 // 5. Terminate the web process to free GPU resources (VA-API, DMA-BUF, GL contexts)
983 // This is critical to prevent zombie processes that hold video decoder resources.992 // 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 {
985 wv.inner.TerminateWebProcess()996 wv.inner.TerminateWebProcess()
986 }997 }
987 998
@@ -993,7 +1004,11 @@ func (wv *WebView) Destroy() {
993 wv.ucm = nil1004 wv.ucm = nil
994 wv.findController = nil1005 wv.findController = nil
995 1006
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+ }
997 }1012 }
998 1013
999 // RunJavaScript executes script in the specified world (empty for main world).1014 // RunJavaScript executes script in the specified world (empty for main world).
M internal/infrastructure/xdg/adapter.go +17 -0
@@ -1,6 +1,7 @@
1 package xdg1 package xdg
2 2
3 import (3 import (
4+ "os"
4 "path/filepath"5 "path/filepath"
5 6
6 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
@@ -55,4 +56,20 @@ func (a *Adapter) FilterCacheDir() (string, error) {
55 return config.GetFilterCacheDir()56 return config.GetFilterCacheDir()
56 }57 }
57 58
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+
58 var _ port.XDGPaths = (*Adapter)(nil)75 var _ port.XDGPaths = (*Adapter)(nil)
M internal/logging/logger.go +12 -3
@@ -10,9 +10,10 @@ import (
10 )10 )
11 11
12 const (12 const (
13- logFormatConsole = "console"13+ logFormatConsole = "console"
14- logFormatJSON = "json"14+ logFormatJSON = "json"
15- logFormatText = "text"15+ logFormatText = "text"
16+ ConsoleTimeFormat = "15:04:05"
16 )17 )
17 18
18 // Config holds logging configuration19 // Config holds logging configuration
@@ -146,8 +147,16 @@ func NewFromEnv() zerolog.Logger {
146 // This is used by main.go to create a logger from the config package's LoggingConfig147 // This is used by main.go to create a logger from the config package's LoggingConfig
147 // without creating an import cycle.148 // without creating an import cycle.
148 func NewFromConfigValues(level, format string) zerolog.Logger {149 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 {
149 cfg := DefaultConfig()155 cfg := DefaultConfig()
150 cfg.Level = ParseLevel(level)156 cfg.Level = ParseLevel(level)
157+ if timeFormat != "" {
158+ cfg.TimeFormat = timeFormat
159+ }
151 160
152 switch format {161 switch format {
153 case logFormatJSON:162 case logFormatJSON:
M internal/ui/adapter/favicon.go +31 -0
@@ -46,19 +46,33 @@ func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
46 }46 }
47 47
48 // GetTextureByURL returns a cached texture by extracting domain from URL.48 // GetTextureByURL returns a cached texture by extracting domain from URL.
49+// For internal dumb:// URLs, returns the app logo texture.
49 func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {50 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+ }
50 domain := domainurl.ExtractDomain(pageURL)55 domain := domainurl.ExtractDomain(pageURL)
51 return a.GetTexture(domain)56 return a.GetTexture(domain)
52 }57 }
53 58
54 // GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.59 // GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
55 // The callback is invoked on the GTK main thread with the texture (or nil).60 // The callback is invoked on the GTK main thread with the texture (or nil).
61+// For internal dumb:// URLs, returns the app logo texture.
56 func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {62 func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
57 if callback == nil {63 if callback == nil {
58 return64 return
59 }65 }
60 66
61 log := logging.FromContext(ctx)67 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+
62 domain := domainurl.ExtractDomain(pageURL)76 domain := domainurl.ExtractDomain(pageURL)
63 77
64 // Check texture cache first78 // Check texture cache first
@@ -311,6 +325,23 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
311 }325 }
312 }326 }
313 327
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+
314 // bytesToTexture converts raw favicon bytes to a GDK texture.345 // bytesToTexture converts raw favicon bytes to a GDK texture.
315 func bytesToTexture(data []byte) *gdk.Texture {346 func bytesToTexture(data []byte) *gdk.Texture {
316 if len(data) == 0 {347 if len(data) == 0 {
M internal/ui/app.go +427 -12
@@ -87,17 +87,25 @@ type App struct {
87 87
88 // App-level toaster for system notifications (filter status, etc.)88 // App-level toaster for system notifications (filter status, etc.)
89 appToaster *component.Toaster89 appToaster *component.Toaster
90+ // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
91+ modeToaster *component.Toaster
90 92
91 // Session management93 // Session management
92 sessionManager *component.SessionManager94 sessionManager *component.SessionManager
95+ tabPicker *component.TabPicker
96+ tabPickerWidget layout.Widget
97+ tabPickerPaneID entity.PaneID
93 snapshotService *snapshot.Service98 snapshotService *snapshot.Service
94 99
95 // Update management100 // Update management
96 updateCoord *coordinator.UpdateCoordinator101 updateCoord *coordinator.UpdateCoordinator
97 102
98 // ID generator for tabs/panes103 // ID generator for tabs/panes
99- idCounter uint64104+ idCounter uint64
100- idMu sync.Mutex105+ idMu sync.Mutex
106+ firstWebViewShownOnce sync.Once
107+
108+ movePaneToTabUC *usecase.MovePaneToTabUseCase
101 109
102 // lifecycle110 // lifecycle
103 cancel context.CancelCauseFunc111 cancel context.CancelCauseFunc
@@ -179,7 +187,9 @@ func (a *App) onActivate(ctx context.Context) {
179 log.Debug().Msg("GTK application activated")187 log.Debug().Msg("GTK application activated")
180 188
181 a.applyGTKColorSchemePreference(ctx)189 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)
183 193
184 if err := a.createMainWindow(ctx); err != nil {194 if err := a.createMainWindow(ctx); err != nil {
185 log.Error().Err(err).Msg("failed to create main window")195 log.Error().Err(err).Msg("failed to create main window")
@@ -195,9 +205,12 @@ func (a *App) onActivate(ctx context.Context) {
195 a.initOmniboxConfig(ctx)205 a.initOmniboxConfig(ctx)
196 a.initFindBarConfig()206 a.initFindBarConfig()
197 a.initSessionManager(ctx)207 a.initSessionManager(ctx)
208+ a.initTabPicker(ctx)
198 a.initSnapshotService(ctx)209 a.initSnapshotService(ctx)
199 a.initUpdateCoordinator(ctx)210 a.initUpdateCoordinator(ctx)
200 a.createInitialTab(ctx)211 a.createInitialTab(ctx)
212+ // Prewarm the remaining WebViews after the initial tab starts loading.
213+ a.prewarmWebViewPoolAsync(ctx)
201 a.finalizeActivation(ctx)214 a.finalizeActivation(ctx)
202 }215 }
203 216
@@ -223,19 +236,31 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
223 Msg("set gtk-application-prefer-dark-theme")236 Msg("set gtk-application-prefer-dark-theme")
224 }237 }
225 238
226-func (a *App) prewarmWebViewPool(ctx context.Context) {239+func (a *App) setupPoolBackgroundColor(ctx context.Context) {
240+ log := logging.FromContext(ctx)
241+
227 // Set theme background color on pool to eliminate white flash.242 // Set theme background color on pool to eliminate white flash.
228- // Must be done before prewarming so WebViews get the correct color.243+ // Must be done before any WebView creation so WebViews get the correct color.
229- if a.pool != nil && a.deps != nil && a.deps.Theme != nil {244+ if a.pool == nil {
230- r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()245+ log.Debug().Msg("webview pool not available; skipping background color setup")
231- a.pool.SetBackgroundColor(r, g, b, alpha)246+ return
232 }247 }
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+}
233 257
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.
235 if a.pool == nil {260 if a.pool == nil {
236 return261 return
237 }262 }
238- a.pool.Prewarm(ctx, 0)263+ a.pool.PrewarmAsync(ctx, 0)
239 }264 }
240 265
241 func (a *App) createMainWindow(ctx context.Context) error {266 func (a *App) createMainWindow(ctx context.Context) error {
@@ -279,6 +304,18 @@ func (a *App) initAppToasterOverlay() {
279 return304 return
280 }305 }
281 a.mainWindow.AddOverlay(gtkWidget)306 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)
282 }319 }
283 320
284 func (a *App) initFocusAndBorderOverlay() {321 func (a *App) initFocusAndBorderOverlay() {
@@ -317,10 +354,13 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
317 a.handleModeChange(ctx, from, to)354 a.handleModeChange(ctx, from, to)
318 })355 })
319 a.keyboardHandler.SetShouldBypassInput(func() bool {356 a.keyboardHandler.SetShouldBypassInput(func() bool {
320- // Bypass keyboard handler when session manager is visible357+ // Bypass keyboard handler when modals are visible
321 if a.sessionManager != nil && a.sessionManager.IsVisible() {358 if a.sessionManager != nil && a.sessionManager.IsVisible() {
322 return true359 return true
323 }360 }
361+ if a.tabPicker != nil && a.tabPicker.IsVisible() {
362+ return true
363+ }
324 wsView := a.activeWorkspaceView()364 wsView := a.activeWorkspaceView()
325 if wsView == nil {365 if wsView == nil {
326 return false366 return false
@@ -349,13 +389,23 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
349 389
350 log := logging.FromContext(ctx)390 log := logging.FromContext(ctx)
351 391
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+
352 // Store omnibox config (omnibox is created per-pane via WorkspaceView).402 // Store omnibox config (omnibox is created per-pane via WorkspaceView).
353 a.omniboxCfg = component.OmniboxConfig{403 a.omniboxCfg = component.OmniboxConfig{
354 HistoryUC: a.deps.HistoryUC,404 HistoryUC: a.deps.HistoryUC,
355 FavoritesUC: a.deps.FavoritesUC,405 FavoritesUC: a.deps.FavoritesUC,
356 FaviconAdapter: a.faviconAdapter,406 FaviconAdapter: a.faviconAdapter,
357 CopyURLUC: a.deps.CopyURLUC,407 CopyURLUC: a.deps.CopyURLUC,
358- Shortcuts: a.deps.Config.SearchShortcuts,408+ ShortcutsUC: shortcutsUC,
359 DefaultSearch: a.deps.Config.DefaultSearchEngine,409 DefaultSearch: a.deps.Config.DefaultSearchEngine,
360 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,410 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
361 UIScale: a.deps.Config.DefaultUIScale,411 UIScale: a.deps.Config.DefaultUIScale,
@@ -464,6 +514,280 @@ func (a *App) ToggleSessionManager(ctx context.Context) {
464 a.sessionManager.Toggle(ctx)514 a.sessionManager.Toggle(ctx)
465 }515 }
466 516
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+
467 func (a *App) initSnapshotService(ctx context.Context) {791 func (a *App) initSnapshotService(ctx context.Context) {
468 log := logging.FromContext(ctx)792 log := logging.FromContext(ctx)
469 793
@@ -630,6 +954,9 @@ func (a *App) finalizeActivation(ctx context.Context) {
630 954
631 // Start async filter loading after window is visible.955 // Start async filter loading after window is visible.
632 a.initFilteringAsync(ctx)956 a.initFilteringAsync(ctx)
957+
958+ // Check for config migration after window is visible.
959+ a.checkConfigMigration(ctx)
633 }960 }
634 961
635 // onShutdown is called when the GTK application is shutting down.962 // onShutdown is called when the GTK application is shutting down.
@@ -732,6 +1059,20 @@ func (a *App) initCoordinators(ctx context.Context) {
732 })1059 })
733 }1060 }
734 1061
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+
735 // 3. Workspace Coordinator1076 // 3. Workspace Coordinator
736 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{1077 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
737 PanesUC: a.panesUC,1078 PanesUC: a.panesUC,
@@ -782,6 +1123,9 @@ func (a *App) initCoordinators(ctx context.Context) {
782 return nil1123 return nil
783 })1124 })
784 1125
1126+ // Move pane use case (cross-tab)
1127+ a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
1128+
785 // 4. Navigation Coordinator1129 // 4. Navigation Coordinator
786 a.navCoord = coordinator.NewNavigationCoordinator(1130 a.navCoord = coordinator.NewNavigationCoordinator(
787 ctx,1131 ctx,
@@ -811,6 +1155,25 @@ func (a *App) initCoordinators(ctx context.Context) {
811 a.MarkDirty()1155 a.MarkDirty()
812 })1156 })
813 1157
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+
814 // 5. Keyboard Dispatcher1177 // 5. Keyboard Dispatcher
815 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(1178 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
816 ctx,1179 ctx,
@@ -837,6 +1200,12 @@ func (a *App) initCoordinators(ctx context.Context) {
837 a.CloseFindBar(ctx)1200 a.CloseFindBar(ctx)
838 return nil1201 return nil
839 })1202 })
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+ })
840 1209
841 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)1210 // Wire gesture handler to dispatcher (for mouse button 8/9 navigation)
842 a.contentCoord.SetGestureActionHandler(func(ctx context.Context, action input.Action) error {1211 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) {
925 if a.borderMgr != nil {1294 if a.borderMgr != nil {
926 a.borderMgr.OnModeChange(ctx, from, to)1295 a.borderMgr.OnModeChange(ctx, from, to)
927 }1296 }
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+ }
928 }1343 }
929 1344
930 func (a *App) clearResizeModeBorder() {1345 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{
65 MaxResults: 50,65 MaxResults: 50,
66 }66 }
67 67
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+
68 // CalculateModalDimensions computes width and top margin based on parent overlay.83 // CalculateModalDimensions computes width and top margin based on parent overlay.
69 // Returns calculated width and top margin in pixels.84 // Returns calculated width and top margin in pixels.
70 func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {85 func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {
M internal/ui/component/omnibox.go +382 -71
@@ -10,7 +10,6 @@ import (
10 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/domain/url"12 "github.com/bnema/dumber/internal/domain/url"
13- "github.com/bnema/dumber/internal/infrastructure/config"
14 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
15 "github.com/bnema/dumber/internal/ui/adapter"14 "github.com/bnema/dumber/internal/ui/adapter"
16 "github.com/bnema/dumber/internal/ui/input"15 "github.com/bnema/dumber/internal/ui/input"
@@ -22,6 +21,7 @@ import (
22 21
23 const (22 const (
24 debounceDelayMs = 15023 debounceDelayMs = 150
24+ endBoxSpacing = 6
25 )25 )
26 26
27 // ViewMode distinguishes history search from favorites display.27 // ViewMode distinguishes history search from favorites display.
@@ -37,6 +37,14 @@ type Suggestion struct {
37 URL string37 URL string
38 Title string38 Title string
39 FaviconURL string39 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
40 }48 }
41 49
42 // Favorite represents a bookmarked URL.50 // Favorite represents a bookmarked URL.
@@ -57,6 +65,7 @@ type Omnibox struct {
57 historyBtn *gtk.Button65 historyBtn *gtk.Button
58 favoritesBtn *gtk.Button66 favoritesBtn *gtk.Button
59 zoomLabel *gtk.Label67 zoomLabel *gtk.Label
68+ bangBadge *gtk.Label
60 entry *gtk.SearchEntry69 entry *gtk.SearchEntry
61 scrolledWin *gtk.ScrolledWindow70 scrolledWin *gtk.ScrolledWindow
62 listBox *gtk.ListBox71 listBox *gtk.ListBox
@@ -65,20 +74,23 @@ type Omnibox struct {
65 parentOverlay layout.OverlayWidget74 parentOverlay layout.OverlayWidget
66 75
67 // State76 // State
68- mu sync.RWMutex77+ mu sync.RWMutex
69- visible bool78+ visible bool
70- viewMode ViewMode79+ viewMode ViewMode
71- selectedIndex int80+ selectedIndex int
72- suggestions []Suggestion81+ suggestions []Suggestion
73- favorites []Favorite82+ favorites []Favorite
74- hasNavigated bool // true if user navigated with arrow keys (enables space to toggle 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)
75 87
76 // Dependencies88 // Dependencies
77 historyUC *usecase.SearchHistoryUseCase89 historyUC *usecase.SearchHistoryUseCase
78 favoritesUC *usecase.ManageFavoritesUseCase90 favoritesUC *usecase.ManageFavoritesUseCase
79 faviconAdapter *adapter.FaviconAdapter91 faviconAdapter *adapter.FaviconAdapter
80 copyURLUC *usecase.CopyURLUseCase92 copyURLUC *usecase.CopyURLUseCase
81- shortcuts map[string]config.SearchShortcut93+ shortcutsUC *usecase.SearchShortcutsUseCase
82 defaultSearch string94 defaultSearch string
83 initialBehavior string95 initialBehavior string
84 ctx context.Context96 ctx context.Context
@@ -86,7 +98,7 @@ type Omnibox struct {
86 // Callbacks98 // Callbacks
87 onNavigate func(url string)99 onNavigate func(url string)
88 onClose func()100 onClose func()
89- onToast func(message string)101+ onToast func(ctx context.Context, message string, level ToastLevel)
90 102
91 // Debouncing103 // Debouncing
92 debounceTimer *time.Timer104 debounceTimer *time.Timer
@@ -114,12 +126,12 @@ type OmniboxConfig struct {
114 FavoritesUC *usecase.ManageFavoritesUseCase126 FavoritesUC *usecase.ManageFavoritesUseCase
115 FaviconAdapter *adapter.FaviconAdapter127 FaviconAdapter *adapter.FaviconAdapter
116 CopyURLUC *usecase.CopyURLUseCase128 CopyURLUC *usecase.CopyURLUseCase
117- Shortcuts map[string]config.SearchShortcut129+ ShortcutsUC *usecase.SearchShortcutsUseCase
118 DefaultSearch string130 DefaultSearch string
119 InitialBehavior string131 InitialBehavior string
120- UIScale float64 // UI scale for favicon sizing132+ UIScale float64 // UI scale for favicon sizing
121- OnNavigate func(url string) // Callback when user navigates via omnibox133+ OnNavigate func(url string) // Callback when user navigates via omnibox
122- OnToast func(message string) // Callback to show toast notification134+ OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification
123 }135 }
124 136
125 // NewOmnibox creates a new native GTK4 omnibox widget.137 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -139,7 +151,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
139 favoritesUC: cfg.FavoritesUC,151 favoritesUC: cfg.FavoritesUC,
140 faviconAdapter: cfg.FaviconAdapter,152 faviconAdapter: cfg.FaviconAdapter,
141 copyURLUC: cfg.CopyURLUC,153 copyURLUC: cfg.CopyURLUC,
142- shortcuts: cfg.Shortcuts,154+ shortcutsUC: cfg.ShortcutsUC,
143 defaultSearch: cfg.DefaultSearch,155 defaultSearch: cfg.DefaultSearch,
144 initialBehavior: cfg.InitialBehavior,156 initialBehavior: cfg.InitialBehavior,
145 onToast: cfg.OnToast,157 onToast: cfg.OnToast,
@@ -350,12 +362,16 @@ func (o *Omnibox) initHeader() error {
350 o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb)362 o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb)
351 o.favoritesBtn.ConnectClicked(&favoritesClickCb)363 o.favoritesBtn.ConnectClicked(&favoritesClickCb)
352 364
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+
353 o.zoomLabel = gtk.NewLabel(nil)371 o.zoomLabel = gtk.NewLabel(nil)
354 if o.zoomLabel != nil {372 if o.zoomLabel != nil {
355 o.zoomLabel.AddCssClass("omnibox-zoom-indicator")373 o.zoomLabel.AddCssClass("omnibox-zoom-indicator")
356 o.zoomLabel.SetVisible(false)374 o.zoomLabel.SetVisible(false)
357- o.zoomLabel.SetHalign(gtk.AlignEndValue)
358- o.zoomLabel.SetHexpand(true)
359 }375 }
360 return nil376 return nil
361 }377 }
@@ -368,7 +384,7 @@ func (o *Omnibox) initEntry() error {
368 o.entry.AddCssClass("omnibox-entry")384 o.entry.AddCssClass("omnibox-entry")
369 o.entry.SetHexpand(true)385 o.entry.SetHexpand(true)
370 386
371- placeholder := "Search history or enter URL..."387+ placeholder := "Search history or enter URL… (! lists bangs)"
372 o.entry.SetPlaceholderText(&placeholder)388 o.entry.SetPlaceholderText(&placeholder)
373 return nil389 return nil
374 }390 }
@@ -412,8 +428,18 @@ func (o *Omnibox) initList() error {
412 func (o *Omnibox) assembleWidgets() {428 func (o *Omnibox) assembleWidgets() {
413 o.headerBox.Append(&o.historyBtn.Widget)429 o.headerBox.Append(&o.historyBtn.Widget)
414 o.headerBox.Append(&o.favoritesBtn.Widget)430 o.headerBox.Append(&o.favoritesBtn.Widget)
415- if o.zoomLabel != nil {431+
416- o.headerBox.Append(&o.zoomLabel.Widget)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)
417 }443 }
418 444
419 o.scrolledWin.SetChild(&o.listBox.Widget)445 o.scrolledWin.SetChild(&o.listBox.Widget)
@@ -577,6 +603,14 @@ func (o *Omnibox) performSearch() {
577 o.lastQuery = query603 o.lastQuery = query
578 o.debounceMu.Unlock()604 o.debounceMu.Unlock()
579 605
606+ if strings.HasPrefix(query, "!") {
607+ o.updateBangDetection(query)
608+ o.loadBangSuggestions(query)
609+ return
610+ }
611+
612+ o.clearBangState()
613+
580 if mode == ViewModeFavorites {614 if mode == ViewModeFavorites {
581 o.loadFavorites(query)615 o.loadFavorites(query)
582 return616 return
@@ -596,21 +630,46 @@ func (o *Omnibox) performSearch() {
596 return630 return
597 }631 }
598 632
599- searchInput := usecase.SearchInput{633+ // Run history search and favorite URL fetch in parallel
600- Query: query,634+ type searchResult struct {
601- Limit: OmniboxListDefaults.MaxResults,635+ output *usecase.SearchOutput
636+ err error
602 }637 }
603- output, err := o.historyUC.Search(ctx, searchInput)638+ searchCh := make(chan searchResult, 1)
604- if err != nil {639+ favCh := make(chan map[string]struct{}, 1)
605- log.Error().Err(err).Msg("history search failed")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 {
606 return663 return
607 }664 }
608 665
609- suggestions := make([]Suggestion, 0, len(output.Matches))666+ suggestions := make([]Suggestion, 0, len(sr.output.Matches))
610- for _, r := range output.Matches {667+ for _, r := range sr.output.Matches {
668+ _, isFav := favoriteURLs[r.Entry.URL]
611 suggestions = append(suggestions, Suggestion{669 suggestions = append(suggestions, Suggestion{
612- URL: r.Entry.URL,670+ URL: r.Entry.URL,
613- Title: r.Entry.Title,671+ Title: r.Entry.Title,
672+ IsFavorite: isFav,
614 })673 })
615 }674 }
616 675
@@ -636,17 +695,43 @@ func (o *Omnibox) loadInitialHistory() {
636 return695 return
637 696
638 case "most_visited", "recent", "":697 case "most_visited", "recent", "":
639- // TODO: Implement GetMostVisited in use case if needed698+ // Run history fetch and favorite URL fetch in parallel
640- results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0)699+ type historyResult struct {
641- if err != nil {700+ results []*entity.HistoryEntry
642- log.Error().Err(err).Msg("failed to load recent history")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")
643 return722 return
644 }723 }
645- suggestions = make([]Suggestion, 0, len(results))724+ if hr.results == nil {
646- for _, r := range results {725+ return
726+ }
727+
728+ suggestions = make([]Suggestion, 0, len(hr.results))
729+ for _, r := range hr.results {
730+ _, isFav := favoriteURLs[r.URL]
647 suggestions = append(suggestions, Suggestion{731 suggestions = append(suggestions, Suggestion{
648- URL: r.URL,732+ URL: r.URL,
649- Title: r.Title,733+ Title: r.Title,
734+ IsFavorite: isFav,
650 })735 })
651 }736 }
652 }737 }
@@ -693,9 +778,108 @@ func (o *Omnibox) loadFavorites(query string) {
693 }()778 }()
694 }779 }
695 780
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+
696 // updateSuggestions updates the list with history suggestions.878 // updateSuggestions updates the list with history suggestions.
697 func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {879 func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
698 o.mu.Lock()880 o.mu.Lock()
881+ o.bangMode = false
882+ o.bangSuggestions = nil
699 o.suggestions = suggestions883 o.suggestions = suggestions
700 o.selectedIndex = -1884 o.selectedIndex = -1
701 o.mu.Unlock()885 o.mu.Unlock()
@@ -718,6 +902,8 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) {
718 // updateFavorites updates the list with favorites.902 // updateFavorites updates the list with favorites.
719 func (o *Omnibox) updateFavorites(favorites []Favorite) {903 func (o *Omnibox) updateFavorites(favorites []Favorite) {
720 o.mu.Lock()904 o.mu.Lock()
905+ o.bangMode = false
906+ o.bangSuggestions = nil
721 o.favorites = favorites907 o.favorites = favorites
722 o.selectedIndex = -1908 o.selectedIndex = -1
723 o.mu.Unlock()909 o.mu.Unlock()
@@ -746,9 +932,18 @@ func (o *Omnibox) rebuildList() {
746 mode := o.viewMode932 mode := o.viewMode
747 suggestions := o.suggestions933 suggestions := o.suggestions
748 favorites := o.favorites934 favorites := o.favorites
935+ bangMode := o.bangMode
936+ bangSuggestions := o.bangSuggestions
749 o.mu.RUnlock()937 o.mu.RUnlock()
750 938
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 {
752 for i, s := range suggestions {947 for i, s := range suggestions {
753 row := o.createSuggestionRow(s, i)948 row := o.createSuggestionRow(s, i)
754 if row != nil {949 if row != nil {
@@ -779,7 +974,9 @@ func (o *Omnibox) rebuildList() {
779 // Re-trigger resize with accurate measurements974 // Re-trigger resize with accurate measurements
780 o.mu.RLock()975 o.mu.RLock()
781 var count int976 var count int
782- if o.viewMode == ViewModeHistory {977+ if o.bangMode {
978+ count = len(o.bangSuggestions)
979+ } else if o.viewMode == ViewModeHistory {
783 count = len(o.suggestions)980 count = len(o.suggestions)
784 } else {981 } else {
785 count = len(o.favorites)982 count = len(o.favorites)
@@ -820,7 +1017,14 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
820 }1017 }
821 1018
822 // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.1019 // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
1020+// Uses rawURL for both favicon fetching and display.
823 func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {1021 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 {
824 row := gtk.NewListBoxRow()1028 row := gtk.NewListBoxRow()
825 if row == nil {1029 if row == nil {
826 return nil1030 return nil
@@ -835,7 +1039,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
835 hbox.SetHexpand(true)1039 hbox.SetHexpand(true)
836 1040
837 // Favicon image (vertically centered)1041 // Favicon image (vertically centered)
838- if favicon := o.createFaviconImage(rawURL, fallbackIcon); favicon != nil {1042+ if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil {
839 favicon.SetValign(gtk.AlignCenterValue)1043 favicon.SetValign(gtk.AlignCenterValue)
840 hbox.Append(&favicon.Widget)1044 hbox.Append(&favicon.Widget)
841 }1045 }
@@ -851,7 +1055,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
851 // Title label (or URL if no title)1055 // Title label (or URL if no title)
852 displayTitle := title1056 displayTitle := title
853 if displayTitle == "" {1057 if displayTitle == "" {
854- displayTitle = rawURL1058+ displayTitle = displayURL
855 }1059 }
856 titleLabel := gtk.NewLabel(nil)1060 titleLabel := gtk.NewLabel(nil)
857 if titleLabel != nil {1061 if titleLabel != nil {
@@ -863,10 +1067,10 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
863 }1067 }
864 1068
865 // URL label (only if title exists and differs from URL)1069 // URL label (only if title exists and differs from URL)
866- if title != "" && title != rawURL {1070+ if title != "" && title != displayURL {
867 urlLabel := gtk.NewLabel(nil)1071 urlLabel := gtk.NewLabel(nil)
868 if urlLabel != nil {1072 if urlLabel != nil {
869- urlLabel.SetText(rawURL)1073+ urlLabel.SetText(displayURL)
870 urlLabel.AddCssClass("omnibox-suggestion-url")1074 urlLabel.AddCssClass("omnibox-suggestion-url")
871 urlLabel.SetHalign(gtk.AlignStartValue)1075 urlLabel.SetHalign(gtk.AlignStartValue)
872 urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END1076 urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
@@ -898,7 +1102,11 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index
898 1102
899 // createSuggestionRow creates a ListBoxRow for a suggestion.1103 // createSuggestionRow creates a ListBoxRow for a suggestion.
900 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {1104 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
902 }1110 }
903 1111
904 // createFavoriteRow creates a ListBoxRow for a favorite.1112 // createFavoriteRow creates a ListBoxRow for a favorite.
@@ -906,6 +1114,16 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
906 return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)1114 return o.createRowWithFavicon(f.URL, f.Title, "starred-symbolic", index)
907 }1115 }
908 1116
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+
909 // selectIndex selects a row by index.1127 // selectIndex selects a row by index.
910 func (o *Omnibox) selectIndex(index int) {1128 func (o *Omnibox) selectIndex(index int) {
911 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb1129 // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb
@@ -922,8 +1140,11 @@ func (o *Omnibox) selectNext() {
922 o.mu.Lock()1140 o.mu.Lock()
923 current := o.selectedIndex1141 current := o.selectedIndex
924 mode := o.viewMode1142 mode := o.viewMode
1143+ bangMode := o.bangMode
925 var maxIndex int1144 var maxIndex int
926- if mode == ViewModeHistory {1145+ if bangMode {
1146+ maxIndex = len(o.bangSuggestions) - 1
1147+ } else if mode == ViewModeHistory {
927 maxIndex = len(o.suggestions) - 11148 maxIndex = len(o.suggestions) - 1
928 } else {1149 } else {
929 maxIndex = len(o.favorites) - 11150 maxIndex = len(o.favorites) - 1
@@ -947,8 +1168,11 @@ func (o *Omnibox) selectPrevious() {
947 o.mu.Lock()1168 o.mu.Lock()
948 current := o.selectedIndex1169 current := o.selectedIndex
949 mode := o.viewMode1170 mode := o.viewMode
1171+ bangMode := o.bangMode
950 var maxIndex int1172 var maxIndex int
951- if mode == ViewModeHistory {1173+ if bangMode {
1174+ maxIndex = len(o.bangSuggestions) - 1
1175+ } else if mode == ViewModeHistory {
952 maxIndex = len(o.suggestions) - 11176 maxIndex = len(o.suggestions) - 1
953 } else {1177 } else {
954 maxIndex = len(o.favorites) - 11178 maxIndex = len(o.favorites) - 1
@@ -981,9 +1205,37 @@ func (o *Omnibox) navigateToSelected() {
981 idx := o.selectedIndex1205 idx := o.selectedIndex
982 suggestions := o.suggestions1206 suggestions := o.suggestions
983 favorites := o.favorites1207 favorites := o.favorites
1208+ bangMode := o.bangMode
1209+ bangSuggestions := o.bangSuggestions
984 o.mu.RUnlock()1210 o.mu.RUnlock()
985 1211
986 entryText := o.entry.GetText()1212 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+
987 var targetURL string1239 var targetURL string
988 1240
989 // Check if user typed a URL-like string (contains . and no spaces)1241 // Check if user typed a URL-like string (contains . and no spaces)
@@ -1039,15 +1291,15 @@ func (o *Omnibox) toggleFavorite() {
1039 }1291 }
1040 1292
1041 if mode == ViewModeHistory {1293 if mode == ViewModeHistory {
1042- // Add to favorites1294+ // Toggle favorite in history mode
1043 if idx < 0 || idx >= len(suggestions) {1295 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")
1045 return1297 return
1046 }1298 }
1047 1299
1048 s := suggestions[idx]1300 s := suggestions[idx]
1049 if s.URL == "" {1301 if s.URL == "" {
1050- log.Debug().Msg("add favorite: empty URL")1302+ log.Debug().Msg("toggle favorite: empty URL")
1051 return1303 return
1052 }1304 }
1053 1305
@@ -1055,20 +1307,39 @@ func (o *Omnibox) toggleFavorite() {
1055 ctx := o.ctx1307 ctx := o.ctx
1056 goLog := logging.FromContext(ctx)1308 goLog := logging.FromContext(ctx)
1057 1309
1058- goLog.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites")1310+ result, err := o.favoritesUC.Toggle(ctx, s.URL, s.Title)
1059-
1060- addInput := usecase.AddFavoriteInput{
1061- URL: s.URL,
1062- Title: s.Title,
1063- }
1064-
1065- fav, err := o.favoritesUC.Add(ctx, addInput)
1066 if err != nil {1311 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+ }
1068 return1324 return
1069 }1325 }
1070 1326
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)
1072 }()1343 }()
1073 } else {1344 } else {
1074 // Remove from favorites1345 // Remove from favorites
@@ -1092,17 +1363,46 @@ func (o *Omnibox) toggleFavorite() {
1092 err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID))1363 err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID))
1093 if err != nil {1364 if err != nil {
1094 log.Error().Err(err).Int64("id", f.ID).Msg("failed to remove favorite")1365 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+ }
1095 return1374 return
1096 }1375 }
1097 1376
1098 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")1377 log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox")
1099 1378
1100- // Refresh favorites list1379+ // Refresh favorites list and show toast
1101 o.loadFavorites(o.entry.GetText())1380 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+ }
1102 }()1388 }()
1103 }1389 }
1104 }1390 }
1105 1391
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+
1106 // yankSelectedURL copies the URL of the selected item to clipboard.1406 // yankSelectedURL copies the URL of the selected item to clipboard.
1107 func (o *Omnibox) yankSelectedURL() {1407 func (o *Omnibox) yankSelectedURL() {
1108 log := logging.FromContext(o.ctx)1408 log := logging.FromContext(o.ctx)
@@ -1148,7 +1448,7 @@ func (o *Omnibox) yankSelectedURL() {
1148 // Show toast notification on success (must run on GTK main thread)1448 // Show toast notification on success (must run on GTK main thread)
1149 if o.onToast != nil {1449 if o.onToast != nil {
1150 cb := glib.SourceFunc(func(_ uintptr) bool {1450 cb := glib.SourceFunc(func(_ uintptr) bool {
1151- o.onToast("URL copied")1451+ o.onToast(ctx, "URL copied", ToastSuccess)
1152 return false // Don't repeat1452 return false // Don't repeat
1153 })1453 })
1154 glib.IdleAdd(&cb, 0)1454 glib.IdleAdd(&cb, 0)
@@ -1158,16 +1458,11 @@ func (o *Omnibox) yankSelectedURL() {
1158 1458
1159 // buildURL constructs a URL from text, handling search shortcuts.1459 // buildURL constructs a URL from text, handling search shortcuts.
1160 func (o *Omnibox) buildURL(text string) string {1460 func (o *Omnibox) buildURL(text string) string {
1161- return url.BuildSearchURL(text, o.shortcutURLs(), o.defaultSearch)1461+ var shortcutURLs map[string]string
1162-}1462+ if o.shortcutsUC != nil {
1163-1463+ shortcutURLs = o.shortcutsUC.ShortcutURLs()
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
1169 }1464 }
1170- return result1465+ return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch)
1171 }1466 }
1172 1467
1173 // toggleViewMode switches between history and favorites.1468 // toggleViewMode switches between history and favorites.
@@ -1274,6 +1569,7 @@ func (o *Omnibox) Hide(ctx context.Context) {
1274 o.outerBox.SetVisible(false)1569 o.outerBox.SetVisible(false)
1275 1570
1276 // Clear state1571 // Clear state
1572+ o.clearBangState()
1277 o.entry.SetText("")1573 o.entry.SetText("")
1278 o.listBox.RemoveAll()1574 o.listBox.RemoveAll()
1279 1575
@@ -1340,6 +1636,21 @@ func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) {
1340 glib.IdleAdd(&cb, 0)1636 glib.IdleAdd(&cb, 0)
1341 }1637 }
1342 1638
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+
1343 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.1654 // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread.
1344 func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {1655 func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {
1345 var cb glib.SourceFunc = func(data uintptr) bool {1656 var cb glib.SourceFunc = func(data uintptr) bool {
M internal/ui/component/pane_view.go +39 -2
@@ -25,6 +25,7 @@ type PaneView struct {
25 progressBar *ProgressBar // Loading progress indicator25 progressBar *ProgressBar // Loading progress indicator
26 toaster *Toaster // Toast notification overlay26 toaster *Toaster // Toast notification overlay
27 linkStatus *LinkStatusOverlay // Link hover URL overlay27 linkStatus *LinkStatusOverlay // Link hover URL overlay
28+ loading *LoadingSkeleton // Placeholder shown until WebView paints
28 paneID entity.PaneID29 paneID entity.PaneID
29 isActive bool30 isActive bool
30 31
@@ -51,6 +52,15 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg
51 overlay.SetChild(webViewWidget)52 overlay.SetChild(webViewWidget)
52 }53 }
53 54
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+
54 // Create border overlay box (used for active pane indication)64 // Create border overlay box (used for active pane indication)
55 // This is an empty box that gets styled via CSS to show a border65 // This is an empty box that gets styled via CSS to show a border
56 borderBox := factory.NewBox(layout.OrientationVertical, 0)66 borderBox := factory.NewBox(layout.OrientationVertical, 0)
@@ -75,6 +85,7 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg
75 webViewWidget: webViewWidget,85 webViewWidget: webViewWidget,
76 borderBox: borderBox,86 borderBox: borderBox,
77 progressBar: nil, // Created lazily in ensureProgressBar()87 progressBar: nil, // Created lazily in ensureProgressBar()
88+ loading: loading,
78 paneID: paneID,89 paneID: paneID,
79 isActive: false,90 isActive: false,
80 }91 }
@@ -133,13 +144,28 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
133 pv.webViewWidget = widget144 pv.webViewWidget = widget
134 145
135 if widget != nil {146 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+
136 // Unparent widget from any previous parent (critical for rebuild scenarios)156 // Unparent widget from any previous parent (critical for rebuild scenarios)
137 // In GTK4, a widget can only have one parent at a time157 // In GTK4, a widget can only have one parent at a time
138- if widget.GetParent() != nil {158+ if hadParent {
139 widget.Unparent()159 widget.Unparent()
140 }160 }
141- widget.SetVisible(true)
142 pv.overlay.SetChild(widget)161 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+ }
143 }169 }
144 }170 }
145 171
@@ -316,6 +342,17 @@ func (pv *PaneView) SetLoading(loading bool) {
316 }342 }
317 }343 }
318 344
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+
319 // ensureToaster creates the toaster lazily on first use.356 // ensureToaster creates the toaster lazily on first use.
320 // Must be called with write lock held.357 // Must be called with write lock held.
321 func (pv *PaneView) ensureToaster() *Toaster {358 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 (
4 "testing"4 "testing"
5 5
6 "github.com/stretchr/testify/assert"6 "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/mock"
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
8 9
9 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
@@ -19,6 +20,11 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
19 mockBorderBox := mocks.NewMockBoxWidget(t)20 mockBorderBox := mocks.NewMockBoxWidget(t)
20 mockWebView := mocks.NewMockWidget(t)21 mockWebView := mocks.NewMockWidget(t)
21 22
23+ mockLoadingContainer := mocks.NewMockBoxWidget(t)
24+ mockLoadingContent := mocks.NewMockBoxWidget(t)
25+ mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
26+ mockLoadingLogo := mocks.NewMockImageWidget(t)
27+
22 paneID := entity.PaneID("pane-1")28 paneID := entity.PaneID("pane-1")
23 29
24 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()30 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
@@ -28,6 +34,8 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) {
28 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background34 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
29 mockOverlay.EXPECT().SetChild(mockWebView).Once()35 mockOverlay.EXPECT().SetChild(mockWebView).Once()
30 36
37+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
38+
31 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()39 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
32 mockBorderBox.EXPECT().SetCanFocus(false).Once()40 mockBorderBox.EXPECT().SetCanFocus(false).Once()
33 mockBorderBox.EXPECT().SetCanTarget(false).Once()41 mockBorderBox.EXPECT().SetCanTarget(false).Once()
@@ -53,6 +61,11 @@ func TestNewPaneView_NilWebView(t *testing.T) {
53 mockOverlay := mocks.NewMockOverlayWidget(t)61 mockOverlay := mocks.NewMockOverlayWidget(t)
54 mockBorderBox := mocks.NewMockBoxWidget(t)62 mockBorderBox := mocks.NewMockBoxWidget(t)
55 63
64+ mockLoadingContainer := mocks.NewMockBoxWidget(t)
65+ mockLoadingContent := mocks.NewMockBoxWidget(t)
66+ mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
67+ mockLoadingLogo := mocks.NewMockImageWidget(t)
68+
56 paneID := entity.PaneID("pane-1")69 paneID := entity.PaneID("pane-1")
57 70
58 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()71 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
@@ -62,6 +75,8 @@ func TestNewPaneView_NilWebView(t *testing.T) {
62 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background75 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
63 // SetChild should NOT be called when webview is nil76 // SetChild should NOT be called when webview is nil
64 77
78+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
79+
65 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()80 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
66 mockBorderBox.EXPECT().SetCanFocus(false).Once()81 mockBorderBox.EXPECT().SetCanFocus(false).Once()
67 mockBorderBox.EXPECT().SetCanTarget(false).Once()82 mockBorderBox.EXPECT().SetCanTarget(false).Once()
@@ -87,7 +102,7 @@ func TestSetActive_True_AddsCSSClass(t *testing.T) {
87 mockBorderBox := mocks.NewMockBoxWidget(t)102 mockBorderBox := mocks.NewMockBoxWidget(t)
88 mockWebView := mocks.NewMockWidget(t)103 mockWebView := mocks.NewMockWidget(t)
89 104
90- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)105+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
91 106
92 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)107 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
93 108
@@ -108,7 +123,7 @@ func TestSetActive_False_RemovesCSSClass(t *testing.T) {
108 mockBorderBox := mocks.NewMockBoxWidget(t)123 mockBorderBox := mocks.NewMockBoxWidget(t)
109 mockWebView := mocks.NewMockWidget(t)124 mockWebView := mocks.NewMockWidget(t)
110 125
111- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)126+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
112 127
113 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)128 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
114 129
@@ -133,7 +148,7 @@ func TestSetActive_NoChangeWhenSameState(t *testing.T) {
133 mockBorderBox := mocks.NewMockBoxWidget(t)148 mockBorderBox := mocks.NewMockBoxWidget(t)
134 mockWebView := mocks.NewMockWidget(t)149 mockWebView := mocks.NewMockWidget(t)
135 150
136- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)151+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
137 152
138 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)153 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
139 154
@@ -152,7 +167,7 @@ func TestPaneID_ReturnsPaneID(t *testing.T) {
152 mockWebView := mocks.NewMockWidget(t)167 mockWebView := mocks.NewMockWidget(t)
153 168
154 paneID := entity.PaneID("test-pane-123")169 paneID := entity.PaneID("test-pane-123")
155- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)170+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
156 171
157 pv := component.NewPaneView(mockFactory, paneID, mockWebView)172 pv := component.NewPaneView(mockFactory, paneID, mockWebView)
158 173
@@ -170,7 +185,7 @@ func TestWebViewWidget_ReturnsWebView(t *testing.T) {
170 mockBorderBox := mocks.NewMockBoxWidget(t)185 mockBorderBox := mocks.NewMockBoxWidget(t)
171 mockWebView := mocks.NewMockWidget(t)186 mockWebView := mocks.NewMockWidget(t)
172 187
173- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)188+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
174 189
175 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)190 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
176 191
@@ -189,14 +204,13 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) {
189 mockOldWebView := mocks.NewMockWidget(t)204 mockOldWebView := mocks.NewMockWidget(t)
190 mockNewWebView := mocks.NewMockWidget(t)205 mockNewWebView := mocks.NewMockWidget(t)
191 206
192- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockOldWebView)207+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockOldWebView)
193 208
194 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockOldWebView)209 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockOldWebView)
195 210
196 // Expect removal of old widget and addition of new211 // Expect removal of old widget and addition of new
197 mockOverlay.EXPECT().SetChild(nil).Once()212 mockOverlay.EXPECT().SetChild(nil).Once()
198 mockNewWebView.EXPECT().GetParent().Return(nil).Once()213 mockNewWebView.EXPECT().GetParent().Return(nil).Once()
199- mockNewWebView.EXPECT().SetVisible(true).Once()
200 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()214 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
201 215
202 // Act216 // Act
@@ -213,13 +227,12 @@ func TestSetWebViewWidget_FromNil(t *testing.T) {
213 mockBorderBox := mocks.NewMockBoxWidget(t)227 mockBorderBox := mocks.NewMockBoxWidget(t)
214 mockNewWebView := mocks.NewMockWidget(t)228 mockNewWebView := mocks.NewMockWidget(t)
215 229
216- setupPaneViewMocksNoWebView(mockFactory, mockOverlay, mockBorderBox)230+ setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
217 231
218 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)232 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
219 233
220 // Expect only setting new child (no removal since old was nil)234 // Expect only setting new child (no removal since old was nil)
221 mockNewWebView.EXPECT().GetParent().Return(nil).Once()235 mockNewWebView.EXPECT().GetParent().Return(nil).Once()
222- mockNewWebView.EXPECT().SetVisible(true).Once()
223 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()236 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
224 237
225 // Act238 // Act
@@ -236,7 +249,7 @@ func TestGrabFocus_DelegatesToWebView(t *testing.T) {
236 mockBorderBox := mocks.NewMockBoxWidget(t)249 mockBorderBox := mocks.NewMockBoxWidget(t)
237 mockWebView := mocks.NewMockWidget(t)250 mockWebView := mocks.NewMockWidget(t)
238 251
239- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)252+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
240 253
241 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)254 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
242 255
@@ -255,7 +268,7 @@ func TestGrabFocus_NilWebView_ReturnsFalse(t *testing.T) {
255 mockOverlay := mocks.NewMockOverlayWidget(t)268 mockOverlay := mocks.NewMockOverlayWidget(t)
256 mockBorderBox := mocks.NewMockBoxWidget(t)269 mockBorderBox := mocks.NewMockBoxWidget(t)
257 270
258- setupPaneViewMocksNoWebView(mockFactory, mockOverlay, mockBorderBox)271+ setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
259 272
260 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)273 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
261 274
@@ -273,7 +286,7 @@ func TestHasFocus_DelegatesToWebView(t *testing.T) {
273 mockBorderBox := mocks.NewMockBoxWidget(t)286 mockBorderBox := mocks.NewMockBoxWidget(t)
274 mockWebView := mocks.NewMockWidget(t)287 mockWebView := mocks.NewMockWidget(t)
275 288
276- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)289+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
277 290
278 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)291 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
279 292
@@ -292,7 +305,7 @@ func TestHasFocus_NilWebView_ReturnsFalse(t *testing.T) {
292 mockOverlay := mocks.NewMockOverlayWidget(t)305 mockOverlay := mocks.NewMockOverlayWidget(t)
293 mockBorderBox := mocks.NewMockBoxWidget(t)306 mockBorderBox := mocks.NewMockBoxWidget(t)
294 307
295- setupPaneViewMocksNoWebView(mockFactory, mockOverlay, mockBorderBox)308+ setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox)
296 309
297 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)310 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil)
298 311
@@ -310,7 +323,7 @@ func TestWidget_ReturnsOverlay(t *testing.T) {
310 mockBorderBox := mocks.NewMockBoxWidget(t)323 mockBorderBox := mocks.NewMockBoxWidget(t)
311 mockWebView := mocks.NewMockWidget(t)324 mockWebView := mocks.NewMockWidget(t)
312 325
313- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)326+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
314 327
315 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)328 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
316 329
@@ -328,7 +341,7 @@ func TestOverlay_ReturnsOverlayWidget(t *testing.T) {
328 mockBorderBox := mocks.NewMockBoxWidget(t)341 mockBorderBox := mocks.NewMockBoxWidget(t)
329 mockWebView := mocks.NewMockWidget(t)342 mockWebView := mocks.NewMockWidget(t)
330 343
331- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)344+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
332 345
333 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)346 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
334 347
@@ -346,7 +359,7 @@ func TestShow_DelegatesToOverlay(t *testing.T) {
346 mockBorderBox := mocks.NewMockBoxWidget(t)359 mockBorderBox := mocks.NewMockBoxWidget(t)
347 mockWebView := mocks.NewMockWidget(t)360 mockWebView := mocks.NewMockWidget(t)
348 361
349- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)362+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
350 363
351 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)364 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
352 365
@@ -365,7 +378,7 @@ func TestHide_DelegatesToOverlay(t *testing.T) {
365 mockBorderBox := mocks.NewMockBoxWidget(t)378 mockBorderBox := mocks.NewMockBoxWidget(t)
366 mockWebView := mocks.NewMockWidget(t)379 mockWebView := mocks.NewMockWidget(t)
367 380
368- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)381+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
369 382
370 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)383 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
371 384
@@ -384,7 +397,7 @@ func TestSetVisible_DelegatesToOverlay(t *testing.T) {
384 mockBorderBox := mocks.NewMockBoxWidget(t)397 mockBorderBox := mocks.NewMockBoxWidget(t)
385 mockWebView := mocks.NewMockWidget(t)398 mockWebView := mocks.NewMockWidget(t)
386 399
387- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)400+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
388 401
389 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)402 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
390 403
@@ -403,7 +416,7 @@ func TestIsVisible_DelegatesToOverlay(t *testing.T) {
403 mockBorderBox := mocks.NewMockBoxWidget(t)416 mockBorderBox := mocks.NewMockBoxWidget(t)
404 mockWebView := mocks.NewMockWidget(t)417 mockWebView := mocks.NewMockWidget(t)
405 418
406- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)419+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
407 420
408 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)421 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
409 422
@@ -423,7 +436,7 @@ func TestAddCssClass_DelegatesToOverlay(t *testing.T) {
423 mockBorderBox := mocks.NewMockBoxWidget(t)436 mockBorderBox := mocks.NewMockBoxWidget(t)
424 mockWebView := mocks.NewMockWidget(t)437 mockWebView := mocks.NewMockWidget(t)
425 438
426- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)439+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
427 440
428 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)441 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
429 442
@@ -442,7 +455,7 @@ func TestRemoveCssClass_DelegatesToOverlay(t *testing.T) {
442 mockBorderBox := mocks.NewMockBoxWidget(t)455 mockBorderBox := mocks.NewMockBoxWidget(t)
443 mockWebView := mocks.NewMockWidget(t)456 mockWebView := mocks.NewMockWidget(t)
444 457
445- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)458+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
446 459
447 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)460 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
448 461
@@ -461,7 +474,7 @@ func TestSetOnFocusIn_SetsCallback(t *testing.T) {
461 mockBorderBox := mocks.NewMockBoxWidget(t)474 mockBorderBox := mocks.NewMockBoxWidget(t)
462 mockWebView := mocks.NewMockWidget(t)475 mockWebView := mocks.NewMockWidget(t)
463 476
464- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)477+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
465 478
466 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)479 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
467 480
@@ -481,7 +494,7 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) {
481 mockBorderBox := mocks.NewMockBoxWidget(t)494 mockBorderBox := mocks.NewMockBoxWidget(t)
482 mockWebView := mocks.NewMockWidget(t)495 mockWebView := mocks.NewMockWidget(t)
483 496
484- setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView)497+ setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView)
485 498
486 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)499 pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView)
487 500
@@ -494,13 +507,73 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) {
494 assert.NotNil(t, pv)507 assert.NotNil(t, pv)
495 }508 }
496 509
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+
497 // Helper function to setup common mock expectations for PaneView creation564 // Helper function to setup common mock expectations for PaneView creation
498 func setupPaneViewMocks(565 func setupPaneViewMocks(
566+ t *testing.T,
499 mockFactory *mocks.MockWidgetFactory,567 mockFactory *mocks.MockWidgetFactory,
500 mockOverlay *mocks.MockOverlayWidget,568 mockOverlay *mocks.MockOverlayWidget,
501 mockBorderBox *mocks.MockBoxWidget,569 mockBorderBox *mocks.MockBoxWidget,
502 mockWebView *mocks.MockWidget,570 mockWebView *mocks.MockWidget,
503 ) {571 ) {
572+ mockLoadingContainer := mocks.NewMockBoxWidget(t)
573+ mockLoadingContent := mocks.NewMockBoxWidget(t)
574+ mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
575+ mockLoadingLogo := mocks.NewMockImageWidget(t)
576+
504 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()577 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
505 mockOverlay.EXPECT().SetHexpand(true).Once()578 mockOverlay.EXPECT().SetHexpand(true).Once()
506 mockOverlay.EXPECT().SetVexpand(true).Once()579 mockOverlay.EXPECT().SetVexpand(true).Once()
@@ -508,6 +581,8 @@ func setupPaneViewMocks(
508 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background581 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
509 mockOverlay.EXPECT().SetChild(mockWebView).Once()582 mockOverlay.EXPECT().SetChild(mockWebView).Once()
510 583
584+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
585+
511 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()586 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
512 mockBorderBox.EXPECT().SetCanFocus(false).Once()587 mockBorderBox.EXPECT().SetCanFocus(false).Once()
513 mockBorderBox.EXPECT().SetCanTarget(false).Once()588 mockBorderBox.EXPECT().SetCanTarget(false).Once()
@@ -520,16 +595,24 @@ func setupPaneViewMocks(
520 }595 }
521 596
522 func setupPaneViewMocksNoWebView(597 func setupPaneViewMocksNoWebView(
598+ t *testing.T,
523 mockFactory *mocks.MockWidgetFactory,599 mockFactory *mocks.MockWidgetFactory,
524 mockOverlay *mocks.MockOverlayWidget,600 mockOverlay *mocks.MockOverlayWidget,
525 mockBorderBox *mocks.MockBoxWidget,601 mockBorderBox *mocks.MockBoxWidget,
526 ) {602 ) {
603+ mockLoadingContainer := mocks.NewMockBoxWidget(t)
604+ mockLoadingContent := mocks.NewMockBoxWidget(t)
605+ mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
606+ mockLoadingLogo := mocks.NewMockImageWidget(t)
607+
527 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()608 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
528 mockOverlay.EXPECT().SetHexpand(true).Once()609 mockOverlay.EXPECT().SetHexpand(true).Once()
529 mockOverlay.EXPECT().SetVexpand(true).Once()610 mockOverlay.EXPECT().SetVexpand(true).Once()
530 mockOverlay.EXPECT().SetVisible(true).Once()611 mockOverlay.EXPECT().SetVisible(true).Once()
531 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background612 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background
532 613
614+ setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
615+
533 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()616 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
534 mockBorderBox.EXPECT().SetCanFocus(false).Once()617 mockBorderBox.EXPECT().SetCanFocus(false).Once()
535 mockBorderBox.EXPECT().SetCanTarget(false).Once()618 mockBorderBox.EXPECT().SetCanTarget(false).Once()
M internal/ui/component/progress_bar.go +45 -1
@@ -15,11 +15,14 @@ const (
15 progressStep = 0.0215 progressStep = 0.02
16 // Animation interval in milliseconds16 // Animation interval in milliseconds
17 progressIntervalMs = 16 // ~60fps17 progressIntervalMs = 16 // ~60fps
18+ // Timeout to auto-hide progress bar if stuck (30 seconds)
19+ progressTimeoutMs = 30000
18 )20 )
19 21
20 // ProgressBar displays a slim loading progress indicator at the bottom of a pane.22 // ProgressBar displays a slim loading progress indicator at the bottom of a pane.
21 // Uses native GtkProgressBar with "osd" styling for overlay appearance.23 // Uses native GtkProgressBar with "osd" styling for overlay appearance.
22 // Implements smooth animation by incrementing towards the target value.24 // Implements smooth animation by incrementing towards the target value.
25+// Includes a 30-second timeout to auto-hide if the page load stalls.
23 type ProgressBar struct {26 type ProgressBar struct {
24 progressBar layout.ProgressBarWidget27 progressBar layout.ProgressBarWidget
25 28
@@ -27,6 +30,7 @@ type ProgressBar struct {
27 currentValue float64 // Current displayed value30 currentValue float64 // Current displayed value
28 targetValue float64 // Target value to animate towards31 targetValue float64 // Target value to animate towards
29 animationTimer uint // Timer source ID for animation32 animationTimer uint // Timer source ID for animation
33+ timeoutTimer uint // Timer source ID for auto-hide timeout
30 34
31 mu sync.Mutex35 mu sync.Mutex
32 }36 }
@@ -127,7 +131,7 @@ func (pb *ProgressBar) startAnimation() {
127 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)131 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)
128 }132 }
129 133
130-// Show makes the progress bar visible.134+// Show makes the progress bar visible and starts the auto-hide timeout.
131 func (pb *ProgressBar) Show() {135 func (pb *ProgressBar) Show() {
132 pb.mu.Lock()136 pb.mu.Lock()
133 defer pb.mu.Unlock()137 defer pb.mu.Unlock()
@@ -136,6 +140,34 @@ func (pb *ProgressBar) Show() {
136 pb.visible = true140 pb.visible = true
137 pb.progressBar.SetVisible(true)141 pb.progressBar.SetVisible(true)
138 }142 }
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)
139 }171 }
140 172
141 // Hide makes the progress bar invisible and resets state.173 // Hide makes the progress bar invisible and resets state.
@@ -143,6 +175,12 @@ func (pb *ProgressBar) Hide() {
143 pb.mu.Lock()175 pb.mu.Lock()
144 defer pb.mu.Unlock()176 defer pb.mu.Unlock()
145 177
178+ pb.hideInternal()
179+}
180+
181+// hideInternal performs the actual hide operation.
182+// Must be called with lock held.
183+func (pb *ProgressBar) hideInternal() {
146 if pb.visible {184 if pb.visible {
147 pb.visible = false185 pb.visible = false
148 pb.progressBar.SetVisible(false)186 pb.progressBar.SetVisible(false)
@@ -153,6 +191,12 @@ func (pb *ProgressBar) Hide() {
153 pb.animationTimer = 0191 pb.animationTimer = 0
154 }192 }
155 193
194+ // Stop timeout timer
195+ if pb.timeoutTimer != 0 {
196+ glib.SourceRemove(pb.timeoutTimer)
197+ pb.timeoutTimer = 0
198+ }
199+
156 // Reset values200 // Reset values
157 pb.currentValue = 0201 pb.currentValue = 0
158 pb.targetValue = 0202 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 {
48 }48 }
49 }49 }
50 50
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+
51 // Toaster displays toast notifications in an overlay.134 // Toaster displays toast notifications in an overlay.
52 // It supports different notification levels and auto-dismissal.135 // It supports different notification levels and auto-dismissal.
53 // When a new toast is shown while one is already visible, the text136 // When a new toast is shown while one is already visible, the text
@@ -60,18 +143,26 @@ type Toaster struct {
60 visible bool143 visible bool
61 dismissTimer uint // GLib timer source ID144 dismissTimer uint // GLib timer source ID
62 145
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+
63 mu sync.Mutex154 mu sync.Mutex
64 }155 }
65 156
66 // NewToaster creates a new toaster component for overlay display.157 // 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.
68 func NewToaster(factory layout.WidgetFactory) *Toaster {159 func NewToaster(factory layout.WidgetFactory) *Toaster {
69 // Create container box for styling and positioning160 // Create container box for styling and positioning
70 container := factory.NewBox(layout.OrientationHorizontal, 0)161 container := factory.NewBox(layout.OrientationHorizontal, 0)
71 container.AddCssClass("toast")162 container.AddCssClass("toast")
72 container.AddCssClass("toast-info") // Default level163 container.AddCssClass("toast-info") // Default level
73 164
74- // Position in top-left with margin165+ // Position in top-left with margin (default)
75 container.SetHalign(gtk.AlignStartValue)166 container.SetHalign(gtk.AlignStartValue)
76 container.SetValign(gtk.AlignStartValue)167 container.SetValign(gtk.AlignStartValue)
77 168
@@ -98,17 +189,28 @@ func NewToaster(factory layout.WidgetFactory) *Toaster {
98 label: label,189 label: label,
99 currentLevel: ToastInfo,190 currentLevel: ToastInfo,
100 visible: false,191 visible: false,
192+ currentOpts: defaultToastOptions(),
101 }193 }
102 }194 }
103 195
104 // Show displays a toast notification with the given message and level.196 // Show displays a toast notification with the given message and level.
105 // If a toast is already visible, updates the text and resets the dismiss timer.197 // 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) {
107 log := logging.FromContext(ctx)200 log := logging.FromContext(ctx)
108 201
202+ // Build options from defaults and overrides
203+ options := defaultToastOptions()
204+ for _, opt := range opts {
205+ opt(&options)
206+ }
207+
109 t.mu.Lock()208 t.mu.Lock()
110 defer t.mu.Unlock()209 defer t.mu.Unlock()
111 210
211+ // Clear previous custom styles if any
212+ t.clearCustomStyle()
213+
112 // Update level CSS class if changed214 // Update level CSS class if changed
113 if t.currentLevel != level {215 if t.currentLevel != level {
114 t.container.RemoveCssClass(t.currentLevel.cssClass())216 t.container.RemoveCssClass(t.currentLevel.cssClass())
@@ -116,6 +218,17 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
116 t.currentLevel = level218 t.currentLevel = level
117 }219 }
118 220
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+
119 // Update message text232 // Update message text
120 t.label.SetText(message)233 t.label.SetText(message)
121 234
@@ -131,12 +244,18 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
131 t.container.SetVisible(true)244 t.container.SetVisible(true)
132 }245 }
133 246
134- // Start new dismiss timer247+ // Start new dismiss timer (if duration > 0)
135- t.startDismissTimer(ctx)248+ if options.Duration > 0 {
249+ t.startDismissTimerWithDuration(ctx, options.Duration)
250+ }
251+ // Duration == 0 means persistent, no timer
252+
253+ t.currentOpts = options
136 254
137 log.Debug().255 log.Debug().
138 Str("toast_message", message).256 Str("toast_message", message).
139 Int("toast_level", int(level)).257 Int("toast_level", int(level)).
258+ Int("duration", options.Duration).
140 Msg("toast shown")259 Msg("toast shown")
141 }260 }
142 261
@@ -167,13 +286,125 @@ func (t *Toaster) hide() {
167 t.dismissTimer = 0286 t.dismissTimer = 0
168 }287 }
169 288
289+ // Clear custom styles
290+ t.clearCustomStyle()
291+
170 t.visible = false292 t.visible = false
171 t.container.SetVisible(false)293 t.container.SetVisible(false)
172 }294 }
173 295
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.
175 // Must be called with lock held.406 // Must be called with lock held.
176-func (t *Toaster) startDismissTimer(ctx context.Context) {407+func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) {
177 log := logging.FromContext(ctx)408 log := logging.FromContext(ctx)
178 409
179 cb := glib.SourceFunc(func(_ uintptr) bool {410 cb := glib.SourceFunc(func(_ uintptr) bool {
@@ -188,7 +419,7 @@ func (t *Toaster) startDismissTimer(ctx context.Context) {
188 return false // Don't repeat419 return false // Don't repeat
189 })420 })
190 421
191- t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0)422+ t.dismissTimer = glib.TimeoutAdd(uint(durationMs), &cb, 0)
192 }423 }
193 424
194 // Widget returns the underlying widget for embedding in overlays.425 // 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) {
556 556
557 // Create omnibox with pane-specific toast callback557 // Create omnibox with pane-specific toast callback
558 cfg := wv.omniboxCfg558 cfg := wv.omniboxCfg
559- cfg.OnToast = func(message string) {559+ cfg.OnToast = func(toastCtx context.Context, message string, level ToastLevel) {
560- pv.ShowToast(ctx, message, ToastSuccess)560+ pv.ShowToast(toastCtx, message, level)
561 }561 }
562 omnibox := NewOmnibox(ctx, cfg)562 omnibox := NewOmnibox(ctx, cfg)
563 if omnibox == nil {563 if omnibox == nil {
@@ -608,7 +608,13 @@ func (wv *WorkspaceView) hideOmniboxInternal() {
608 608
609 // Remove from pane overlay609 // Remove from pane overlay
610 if pv := wv.paneViews[wv.omniboxPaneID]; pv != nil && wv.omniboxWidget != nil {610 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+ }
612 }618 }
613 619
614 // Clear references620 // Clear references
@@ -715,7 +721,11 @@ func (wv *WorkspaceView) hideFindBarInternal() {
715 wv.findBar.Hide()721 wv.findBar.Hide()
716 722
717 if pv := wv.paneViews[wv.findBarPaneID]; pv != nil && wv.findBarWidget != nil {723 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+ }
719 }729 }
720 730
721 wv.findBar = nil731 wv.findBar = nil
M internal/ui/component/workspace_view_test.go +60 -0
@@ -14,6 +14,59 @@ import (
14 "github.com/bnema/dumber/internal/ui/layout/mocks"14 "github.com/bnema/dumber/internal/ui/layout/mocks"
15 )15 )
16 16
17+func 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+
17 func setupWorkspaceViewBase(t *testing.T, mockFactory *mocks.MockWidgetFactory) (context.Context, *mocks.MockBoxWidget) {70 func setupWorkspaceViewBase(t *testing.T, mockFactory *mocks.MockWidgetFactory) (context.Context, *mocks.MockBoxWidget) {
18 ctx := context.Background()71 ctx := context.Background()
19 mockContainer := mocks.NewMockBoxWidget(t)72 mockContainer := mocks.NewMockBoxWidget(t)
@@ -37,12 +90,19 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact
37 mockOverlay := mocks.NewMockOverlayWidget(t)90 mockOverlay := mocks.NewMockOverlayWidget(t)
38 mockBorderBox := mocks.NewMockBoxWidget(t)91 mockBorderBox := mocks.NewMockBoxWidget(t)
39 92
93+ mockLoadingContainer := mocks.NewMockBoxWidget(t)
94+ mockLoadingContent := mocks.NewMockBoxWidget(t)
95+ mockLoadingSpinner := mocks.NewMockSpinnerWidget(t)
96+ mockLoadingLogo := mocks.NewMockImageWidget(t)
97+
40 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()98 mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once()
41 mockOverlay.EXPECT().SetHexpand(true).Once()99 mockOverlay.EXPECT().SetHexpand(true).Once()
42 mockOverlay.EXPECT().SetVexpand(true).Once()100 mockOverlay.EXPECT().SetVexpand(true).Once()
43 mockOverlay.EXPECT().SetVisible(true).Once()101 mockOverlay.EXPECT().SetVisible(true).Once()
44 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()102 mockOverlay.EXPECT().AddCssClass("pane-overlay").Once()
45 103
104+ setupLoadingSkeletonMocksWorkspace(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo)
105+
46 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()106 mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once()
47 mockBorderBox.EXPECT().SetCanFocus(false).Once()107 mockBorderBox.EXPECT().SetCanFocus(false).Once()
48 mockBorderBox.EXPECT().SetCanTarget(false).Once()108 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 {
52 // Callback when active pane title changes (for window title updates)52 // Callback when active pane title changes (for window title updates)
53 onWindowTitleChanged func(title string)53 onWindowTitleChanged func(title string)
54 54
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+
55 // Gesture action handler for mouse button navigation61 // Gesture action handler for mouse button navigation
56 gestureActionHandler input.ActionHandler62 gestureActionHandler input.ActionHandler
57 63
@@ -72,6 +78,9 @@ type ContentCoordinator struct {
72 78
73 // Idle inhibitor for fullscreen video playback79 // Idle inhibitor for fullscreen video playback
74 idleInhibitor port.IdleInhibitor80 idleInhibitor port.IdleInhibitor
81+
82+ // Callback when fullscreen state changes (for hiding/showing tab bar)
83+ onFullscreenChanged func(entering bool)
75 }84 }
76 85
77 // NewContentCoordinator creates a new ContentCoordinator.86 // NewContentCoordinator creates a new ContentCoordinator.
@@ -94,6 +103,7 @@ func NewContentCoordinator(
94 webViews: make(map[entity.PaneID]*webkit.WebView),103 webViews: make(map[entity.PaneID]*webkit.WebView),
95 paneTitles: make(map[entity.PaneID]string),104 paneTitles: make(map[entity.PaneID]string),
96 navOrigins: make(map[entity.PaneID]string),105 navOrigins: make(map[entity.PaneID]string),
106+ pendingReveal: make(map[entity.PaneID]bool),
97 getActiveWS: getActiveWS,107 getActiveWS: getActiveWS,
98 pendingPopups: make(map[port.WebViewID]*PendingPopup),108 pendingPopups: make(map[port.WebViewID]*PendingPopup),
99 }109 }
@@ -119,6 +129,11 @@ func (c *ContentCoordinator) SetOnWindowTitleChanged(fn func(title string)) {
119 c.onWindowTitleChanged = fn129 c.onWindowTitleChanged = fn
120 }130 }
121 131
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+
122 // SetGestureActionHandler sets the callback for mouse button navigation gestures.137 // SetGestureActionHandler sets the callback for mouse button navigation gestures.
123 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {138 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
124 c.gestureActionHandler = handler139 c.gestureActionHandler = handler
@@ -129,6 +144,11 @@ func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
129 c.idleInhibitor = inhibitor144 c.idleInhibitor = inhibitor
130 }145 }
131 146
147+// SetOnFullscreenChanged sets the callback for fullscreen state changes.
148+func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) {
149+ c.onFullscreenChanged = fn
150+}
151+
132 // EnsureWebView acquires or reuses a WebView for the given pane.152 // EnsureWebView acquires or reuses a WebView for the given pane.
133 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {153 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
134 log := logging.FromContext(ctx)154 log := logging.FromContext(ctx)
@@ -674,18 +694,46 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en
674 func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {694 func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
675 log := logging.FromContext(ctx)695 log := logging.FromContext(ctx)
676 696
697+ url := wv.URI()
698+ if url == "" {
699+ return
700+ }
701+
677 // Show the WebView now that content is being painted702 // Show the WebView now that content is being painted
678 // (WebViews are hidden on creation to avoid white flash)703 // (WebViews are hidden on creation to avoid white flash)
679- if inner := wv.Widget(); inner != nil {704+ // Skip showing if this is about:blank but the pane is loading a different URL
680- inner.SetVisible(true)705+ // This prevents the brief flash of about:blank during initial navigation
681- log.Debug().Str("pane_id", string(paneID)).Msg("webview shown on load committed")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+ }
682 }722 }
683 723
684- url := wv.URI()724+ if !shouldShow {
685- if url == "" {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)
686 return729 return
687 }730 }
688 731
732+ c.markPendingReveal(paneID)
733+ if wv.EstimatedProgress() > 0 {
734+ c.revealIfPending(ctx, paneID, url, "progress-after-commit")
735+ }
736+
689 // Update domain model with current URI for session snapshots737 // Update domain model with current URI for session snapshots
690 c.updatePaneURI(paneID, url)738 c.updatePaneURI(paneID, url)
691 739
@@ -754,10 +802,16 @@ func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
754 if paneView != nil {802 if paneView != nil {
755 paneView.SetLoading(false)803 paneView.SetLoading(false)
756 }804 }
805+
806+ c.revealIfPending(context.Background(), paneID, "", "load-finished")
757 }807 }
758 808
759 // onProgressChanged updates the progress bar with current load progress.809 // onProgressChanged updates the progress bar with current load progress.
760 func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) {810 func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
811+ if progress > 0 {
812+ c.revealIfPending(context.Background(), paneID, "", "progress")
813+ }
814+
761 _, wsView := c.getActiveWS()815 _, wsView := c.getActiveWS()
762 if wsView == nil {816 if wsView == nil {
763 return817 return
@@ -769,6 +823,49 @@ func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress fl
769 }823 }
770 }824 }
771 825
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+
772 // onLinkHover updates the link status overlay when hovering over links.869 // onLinkHover updates the link status overlay when hovering over links.
773 func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) {870 func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) {
774 _, wsView := c.getActiveWS()871 _, wsView := c.getActiveWS()
@@ -1251,6 +1348,9 @@ func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, pa
1251 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")1348 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
1252 }1349 }
1253 }1350 }
1351+ if c.onFullscreenChanged != nil {
1352+ c.onFullscreenChanged(true)
1353+ }
1254 return false // Allow fullscreen1354 return false // Allow fullscreen
1255 }1355 }
1256 1356
@@ -1260,6 +1360,9 @@ func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, pa
1260 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")1360 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
1261 }1361 }
1262 }1362 }
1363+ if c.onFullscreenChanged != nil {
1364+ c.onFullscreenChanged(false)
1365+ }
1263 return false // Allow leaving fullscreen1366 return false // Allow leaving fullscreen
1264 }1367 }
1265 1368
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 (
19 // This struct is created once at startup and passed to UI components.19 // This struct is created once at startup and passed to UI components.
20 type Dependencies struct {20 type Dependencies struct {
21 // Core context and configuration21 // Core context and configuration
22- Ctx context.Context22+ Ctx context.Context
23- Config *config.Config23+ Config *config.Config
24- InitialURL string // URL to open on startup (optional)24+ InitialURL string // URL to open on startup (optional)
25- RestoreSessionID string // Session ID to restore on startup (optional)25+ RestoreSessionID string // Session ID to restore on startup (optional)
26+ OnFirstWebViewShown func(context.Context)
26 27
27 // Theme management28 // Theme management
28 Theme *theme.Manager29 Theme *theme.Manager
M internal/ui/dispatcher/keyboard.go +44 -12
@@ -17,18 +17,20 @@ import (
17 17
18 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.18 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
19 type KeyboardDispatcher struct {19 type KeyboardDispatcher struct {
20- tabCoord *coordinator.TabCoordinator20+ tabCoord *coordinator.TabCoordinator
21- wsCoord *coordinator.WorkspaceCoordinator21+ wsCoord *coordinator.WorkspaceCoordinator
22- navCoord *coordinator.NavigationCoordinator22+ navCoord *coordinator.NavigationCoordinator
23- zoomUC *usecase.ManageZoomUseCase23+ zoomUC *usecase.ManageZoomUseCase
24- copyURLUC *usecase.CopyURLUseCase24+ copyURLUC *usecase.CopyURLUseCase
25- actionHandlers map[input.Action]func(ctx context.Context) error25+ actionHandlers map[input.Action]func(ctx context.Context) error
26- onQuit func()26+ onQuit func()
27- onFindOpen func(ctx context.Context) error27+ onFindOpen func(ctx context.Context) error
28- onFindNext func(ctx context.Context) error28+ onFindNext func(ctx context.Context) error
29- onFindPrev func(ctx context.Context) error29+ onFindPrev func(ctx context.Context) error
30- onFindClose func(ctx context.Context) error30+ onFindClose func(ctx context.Context) error
31- onSessionOpen func(ctx context.Context) error31+ onSessionOpen func(ctx context.Context) error
32+ onMovePaneToTab func(ctx context.Context) error
33+ onMovePaneToNext func(ctx context.Context) error
32 }34 }
33 35
34 // NewKeyboardDispatcher creates a new KeyboardDispatcher.36 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -84,6 +86,14 @@ func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error
84 d.onSessionOpen = fn86 d.onSessionOpen = fn
85 }87 }
86 88
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+
87 func (d *KeyboardDispatcher) initActionHandlers() {97 func (d *KeyboardDispatcher) initActionHandlers() {
88 const (98 const (
89 firstTabIndex = 099 firstTabIndex = 0
@@ -128,6 +138,12 @@ func (d *KeyboardDispatcher) initActionHandlers() {
128 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },138 input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) },
129 input.ActionClosePane: d.wsCoord.ClosePane,139 input.ActionClosePane: d.wsCoord.ClosePane,
130 input.ActionStackPane: d.wsCoord.StackPane,140 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+ },
131 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {147 input.ActionConsumeOrExpelLeft: func(ctx context.Context) error {
132 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)148 return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft)
133 },149 },
@@ -239,6 +255,22 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
239 return nil255 return nil
240 }256 }
241 257
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+
242 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {274 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
243 if ctx == nil {275 if ctx == nil {
244 return fmt.Errorf("missing context")276 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
139 139
140 // Build KeyBinding from event140 // Build KeyBinding from event
141 modifiers := Modifier(state) & modifierMask141 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+
142 binding := KeyBinding{150 binding := KeyBinding{
143 Keyval: keyval,151 Keyval: keyval,
144 Modifiers: modifiers,152 Modifiers: modifiers,
M internal/ui/input/modal.go +16 -0
@@ -42,6 +42,22 @@ func (m Mode) String() string {
42 }42 }
43 }43 }
44 44
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+
45 // ModalState manages the current input mode with optional timeout.61 // ModalState manages the current input mode with optional timeout.
46 type ModalState struct {62 type ModalState struct {
47 mode Mode63 mode Mode
M internal/ui/input/shortcuts.go +32 -11
@@ -134,6 +134,9 @@ const (
134 ActionClosePane Action = "close_pane"134 ActionClosePane Action = "close_pane"
135 ActionStackPane Action = "stack_pane"135 ActionStackPane Action = "stack_pane"
136 136
137+ ActionMovePaneToTab Action = "move_pane_to_tab"
138+ ActionMovePaneToNextTab Action = "move_pane_to_next_tab"
139+
137 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"140 ActionConsumeOrExpelLeft Action = "consume_or_expel_left"
138 ActionConsumeOrExpelRight Action = "consume_or_expel_right"141 ActionConsumeOrExpelRight Action = "consume_or_expel_right"
139 ActionConsumeOrExpelUp Action = "consume_or_expel_up"142 ActionConsumeOrExpelUp Action = "consume_or_expel_up"
@@ -353,7 +356,7 @@ func (s *ShortcutSet) registerStandardShortcuts() {
353 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext356 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext
354 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev357 s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev
355 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload358 s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
356- s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload359+ s.Global[KeyBinding{uint('r'), ModCtrl | ModShift}] = ActionHardReload
357 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload360 s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
358 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload361 s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
359 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools362 s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
@@ -365,7 +368,7 @@ func (s *ShortcutSet) registerStandardShortcuts() {
365 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset368 s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
366 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit369 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
367 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen370 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
368- s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL371+ s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL
369 // Session management - direct shortcut to open session manager372 // Session management - direct shortcut to open session manager
370 s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager373 s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
371 }374 }
@@ -429,12 +432,14 @@ var configActionToAction = map[string]Action{
429 "rename-tab": ActionRenameTab,432 "rename-tab": ActionRenameTab,
430 433
431 // Pane actions434 // Pane actions
432- "split-right": ActionSplitRight,435+ "split-right": ActionSplitRight,
433- "split-left": ActionSplitLeft,436+ "split-left": ActionSplitLeft,
434- "split-up": ActionSplitUp,437+ "split-up": ActionSplitUp,
435- "split-down": ActionSplitDown,438+ "split-down": ActionSplitDown,
436- "close-pane": ActionClosePane,439+ "close-pane": ActionClosePane,
437- "stack-pane": ActionStackPane,440+ "stack-pane": ActionStackPane,
441+ "move-pane-to-tab": ActionMovePaneToTab,
442+ "move-pane-to-next-tab": ActionMovePaneToNextTab,
438 443
439 "consume-or-expel-left": ActionConsumeOrExpelLeft,444 "consume-or-expel-left": ActionConsumeOrExpelLeft,
440 "consume-or-expel-right": ActionConsumeOrExpelRight,445 "consume-or-expel-right": ActionConsumeOrExpelRight,
@@ -484,7 +489,10 @@ func ParseKeyString(s string) (KeyBinding, bool) {
484 return KeyBinding{}, false489 return KeyBinding{}, false
485 }490 }
486 491
487- s = strings.ToLower(strings.TrimSpace(s))492+ s = strings.TrimSpace(s)
493+ if s == "" {
494+ return KeyBinding{}, false
495+ }
488 if s == "+" {496 if s == "+" {
489 return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true497 return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
490 }498 }
@@ -499,7 +507,9 @@ func ParseKeyString(s string) (KeyBinding, bool) {
499 if part == "" {507 if part == "" {
500 continue508 continue
501 }509 }
502- switch part {510+
511+ lower := strings.ToLower(part)
512+ switch lower {
503 case "ctrl", "control":513 case "ctrl", "control":
504 modifiers |= ModCtrl514 modifiers |= ModCtrl
505 case "shift":515 case "shift":
@@ -523,6 +533,12 @@ func ParseKeyString(s string) (KeyBinding, bool) {
523 return KeyBinding{}, false533 return KeyBinding{}, false
524 }534 }
525 535
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+
526 keyval, ok := stringToKeyval(keyPart)542 keyval, ok := stringToKeyval(keyPart)
527 if !ok {543 if !ok {
528 return KeyBinding{}, false544 return KeyBinding{}, false
@@ -536,7 +552,11 @@ func ParseKeyString(s string) (KeyBinding, bool) {
536 552
537 // stringToKeyval converts a key name to its GDK keyval.553 // stringToKeyval converts a key name to its GDK keyval.
538 func stringToKeyval(s string) (uint, bool) {554 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 {
540 return keyval, true560 return keyval, true
541 }561 }
542 562
@@ -588,6 +608,7 @@ func ShouldAutoExitMode(action Action) bool {
588 case ActionNewTab, ActionCloseTab, ActionRenameTab,608 case ActionNewTab, ActionCloseTab, ActionRenameTab,
589 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,609 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
590 ActionClosePane, ActionStackPane,610 ActionClosePane, ActionStackPane,
611+ ActionMovePaneToTab, ActionMovePaneToNextTab,
591 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,612 ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
592 ActionOpenSessionManager:613 ActionOpenSessionManager:
593 return true614 return true
M internal/ui/input/shortcuts_test.go +16 -1
@@ -207,9 +207,11 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) {
207 input1 string207 input1 string
208 input2 string208 input2 string
209 }{209 }{
210- {"ctrl+t", "CTRL+T"},210+ // Modifiers and named keys are case-insensitive.
211+ {"ctrl+t", "CTRL+t"},
211 {"Escape", "escape"},212 {"Escape", "escape"},
212 {"Tab", "TAB"},213 {"Tab", "TAB"},
214+ // If shift is explicitly present, the key letter case shouldn't matter.
213 {"Ctrl+Shift+T", "ctrl+shift+t"},215 {"Ctrl+Shift+T", "ctrl+shift+t"},
214 }216 }
215 217
@@ -230,6 +232,17 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) {
230 }232 }
231 }233 }
232 234
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+
233 func TestShortcutSet_Lookup(t *testing.T) {246 func TestShortcutSet_Lookup(t *testing.T) {
234 set := &ShortcutSet{247 set := &ShortcutSet{
235 Global: ShortcutTable{248 Global: ShortcutTable{
@@ -336,6 +349,8 @@ func TestShouldAutoExitMode(t *testing.T) {
336 ActionSplitDown,349 ActionSplitDown,
337 ActionClosePane,350 ActionClosePane,
338 ActionStackPane,351 ActionStackPane,
352+ ActionMovePaneToTab,
353+ ActionMovePaneToNextTab,
339 }354 }
340 355
341 stayActions := []Action{356 stayActions := []Action{
M internal/ui/layout/gtk_widgets.go +66 -0
@@ -18,6 +18,7 @@ var (
18 _ ButtonWidget = (*gtkButton)(nil)18 _ ButtonWidget = (*gtkButton)(nil)
19 _ ImageWidget = (*gtkImage)(nil)19 _ ImageWidget = (*gtkImage)(nil)
20 _ ProgressBarWidget = (*gtkProgressBar)(nil)20 _ ProgressBarWidget = (*gtkProgressBar)(nil)
21+ _ SpinnerWidget = (*gtkSpinner)(nil)
21 _ WidgetFactory = (*GtkWidgetFactory)(nil)22 _ WidgetFactory = (*GtkWidgetFactory)(nil)
22 )23 )
23 24
@@ -687,6 +688,66 @@ func (p *gtkProgressBar) ComputePoint(target Widget) (x, y float64, ok bool) {
687 func (p *gtkProgressBar) SetFraction(fraction float64) { p.inner.SetFraction(fraction) }688 func (p *gtkProgressBar) SetFraction(fraction float64) { p.inner.SetFraction(fraction) }
688 func (p *gtkProgressBar) GetFraction() float64 { return p.inner.GetFraction() }689 func (p *gtkProgressBar) GetFraction() float64 { return p.inner.GetFraction() }
689 690
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+
690 // GtkWidgetFactory creates real GTK widgets.751 // GtkWidgetFactory creates real GTK widgets.
691 type GtkWidgetFactory struct{}752 type GtkWidgetFactory struct{}
692 753
@@ -736,6 +797,11 @@ func (f *GtkWidgetFactory) NewProgressBar() ProgressBarWidget {
736 return &gtkProgressBar{inner: progressBar}797 return &gtkProgressBar{inner: progressBar}
737 }798 }
738 799
800+func (f *GtkWidgetFactory) NewSpinner() SpinnerWidget {
801+ spinner := gtk.NewSpinner()
802+ return &gtkSpinner{inner: spinner}
803+}
804+
739 func (f *GtkWidgetFactory) WrapWidget(w *gtk.Widget) Widget {805 func (f *GtkWidgetFactory) WrapWidget(w *gtk.Widget) Widget {
740 if w == nil {806 if w == nil {
741 return nil807 return nil
M internal/ui/layout/mocks/mocks.go +1325 -0
@@ -11518,6 +11518,1285 @@ func (_c *MockProgressBarWidget_Unparent_Call) RunAndReturn(run func()) *MockPro
11518 return _c11518 return _c
11519 }11519 }
11520 11520
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+
11521 // 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.12800 // 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.
11522 // The first argument is typically a *testing.T value.12801 // The first argument is typically a *testing.T value.
11523 func NewMockWidgetFactory(t interface {12802 func NewMockWidgetFactory(t interface {
@@ -11894,6 +13173,52 @@ func (_c *MockWidgetFactory_NewProgressBar_Call) RunAndReturn(run func() layout.
11894 return _c13173 return _c
11895 }13174 }
11896 13175
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+
11897 // WrapWidget provides a mock function for the type MockWidgetFactory13222 // WrapWidget provides a mock function for the type MockWidgetFactory
11898 func (_mock *MockWidgetFactory) WrapWidget(w *gtk.Widget) layout.Widget {13223 func (_mock *MockWidgetFactory) WrapWidget(w *gtk.Widget) layout.Widget {
11899 ret := _mock.Called(w)13224 ret := _mock.Called(w)
M internal/ui/layout/widgets.go +11 -0
@@ -211,6 +211,16 @@ type ProgressBarWidget interface {
211 GetFraction() float64211 GetFraction() float64
212 }212 }
213 213
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+
214 // WidgetFactory creates widget instances.224 // WidgetFactory creates widget instances.
215 // This abstraction allows tests to inject mock factories.225 // This abstraction allows tests to inject mock factories.
216 type WidgetFactory interface {226 type WidgetFactory interface {
@@ -224,6 +234,7 @@ type WidgetFactory interface {
224 NewButton() ButtonWidget234 NewButton() ButtonWidget
225 NewImage() ImageWidget235 NewImage() ImageWidget
226 NewProgressBar() ProgressBarWidget236 NewProgressBar() ProgressBarWidget
237+ NewSpinner() SpinnerWidget
227 238
228 // Wrap existing GTK widget239 // Wrap existing GTK widget
229 WrapWidget(w *gtk.Widget) Widget240 WrapWidget(w *gtk.Widget) Widget
M internal/ui/theme/css.go +114 -13
@@ -50,7 +50,14 @@ window, tooltip, popover {
50 50
51 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.51 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
52 // Scale affects font sizes and widget padding/margins proportionally.52 // Scale affects font sizes and widget padding/margins proportionally.
53+// Uses default mode colors.
53 func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {54 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 {
54 if scale <= 0 {61 if scale <= 0 {
55 scale = 1.062 scale = 1.0
56 }63 }
@@ -68,6 +75,7 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
68 sb.WriteString(":root {\n")75 sb.WriteString(":root {\n")
69 sb.WriteString(p.ToCSSVars())76 sb.WriteString(p.ToCSSVars())
70 sb.WriteString(FontCSSVars(fonts))77 sb.WriteString(FontCSSVars(fonts))
78+ sb.WriteString(modeColors.ToCSSVars())
71 sb.WriteString("}\n\n")79 sb.WriteString("}\n\n")
72 80
73 // Global font styling81 // Global font styling
@@ -114,6 +122,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
114 122
115 // Session manager styling123 // Session manager styling
116 sb.WriteString(generateSessionManagerCSS(p))124 sb.WriteString(generateSessionManagerCSS(p))
125+ sb.WriteString("\n")
126+
127+ // Tab picker styling
128+ sb.WriteString(generateTabPickerCSS(p))
117 129
118 return sb.String()130 return sb.String()
119 }131 }
@@ -287,6 +299,22 @@ entry.omnibox-entry > *:focus-visible {
287 border-left: 0.1875em solid var(--accent);299 border-left: 0.1875em solid var(--accent);
288 }300 }
289 301
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+
290 /* Favicon in omnibox rows */318 /* Favicon in omnibox rows */
291 .omnibox-favicon {319 .omnibox-favicon {
292 min-width: 1em;320 min-width: 1em;
@@ -311,15 +339,15 @@ entry.omnibox-entry > *:focus-visible {
311 font-weight: 500;339 font-weight: 500;
312 }340 }
313 341
314-/* URL text below title */342+/* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */
315-.omnibox-suggestion-url {343+.omnibox-row .omnibox-suggestion-url {
316 font-size: 0.75em;344 font-size: 0.75em;
317- color: var(--muted);345+ color: alpha(var(--muted), 0.65);
318 font-weight: 400;346 font-weight: 400;
319 }347 }
320 348
321 .omnibox-row:selected .omnibox-suggestion-url {349 .omnibox-row:selected .omnibox-suggestion-url {
322- color: var(--muted);350+ color: alpha(var(--muted), 0.75);
323 }351 }
324 352
325 /* Keyboard shortcut badge */353 /* Keyboard shortcut badge */
@@ -344,6 +372,16 @@ entry.omnibox-entry > *:focus-visible {
344 color: var(--accent);372 color: var(--accent);
345 }373 }
346 374
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+
347 /* Zoom indicator in omnibox header */385 /* Zoom indicator in omnibox header */
348 .omnibox-zoom-indicator {386 .omnibox-zoom-indicator {
349 background-color: alpha(var(--accent), 0.2);387 background-color: alpha(var(--accent), 0.2);
@@ -354,6 +392,19 @@ entry.omnibox-entry > *:focus-visible {
354 font-weight: 600;392 font-weight: 600;
355 /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */393 /* Note: margin-left: auto not supported in GTK4 CSS, use SetHalign(End) in code */
356 }394 }
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+}
357 `408 `
358 }409 }
359 410
@@ -447,6 +498,29 @@ func generatePaneCSS(p Palette) string {
447 background-color: var(--bg);498 background-color: var(--bg);
448 }499 }
449 500
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+
450 /* Pane border - default transparent */524 /* Pane border - default transparent */
451 .pane-border {525 .pane-border {
452 border: 0.0625em solid transparent;526 border: 0.0625em solid transparent;
@@ -462,26 +536,26 @@ func generatePaneCSS(p Palette) string {
462 border-color: transparent;536 border-color: transparent;
463 }537 }
464 538
465-/* Pane mode active - thick blue inset border (for overlay) */539+/* Pane mode active - thick inset border (for overlay) */
466 .pane-mode-active {540 .pane-mode-active {
467 background-color: transparent;541 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);
469 border-radius: 0;543 border-radius: 0;
470 }544 }
471 545
472-/* Tab mode active - thick orange inset border (for overlay) */546+/* Tab mode active - thick inset border (for overlay) */
473 .tab-mode-active {547 .tab-mode-active {
474 background-color: transparent;548 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);
476 border-radius: 0;550 border-radius: 0;
477 }551 }
478 552
479-/* Resize mode active - thick teal inset border */553+/* Resize mode active - thick inset border */
480 .resize-mode-active {554 .resize-mode-active {
481 background-color: transparent;555 background-color: transparent;
482 /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */556 /* Prefer inset shadow, but also set outline for widgets that don't paint shadows */
483- box-shadow: inset 0 0 0 0.25em #00D4AA;557+ box-shadow: inset 0 0 0 0.25em var(--resize-mode-color);
484- outline: 0.25em solid #00D4AA;558+ outline: 0.25em solid var(--resize-mode-color);
485 outline-offset: -0.25em;559 outline-offset: -0.25em;
486 border-radius: 0;560 border-radius: 0;
487 }561 }
@@ -578,6 +652,7 @@ progressbar.osd progress {
578 // generateSessionManagerCSS creates session manager modal styles.652 // generateSessionManagerCSS creates session manager modal styles.
579 // Uses em units for scalable UI, matches omnibox styling patterns.653 // Uses em units for scalable UI, matches omnibox styling patterns.
580 func generateSessionManagerCSS(p Palette) string {654 func generateSessionManagerCSS(p Palette) string {
655+ _ = p
581 return `/* ===== Session Manager Styling ===== */656 return `/* ===== Session Manager Styling ===== */
582 657
583 /* Session manager outer container - for positioning in overlay */658 /* Session manager outer container - for positioning in overlay */
@@ -728,10 +803,10 @@ row:selected .session-manager-row {
728 font-family: var(--font-mono);803 font-family: var(--font-mono);
729 }804 }
730 805
731-/* Session mode border (purple) */806+/* Session mode border */
732 .session-mode-active {807 .session-mode-active {
733 background-color: transparent;808 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);
735 border-radius: 0;810 border-radius: 0;
736 }811 }
737 `812 `
@@ -777,6 +852,32 @@ func generateToasterCSS(p Palette) string {
777 background-color: alpha(var(--destructive), 0.9);852 background-color: alpha(var(--destructive), 0.9);
778 color: var(--bg);853 color: var(--bg);
779 }854 }
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+}
780 `881 `
781 }882 }
782 883
M internal/ui/theme/manager.go +30 -2
@@ -18,6 +18,7 @@ type Manager struct {
18 darkPalette Palette // Dark theme colors18 darkPalette Palette // Dark theme colors
19 uiScale float64 // UI scaling factor (1.0 = 100%)19 uiScale float64 // UI scaling factor (1.0 = 100%)
20 fonts FontConfig20 fonts FontConfig
21+ modeColors ModeColors // Modal mode indicator colors
21 cssProvider *gtk.CssProvider22 cssProvider *gtk.CssProvider
22 }23 }
23 24
@@ -56,6 +57,9 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
56 fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont)57 fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont)
57 }58 }
58 59
60+ // Build mode colors from config
61+ modeColors := modeColorsFromConfig(cfg)
62+
59 log.Debug().63 log.Debug().
60 Str("scheme", scheme).64 Str("scheme", scheme).
61 Bool("prefers_dark", prefersDark).65 Bool("prefers_dark", prefersDark).
@@ -71,6 +75,22 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager {
71 darkPalette: darkPalette,75 darkPalette: darkPalette,
72 uiScale: uiScale,76 uiScale: uiScale,
73 fonts: fonts,77 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),
74 }94 }
75 }95 }
76 96
@@ -104,6 +124,11 @@ func (m *Manager) GetDarkPalette() Palette {
104 return m.darkPalette124 return m.darkPalette
105 }125 }
106 126
127+// GetModeColors returns the modal mode indicator colors.
128+func (m *Manager) GetModeColors() ModeColors {
129+ return m.modeColors
130+}
131+
107 // GetWebUIThemeCSS returns CSS text that defines both light and dark variables.132 // GetWebUIThemeCSS returns CSS text that defines both light and dark variables.
108 // WebUI uses `:root` for light and `.dark` overrides for dark.133 // WebUI uses `:root` for light and `.dark` overrides for dark.
109 func (m *Manager) GetWebUIThemeCSS() string {134 func (m *Manager) GetWebUIThemeCSS() string {
@@ -121,9 +146,9 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
121 return146 return
122 }147 }
123 148
124- // Generate CSS with current palette, UI scale and fonts149+ // Generate CSS with current palette, UI scale, fonts, and mode colors
125 palette := m.GetCurrentPalette()150 palette := m.GetCurrentPalette()
126- css := GenerateCSSWithScaleAndFonts(palette, m.uiScale, m.fonts)151+ css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors)
127 152
128 // Create CSS provider if needed153 // Create CSS provider if needed
129 if m.cssProvider == nil {154 if m.cssProvider == nil {
@@ -197,6 +222,9 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp
197 MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont),222 MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont),
198 }223 }
199 224
225+ // Update mode colors
226+ m.modeColors = modeColorsFromConfig(cfg)
227+
200 log.Info().228 log.Info().
201 Str("scheme", m.scheme).229 Str("scheme", m.scheme).
202 Bool("prefers_dark", m.prefersDark).230 Bool("prefers_dark", m.prefersDark).
M internal/ui/theme/palette.go +28 -0
@@ -24,6 +24,34 @@ type Palette struct {
24 Destructive string // Error/destructive actions24 Destructive string // Error/destructive actions
25 }25 }
26 26
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+
27 // DefaultDarkPalette returns the default dark theme palette.55 // DefaultDarkPalette returns the default dark theme palette.
28 func DefaultDarkPalette() Palette {56 func DefaultDarkPalette() Palette {
29 return Palette{57 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 @@
21 const getFaviconUrl = (fav: Favorite): string => {21 const getFaviconUrl = (fav: Favorite): string => {
22 // Use provided favicon_url if available22 // Use provided favicon_url if available
23 if (fav.favicon_url) return fav.favicon_url;23 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+ }
24 // Construct DuckDuckGo favicon URL from domain28 // Construct DuckDuckGo favicon URL from domain
25 const domain = getDomain(fav.url);29 const domain = getDomain(fav.url);
26 return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;30 return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
M webui/src/pages/homepage/history/HistoryItem.svelte +4 -0
@@ -23,6 +23,10 @@
23 const getFaviconUrl = (item: HistoryEntry): string => {23 const getFaviconUrl = (item: HistoryEntry): string => {
24 // Use provided favicon_url if available24 // Use provided favicon_url if available
25 if (item.favicon_url) return item.favicon_url;25 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+ }
26 // Construct DuckDuckGo favicon URL from domain30 // Construct DuckDuckGo favicon URL from domain
27 const domain = getDomain(item.url).replace(/^www\./, '');31 const domain = getDomain(item.url).replace(/^www\./, '');
28 return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;32 return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`;
M webui/vite.config.ts +21 -0
@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
2 import { svelte } from "@sveltejs/vite-plugin-svelte";2 import { svelte } from "@sveltejs/vite-plugin-svelte";
3 import { resolve } from "path";3 import { resolve } from "path";
4 import { pageGenerator } from "./vite-plugin-pages";4 import { pageGenerator } from "./vite-plugin-pages";
5+import { copyFileSync, existsSync, mkdirSync } from "fs";
5 6
6 // Page configurations for dumb:// protocol pages7 // Page configurations for dumb:// protocol pages
7 const pages = {8 const pages = {
@@ -46,6 +47,20 @@ const pages = {
46 // Get page from VITE_PAGE env var, default to building all47 // Get page from VITE_PAGE env var, default to building all
47 const targetPage = process.env.VITE_PAGE as keyof typeof pages | undefined;48 const targetPage = process.env.VITE_PAGE as keyof typeof pages | undefined;
48 49
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+
49 // Build single page config64 // Build single page config
50 function buildPageConfig(pageName: keyof typeof pages) {65 function buildPageConfig(pageName: keyof typeof pages) {
51 const page = pages[pageName];66 const page = pages[pageName];
@@ -55,6 +70,12 @@ function buildPageConfig(pageName: keyof typeof pages) {
55 emitCss: false, // Inline component CSS for shadow DOM compatibility70 emitCss: false, // Inline component CSS for shadow DOM compatibility
56 }),71 }),
57 pageGenerator([page.html]),72 pageGenerator([page.html]),
73+ {
74+ name: "copy-public-assets",
75+ closeBundle() {
76+ copyPublicAssets();
77+ },
78+ },
58 ],79 ],
59 build: {80 build: {
60 rollupOptions: {81 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