Merge pull request #59 from bnema/release/v0.22.0
Release v0.22.0
98 files · +13274 -496
M .mockery.yaml +5 -0
| @@ -8,6 +8,10 @@ packages: | |||
| 8 | 8 | filename: mocks.go | |
| 9 | 9 | pkgname: mocks | |
| 10 | 10 | interfaces: | |
| 11 | + | ConfigMigrator: {} | |
| 12 | + | ConfigSchemaProvider: {} | |
| 13 | + | DatabaseProvider: {} | |
| 14 | + | DiffFormatter: {} | |
| 11 | 15 | SessionLogger: {} | |
| 12 | 16 | SessionSpawner: {} | |
| 13 | 17 | TabListProvider: {} | |
| @@ -51,5 +55,6 @@ packages: | |||
| 51 | 55 | PaneViewFactory: {} | |
| 52 | 56 | PanedWidget: {} | |
| 53 | 57 | ProgressBarWidget: {} | |
| 58 | + | SpinnerWidget: {} | |
| 54 | 59 | Widget: {} | |
| 55 | 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 | 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 | 47 | ### 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 | 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 | 58 | ## [0.21.0] - 2025-12-26 | |
M Makefile +6 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Makefile for dumber (Clean Architecture - puregotk) | |
| 2 | 2 | ||
| 3 | - | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs | |
| 3 | + | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man | |
| 4 | 4 | ||
| 5 | 5 | # Load local overrides from .env.local if present (Makefile syntax) | |
| 6 | 6 | ifneq (,$(wildcard .env.local)) | |
| @@ -137,6 +137,11 @@ check: ## Check that all tools and dependencies are working | |||
| 137 | 137 | @$(MAKE) test | |
| 138 | 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 | 145 | # Native release targets | |
| 141 | 146 | .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 | 77 | | **↓** / **D** (in pane mode) | Split Down | Create new pane below | | |
| 78 | 78 | | **S** (in pane mode) | Stack Pane | Create stacked pane (Zellij-style) | | |
| 79 | 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 | 82 | | **Enter** (in pane mode) | Confirm Action | Confirm pane operation | | |
| 81 | 83 | | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation | | |
| 82 | 84 | | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes | | |
| @@ -242,6 +244,9 @@ sudo apt install va-driver-all | |||
| 242 | 244 | - `dumber browse "!g golang"` # search via bang shortcut | |
| 243 | 245 | - Show version information: | |
| 244 | 246 | - `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 | 250 | - Launcher integration (dmenu‑style examples with favicon support): | |
| 246 | 251 | - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select` | |
| 247 | 252 | - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select` | |
| @@ -256,6 +261,10 @@ sudo apt install va-driver-all | |||
| 256 | 261 | - Clean up data: | |
| 257 | 262 | - `dumber purge` # interactive selection (TUI) | |
| 258 | 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 | 268 | - Manage sessions: | |
| 260 | 269 | - `dumber sessions` # interactive session browser (TUI) | |
| 261 | 270 | - `dumber sessions list` # list saved sessions | |
| @@ -270,6 +279,10 @@ sudo apt install va-driver-all | |||
| 270 | 279 | - `dumber logs -n 200 <session>` # show last N lines | |
| 271 | 280 | - `dumber logs clear` # clean up old log files | |
| 272 | 281 | - `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 | 287 | ### Dmenu mode invocation | |
| 275 | 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 | 307 | 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode | |
| 295 | 308 | 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 | 312 | ## Pane Management | |
| 300 | 313 | ||
| @@ -322,7 +335,7 @@ Dumber automatically saves your browser session (tabs, panes, split ratios, URLs | |||
| 322 | 335 | 4. **Restore**: Select an exited session to restore it in a new browser window | |
| 323 | 336 | 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 | 340 | ## 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 | 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 | 3 | <defs> | |
| 4 | 4 | <!-- Background gradient - deeper dark --> | |
| 5 | 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 | 15 | //go:embed logo.svg | |
| 16 | 16 | 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 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "os/signal" | |
| 9 | - | "path/filepath" | |
| 10 | 9 | "runtime" | |
| 11 | 10 | "syscall" | |
| 12 | 11 | ||
| @@ -23,16 +22,14 @@ import ( | |||
| 23 | 22 | "github.com/bnema/dumber/internal/infrastructure/deps" | |
| 24 | 23 | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 25 | 24 | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| 26 | - | "github.com/bnema/dumber/internal/infrastructure/media" | |
| 27 | 25 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 28 | 26 | "github.com/bnema/dumber/internal/infrastructure/updater" | |
| 29 | 27 | "github.com/bnema/dumber/internal/logging" | |
| 30 | 28 | "github.com/bnema/dumber/internal/ui" | |
| 31 | 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 | 33 | var ( | |
| 37 | 34 | version = "dev" | |
| 38 | 35 | commit = "unknown" | |
| @@ -48,13 +45,10 @@ var restoreSessionID string | |||
| 48 | 45 | func main() { | |
| 49 | 46 | // Run GUI mode for browse command | |
| 50 | 47 | if len(os.Args) > 1 && os.Args[1] == "browse" { | |
| 51 | - | // Extract URL if provided | |
| 52 | 48 | if len(os.Args) > 2 { | |
| 53 | 49 | initialURL = os.Args[2] | |
| 54 | 50 | } | |
| 55 | - | // Check for session restoration via environment variable | |
| 56 | 51 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | |
| 57 | - | // Strip "browse" and URL from args so GTK doesn't see them | |
| 58 | 52 | os.Args = os.Args[:1] | |
| 59 | 53 | os.Exit(runGUI()) | |
| 60 | 54 | return | |
| @@ -73,75 +67,58 @@ func main() { | |||
| 73 | 67 | } | |
| 74 | 68 | ||
| 75 | 69 | func runGUI() int { | |
| 76 | - | // GTK requires all GTK calls to be made from the main thread | |
| 77 | 70 | runtime.LockOSThread() | |
| 71 | + | timer := bootstrap.NewStartupTimer() | |
| 78 | 72 | ||
| 79 | 73 | cfg := initConfig() | |
| 74 | + | timer.Mark("config") | |
| 80 | 75 | ||
| 81 | - | // Apply optional /opt-style runtime prefix overrides (if configured) | |
| 82 | - | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | |
| 83 | - | ||
| 84 | - | bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format) | |
| 85 | - | bootstrapLogger.Info(). | |
| 86 | - | Str("version", version). | |
| 87 | - | Str("commit", commit). | |
| 88 | - | Str("build_date", buildDate). | |
| 89 | - | Msg("starting dumber") | |
| 76 | + | ctx := initStartupContext(cfg) | |
| 77 | + | timer.Mark("logger") | |
| 78 | + | bootstrapLog := logging.FromContext(ctx) | |
| 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 | 97 | if dbCleanup != nil { | |
| 97 | 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 | 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 | 106 | if browserSession.LogCleanup != nil { | |
| 105 | 107 | defer browserSession.LogCleanup() | |
| 106 | 108 | } | |
| 107 | - | defer func() { | |
| 108 | - | _ = browserSession.End(sessionCtx) | |
| 109 | - | }() | |
| 109 | + | defer func() { _ = browserSession.End(sessionCtx) }() | |
| 110 | + | timer.Mark("session") | |
| 110 | 111 | ||
| 111 | 112 | ctx = sessionCtx | |
| 112 | 113 | log := logging.FromContext(ctx) | |
| 113 | - | ||
| 114 | - | checkRuntimeRequirements(ctx, cfg, *log) | |
| 115 | - | checkMediaRequirements(ctx, cfg, *log) | |
| 116 | - | ||
| 117 | - | themeManager := theme.NewManager(ctx, cfg) | |
| 118 | - | stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, *log) | |
| 119 | - | repos := createRepositories(db) | |
| 120 | - | ||
| 121 | - | stateDir, _ := config.GetStateDir() | |
| 122 | - | buildInfo := build.Info{ | |
| 123 | - | Version: version, | |
| 124 | - | Commit: commit, | |
| 125 | - | BuildDate: buildDate, | |
| 126 | - | GoVersion: runtime.Version(), | |
| 114 | + | if stack.MessageRouter != nil { | |
| 115 | + | stack.MessageRouter.SetBaseContext(ctx) | |
| 127 | 116 | } | |
| 128 | - | useCases := createUseCases(repos, cfg, stateDir, buildInfo) | |
| 129 | 117 | ||
| 130 | - | // Auto-restore: find last restorable session if enabled and no explicit restore requested | |
| 131 | - | if cfg.Session.AutoRestore && restoreSessionID == "" { | |
| 132 | - | var out *usecase.GetLastRestorableSessionOutput | |
| 133 | - | out, err = useCases.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 134 | - | ExcludeSessionID: browserSession.Session.ID, | |
| 135 | - | }) | |
| 136 | - | if err != nil { | |
| 137 | - | log.Warn().Err(err).Msg("auto-restore: failed to find restorable session") | |
| 138 | - | } else if out.SessionID != "" { | |
| 139 | - | restoreSessionID = string(out.SessionID) | |
| 140 | - | log.Info(). | |
| 141 | - | Str("session_id", restoreSessionID). | |
| 142 | - | Int("tabs", len(out.State.Tabs)). | |
| 143 | - | Msg("auto-restore: found last session") | |
| 144 | - | } | |
| 118 | + | // Repositories and use cases | |
| 119 | + | useCases := createUseCases(repos, cfg) | |
| 120 | + | if needsEagerDB { | |
| 121 | + | handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID) | |
| 145 | 122 | } | |
| 146 | 123 | ||
| 147 | 124 | idleInhibitor := idle.NewPortalInhibitor(ctx) | |
| @@ -150,29 +127,99 @@ func runGUI() int { | |||
| 150 | 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 | 135 | app, err := ui.New(uiDeps) | |
| 156 | 136 | if err != nil { | |
| 157 | 137 | log.Error().Err(err).Msg("failed to create application") | |
| 158 | 138 | return 1 | |
| 159 | 139 | } | |
| 140 | + | timer.Mark("ui_deps") | |
| 141 | + | timer.Log(ctx) | |
| 160 | 142 | ||
| 161 | - | // Ensure sessions are closed on Ctrl+C / SIGTERM. | |
| 162 | - | // Without this, the process may exit immediately and skip defers, | |
| 163 | - | // leaving the DB session stuck in "active" state. | |
| 164 | - | sigCh := make(chan os.Signal, 1) | |
| 165 | - | signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) | |
| 166 | - | defer signal.Stop(sigCh) | |
| 167 | - | go func() { | |
| 168 | - | <-sigCh | |
| 169 | - | log.Info().Msg("received interrupt, quitting") | |
| 170 | - | app.Quit() | |
| 171 | - | }() | |
| 143 | + | // Signal handling | |
| 144 | + | setupSignalHandler(ctx, app) | |
| 145 | + | ||
| 146 | + | return app.Run(ctx, os.Args) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func initStartupContext(cfg *config.Config) context.Context { | |
| 150 | + | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | |
| 151 | + | bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat( | |
| 152 | + | cfg.Logging.Level, | |
| 153 | + | cfg.Logging.Format, | |
| 154 | + | logging.ConsoleTimeFormat, | |
| 155 | + | ) | |
| 156 | + | bootstrapLogger.Info(). | |
| 157 | + | Str("version", version). | |
| 158 | + | Str("commit", commit). | |
| 159 | + | Str("build_date", buildDate). | |
| 160 | + | Msg("starting dumber") | |
| 161 | + | ctx := logging.WithContext(context.Background(), bootstrapLogger) | |
| 162 | + | return ctx | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func initStackAndRepos( | |
| 166 | + | ctx context.Context, | |
| 167 | + | cfg *config.Config, | |
| 168 | + | initResult *bootstrap.ParallelInitResult, | |
| 169 | + | needsEagerDB bool, | |
| 170 | + | ) (bootstrap.WebKitStack, *repositories, func(), error) { | |
| 171 | + | if needsEagerDB { | |
| 172 | + | // Parallel phase 2: Database + WebKit stack initialize concurrently | |
| 173 | + | dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{ | |
| 174 | + | Ctx: ctx, | |
| 175 | + | Config: cfg, | |
| 176 | + | DataDir: initResult.DataDir, | |
| 177 | + | CacheDir: initResult.CacheDir, | |
| 178 | + | ThemeManager: initResult.ThemeManager, | |
| 179 | + | }) | |
| 180 | + | if err != nil { | |
| 181 | + | return bootstrap.WebKitStack{}, nil, nil, err | |
| 182 | + | } | |
| 183 | + | return dbWebKit.Stack, createRepositories(dbWebKit.DB), dbWebKit.DBCleanup, nil | |
| 184 | + | } | |
| 172 | 185 | ||
| 173 | - | // Run the application | |
| 174 | - | exitCode := app.Run(ctx, os.Args) | |
| 175 | - | return exitCode | |
| 186 | + | log := logging.FromContext(ctx) | |
| 187 | + | stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log) | |
| 188 | + | ||
| 189 | + | lazyDB, err := bootstrap.CreateLazyDatabase() | |
| 190 | + | if err != nil { | |
| 191 | + | return stack, nil, nil, err | |
| 192 | + | } | |
| 193 | + | dbCleanup := func() { _ = lazyDB.Close() } | |
| 194 | + | return stack, createLazyRepositories(lazyDB), dbCleanup, nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func configureDeferredInit( | |
| 198 | + | uiDeps *ui.Dependencies, | |
| 199 | + | cfg *config.Config, | |
| 200 | + | session *bootstrap.BrowserSession, | |
| 201 | + | ) { | |
| 202 | + | if uiDeps == nil { | |
| 203 | + | return | |
| 204 | + | } | |
| 205 | + | uiDeps.OnFirstWebViewShown = func(cbCtx context.Context) { | |
| 206 | + | logger := logging.FromContext(cbCtx) | |
| 207 | + | bgCtx := logging.WithContext(context.Background(), *logger) | |
| 208 | + | go func() { | |
| 209 | + | result := bootstrap.RunDeferredInit(bootstrap.DeferredInitInput{ | |
| 210 | + | Ctx: bgCtx, | |
| 211 | + | Config: cfg, | |
| 212 | + | }) | |
| 213 | + | logDeferredInitResults(bgCtx, result) | |
| 214 | + | }() | |
| 215 | + | if session != nil && session.Persist != nil { | |
| 216 | + | go func() { | |
| 217 | + | if persistErr := session.Persist(bgCtx); persistErr != nil { | |
| 218 | + | logger.Error().Err(persistErr).Msg("deferred session persistence failed") | |
| 219 | + | } | |
| 220 | + | }() | |
| 221 | + | } | |
| 222 | + | } | |
| 176 | 223 | } | |
| 177 | 224 | ||
| 178 | 225 | func initConfig() *config.Config { | |
| @@ -183,77 +230,74 @@ func initConfig() *config.Config { | |||
| 183 | 230 | return config.Get() | |
| 184 | 231 | } | |
| 185 | 232 | ||
| 186 | - | func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) { | |
| 187 | - | probe := deps.NewPkgConfigProbe() | |
| 188 | - | checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe) | |
| 189 | - | runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{ | |
| 190 | - | Prefix: cfg.Runtime.Prefix, | |
| 191 | - | }) | |
| 192 | - | if err != nil { | |
| 193 | - | logger.Fatal().Err(err).Msg("runtime requirements check failed") | |
| 233 | + | func handleParallelInitError(ctx context.Context, err error) { | |
| 234 | + | log := logging.FromContext(ctx) | |
| 235 | + | if runtimeErr, ok := err.(*bootstrap.RuntimeRequirementsError); ok { | |
| 236 | + | runtimeErr.LogDetails(ctx) | |
| 237 | + | log.Fatal().Err(runtimeErr). | |
| 238 | + | Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)"). | |
| 239 | + | Msg("runtime requirements not met") | |
| 194 | 240 | } | |
| 195 | - | if runtimeOut.OK { | |
| 196 | - | return | |
| 241 | + | log.Fatal().Err(err).Msg("initialization failed") | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func logDeferredInitResults(ctx context.Context, result bootstrap.DeferredInitResult) { | |
| 245 | + | log := logging.FromContext(ctx) | |
| 246 | + | if result.SQLiteErr != nil { | |
| 247 | + | log.Warn().Err(result.SQLiteErr).Msg("deferred sqlite wasm init failed") | |
| 197 | 248 | } | |
| 198 | - | for _, c := range runtimeOut.Checks { | |
| 199 | - | if c.Installed { | |
| 200 | - | logger.Error(). | |
| 201 | - | Str("dependency", c.PkgConfigName). | |
| 202 | - | Str("have", c.Version). | |
| 203 | - | Str("need", c.RequiredVersion). | |
| 204 | - | Bool("ok", c.MeetsRequirement). | |
| 205 | - | Msg("runtime dependency") | |
| 249 | + | if result.RuntimeErr != nil { | |
| 250 | + | if runtimeErr, ok := result.RuntimeErr.(*bootstrap.RuntimeRequirementsError); ok { | |
| 251 | + | runtimeErr.LogDetails(ctx) | |
| 252 | + | log.Warn().Err(runtimeErr). | |
| 253 | + | Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)"). | |
| 254 | + | Msg("runtime requirements not met") | |
| 206 | 255 | } else { | |
| 207 | - | logger.Error(). | |
| 208 | - | Str("dependency", c.PkgConfigName). | |
| 209 | - | Str("need", c.RequiredVersion). | |
| 210 | - | Msg("runtime dependency missing") | |
| 256 | + | log.Warn().Err(result.RuntimeErr).Msg("runtime requirements check failed") | |
| 211 | 257 | } | |
| 212 | 258 | } | |
| 213 | - | logger.Fatal(). | |
| 214 | - | Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)"). | |
| 215 | - | Msg("runtime requirements not met") | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | func checkMediaRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) { | |
| 219 | - | mediaDiagAdapter := media.New() | |
| 220 | - | checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter) | |
| 221 | - | if _, mediaErr := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{ | |
| 222 | - | ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup, | |
| 223 | - | }); mediaErr != nil { | |
| 224 | - | logger.Fatal().Err(mediaErr).Msg("media requirements check failed") | |
| 259 | + | if result.MediaErr != nil { | |
| 260 | + | log.Warn().Err(result.MediaErr).Msg("media check failed") | |
| 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) { | |
| 229 | - | const cacheDirPerm = 0o755 | |
| 230 | - | dataDir, err := config.GetDataDir() | |
| 231 | - | if err != nil { | |
| 232 | - | logger.Fatal().Err(err).Msg("failed to resolve data directory") | |
| 265 | + | func handleAutoRestore( | |
| 266 | + | ctx context.Context, | |
| 267 | + | cfg *config.Config, | |
| 268 | + | uc *useCases, | |
| 269 | + | currentSessionID entity.SessionID, | |
| 270 | + | ) { | |
| 271 | + | if !cfg.Session.AutoRestore || restoreSessionID != "" { | |
| 272 | + | return | |
| 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 | 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") | |
| 239 | - | if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil { | |
| 240 | - | logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory") | |
| 282 | + | if out.SessionID != "" { | |
| 283 | + | restoreSessionID = string(out.SessionID) | |
| 284 | + | log.Info(). | |
| 285 | + | Str("session_id", restoreSessionID). | |
| 286 | + | Int("tabs", len(out.State.Tabs)). | |
| 287 | + | Msg("auto-restore: found last session") | |
| 241 | 288 | } | |
| 242 | - | return dataDir, cacheDir | |
| 243 | 289 | } | |
| 244 | 290 | ||
| 245 | - | func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (*sql.DB, func()) { | |
| 246 | - | dbPath := filepath.Join(dataDir, "dumber.db") | |
| 247 | - | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 248 | - | if err != nil { | |
| 249 | - | logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database") | |
| 250 | - | } | |
| 251 | - | cleanup := func() { | |
| 252 | - | if err := sqlite.Close(db); err != nil { | |
| 253 | - | logger.Error().Err(err).Msg("failed to close database") | |
| 254 | - | } | |
| 255 | - | } | |
| 256 | - | return db, cleanup | |
| 291 | + | func setupSignalHandler(ctx context.Context, app *ui.App) { | |
| 292 | + | log := logging.FromContext(ctx) | |
| 293 | + | sigCh := make(chan os.Signal, 1) | |
| 294 | + | signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) | |
| 295 | + | go func() { | |
| 296 | + | sig := <-sigCh | |
| 297 | + | signal.Stop(sigCh) | |
| 298 | + | log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting") | |
| 299 | + | app.Quit() | |
| 300 | + | }() | |
| 257 | 301 | } | |
| 258 | 302 | ||
| 259 | 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 | 338 | // useCases groups application layer use case implementations. | |
| 283 | 339 | type useCases struct { | |
| 284 | 340 | tabs *usecase.ManageTabsUseCase | |
| @@ -296,7 +352,7 @@ type useCases struct { | |||
| 296 | 352 | 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 | 356 | const idAlphabetSize = 26 | |
| 301 | 357 | idCounter := uint64(0) | |
| 302 | 358 | idGenerator := func() string { | |
| @@ -306,13 +362,17 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu | |||
| 306 | 362 | ||
| 307 | 363 | clipboardAdapter := clipboard.New() | |
| 308 | 364 | faviconCacheDir, _ := config.GetFaviconCacheDir() | |
| 309 | - | cacheDir, _ := config.GetXDGDirs() | |
| 365 | + | xdgDirs, _ := config.GetXDGDirs() | |
| 366 | + | stateDir, _ := config.GetStateDir() | |
| 310 | 367 | defaultZoom := cfg.DefaultWebpageZoom | |
| 311 | - | ||
| 312 | - | // Create LRU cache for zoom levels to avoid database queries on repeat visits | |
| 313 | 368 | zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize) | |
| 314 | 369 | ||
| 315 | - | // Create update infrastructure | |
| 370 | + | buildInfo := build.Info{ | |
| 371 | + | Version: version, | |
| 372 | + | Commit: commit, | |
| 373 | + | BuildDate: buildDate, | |
| 374 | + | GoVersion: runtime.Version(), | |
| 375 | + | } | |
| 316 | 376 | updateChecker := updater.NewGitHubChecker() | |
| 317 | 377 | updateDownloader := updater.NewGitHubDownloader() | |
| 318 | 378 | updateApplier := updater.NewApplier(stateDir) | |
| @@ -328,7 +388,7 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu | |||
| 328 | 388 | snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState), | |
| 329 | 389 | lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir), | |
| 330 | 390 | checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo), | |
| 331 | - | applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, cacheDir.CacheHome), | |
| 391 | + | applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome), | |
| 332 | 392 | clipboard: clipboardAdapter, | |
| 333 | 393 | favicon: favicon.NewService(faviconCacheDir), | |
| 334 | 394 | } | |
M docs/CONFIG.md +51 -8
| @@ -201,6 +201,7 @@ border = "#363636" | |||
| 201 | 201 | | Key | Type | Default | Description | | |
| 202 | 202 | |-----|------|---------|-------------| | |
| 203 | 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 | 206 | **Example:** | |
| 206 | 207 | ```toml | |
| @@ -225,6 +226,8 @@ split-up = ["arrowup", "u"] | |||
| 225 | 226 | split-down = ["arrowdown", "d"] | |
| 226 | 227 | stack-pane = ["s"] | |
| 227 | 228 | close-pane = ["x"] | |
| 229 | + | move-pane-to-tab = ["m"] | |
| 230 | + | move-pane-to-next-tab = ["M", "shift+m"] | |
| 228 | 231 | ||
| 229 | 232 | # Consume-or-expel (niri-style) - very alpha | |
| 230 | 233 | consume-or-expel-left = ["bracketleft", "["] | |
| @@ -330,16 +333,28 @@ Notes: | |||
| 330 | 333 | |-----|------|---------|-------------| | |
| 331 | 334 | | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay | | |
| 332 | 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 | | |
| 334 | - | | `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) | | |
| 335 | - | | `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay | | |
| 336 | - | | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) | | |
| 337 | - | | `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay | | |
| 338 | - | | `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) | | |
| 339 | - | | `workspace.styling.resize_mode_border_width` | int | `4` | Resize mode border width (px) | | |
| 340 | - | | `workspace.styling.resize_mode_border_color` | string | `"#00D4AA"` | Resize mode border color (teal) | | |
| 336 | + | | `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes | | |
| 337 | + | | `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue) - used for border and toaster | | |
| 338 | + | | `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster | | |
| 339 | + | | `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster | | |
| 340 | + | | `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster | | |
| 341 | + | | `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show toaster notification when modal modes are active | | |
| 341 | 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 | 358 | ## Session | |
| 344 | 359 | ||
| 345 | 360 | | Key | Type | Default | Description | | |
| @@ -448,6 +463,34 @@ Notes: | |||
| 448 | 463 | - Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases. | |
| 449 | 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 | 494 | ## Environment Variables | |
| 452 | 495 | ||
| 453 | 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 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 9 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | |
| 10 | 10 | github.com/fsnotify/fsnotify v1.9.0 | |
| 11 | + | github.com/godbus/dbus/v5 v5.2.1 | |
| 11 | 12 | github.com/jwijenbergh/puregotk v0.0.0-20251222174431-73930527faa3 | |
| 12 | 13 | github.com/ncruces/go-sqlite3 v0.30.3 | |
| 14 | + | github.com/pelletier/go-toml/v2 v2.2.4 | |
| 13 | 15 | github.com/pressly/goose/v3 v3.26.0 | |
| 14 | 16 | github.com/rs/zerolog v1.33.0 | |
| 15 | 17 | github.com/spf13/cobra v1.10.2 | |
| 16 | 18 | github.com/spf13/viper v1.21.0 | |
| 17 | 19 | github.com/stretchr/testify v1.11.1 | |
| 18 | 20 | golang.org/x/image v0.34.0 | |
| 21 | + | golang.org/x/sys v0.38.0 | |
| 19 | 22 | ) | |
| 20 | 23 | ||
| 21 | 24 | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3 | |
| @@ -27,10 +30,10 @@ require ( | |||
| 27 | 30 | github.com/charmbracelet/x/ansi v0.10.1 // indirect | |
| 28 | 31 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect | |
| 29 | 32 | github.com/charmbracelet/x/term v0.2.1 // indirect | |
| 33 | + | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect | |
| 30 | 34 | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 31 | 35 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 32 | 36 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | |
| 33 | - | github.com/godbus/dbus/v5 v5.2.1 // indirect | |
| 34 | 37 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 35 | 38 | github.com/jwijenbergh/purego v0.0.0-20251017112123-b71757b9ba42 // indirect | |
| 36 | 39 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | |
| @@ -43,9 +46,9 @@ require ( | |||
| 43 | 46 | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 44 | 47 | github.com/muesli/termenv v0.16.0 // indirect | |
| 45 | 48 | github.com/ncruces/julianday v1.0.0 // indirect | |
| 46 | - | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | |
| 47 | 49 | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 48 | 50 | github.com/rivo/uniseg v0.4.7 // indirect | |
| 51 | + | github.com/russross/blackfriday/v2 v2.1.0 // indirect | |
| 49 | 52 | github.com/sagikazarmark/locafero v0.11.0 // indirect | |
| 50 | 53 | github.com/sahilm/fuzzy v0.1.1 // indirect | |
| 51 | 54 | github.com/sethvargo/go-retry v0.3.0 // indirect | |
| @@ -60,7 +63,6 @@ require ( | |||
| 60 | 63 | go.uber.org/multierr v1.11.0 // indirect | |
| 61 | 64 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 62 | 65 | golang.org/x/sync v0.19.0 // indirect | |
| 63 | - | golang.org/x/sys v0.38.0 // indirect | |
| 64 | 66 | golang.org/x/text v0.32.0 // indirect | |
| 65 | 67 | 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 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | + | "database/sql" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -13,6 +14,630 @@ import ( | |||
| 13 | 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 | 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 | 642 | // The first argument is typically a *testing.T value. | |
| 18 | 643 | func NewMockSessionLogger(t interface { | |
M internal/application/port/xdg.go +4 -0
| @@ -10,4 +10,8 @@ type XDGPaths interface { | |||
| 10 | 10 | FilterJSONDir() (string, error) | |
| 11 | 11 | FilterStoreDir() (string, error) | |
| 12 | 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 | 196 | 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 | 270 | // GetTree builds a complete hierarchical view of folders and favorites. | |
| 200 | 271 | func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) { | |
| 201 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "database/sql" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | "os" | |
| 9 | 8 | "path/filepath" | |
| 9 | + | "sync" | |
| 10 | 10 | "syscall" | |
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/dumber/internal/application/port" | |
| 14 | 14 | "github.com/bnema/dumber/internal/application/usecase" | |
| 15 | 15 | "github.com/bnema/dumber/internal/domain/entity" | |
| 16 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 17 | 18 | infralogging "github.com/bnema/dumber/internal/infrastructure/logging" | |
| 18 | - | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 19 | 19 | corelogging "github.com/bnema/dumber/internal/logging" | |
| 20 | 20 | "github.com/rs/zerolog" | |
| 21 | 21 | ) | |
| @@ -31,11 +31,20 @@ type BrowserSession struct { | |||
| 31 | 31 | Logger zerolog.Logger | |
| 32 | 32 | LogCleanup func() | |
| 33 | 33 | ||
| 34 | - | End func(context.Context) error | |
| 34 | + | 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 | 44 | log := corelogging.FromContext(ctx) | |
| 45 | + | if sessionRepo == nil { | |
| 46 | + | return nil, ctx, errors.New("session repository is nil") | |
| 47 | + | } | |
| 39 | 48 | ||
| 40 | 49 | lockDir := cfg.Logging.LogDir | |
| 41 | 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 | 56 | sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter() | |
| 49 | 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". | |
| 52 | - | // Use per-session lock files to distinguish truly running sessions from stale ones. | |
| 53 | - | if lockDir != "" { | |
| 54 | - | if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil { | |
| 55 | - | log.Warn().Err(err).Msg("failed to mark stale sessions ended") | |
| 56 | - | } | |
| 60 | + | now := time.Now() | |
| 61 | + | session := &entity.Session{ | |
| 62 | + | ID: entity.SessionID(corelogging.GenerateSessionID()), | |
| 63 | + | Type: entity.SessionTypeBrowser, | |
| 64 | + | StartedAt: now.UTC(), | |
| 57 | 65 | } | |
| 58 | - | ||
| 59 | - | // Clean up old exited sessions based on config limits | |
| 60 | - | cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 61 | - | cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 62 | - | MaxExitedSessions: cfg.Session.MaxExitedSessions, | |
| 63 | - | MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays, | |
| 64 | - | }) | |
| 65 | - | if err != nil { | |
| 66 | - | log.Warn().Err(err).Msg("failed to cleanup old sessions") | |
| 67 | - | } else if cleanupOutput.TotalDeleted > 0 { | |
| 68 | - | log.Info(). | |
| 69 | - | Int64("deleted", cleanupOutput.TotalDeleted). | |
| 70 | - | Msg("cleaned up old sessions on startup") | |
| 66 | + | if err := session.Validate(); err != nil { | |
| 67 | + | return nil, ctx, err | |
| 71 | 68 | } | |
| 72 | 69 | ||
| 73 | - | now := time.Now() | |
| 74 | - | out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{ | |
| 75 | - | Type: entity.SessionTypeBrowser, | |
| 76 | - | Now: now, | |
| 77 | - | LogConfig: port.SessionLogConfig{ | |
| 78 | - | Level: cfg.Logging.Level, | |
| 79 | - | Format: cfg.Logging.Format, | |
| 80 | - | TimeFormat: "15:04:05", | |
| 81 | - | LogDir: cfg.Logging.LogDir, | |
| 82 | - | WriteToStderr: true, | |
| 83 | - | EnableFileLog: cfg.Logging.EnableFileLog, | |
| 84 | - | }, | |
| 70 | + | logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{ | |
| 71 | + | Level: cfg.Logging.Level, | |
| 72 | + | Format: cfg.Logging.Format, | |
| 73 | + | TimeFormat: corelogging.ConsoleTimeFormat, | |
| 74 | + | LogDir: cfg.Logging.LogDir, | |
| 75 | + | WriteToStderr: true, | |
| 76 | + | EnableFileLog: cfg.Logging.EnableFileLog, | |
| 85 | 77 | }) | |
| 86 | 78 | if err != nil { | |
| 87 | - | return nil, ctx, err | |
| 79 | + | log.Warn().Err(err).Msg("failed to create session logger") | |
| 88 | 80 | } | |
| 89 | 81 | ||
| 90 | - | lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID) | |
| 91 | - | if lockErr != nil { | |
| 92 | - | // Don’t fail startup if locking fails; it only affects stale-session cleanup. | |
| 93 | - | out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock") | |
| 94 | - | } | |
| 82 | + | sessionCtx := corelogging.WithContext(context.Background(), logger) | |
| 83 | + | ||
| 84 | + | var ( | |
| 85 | + | lockMu sync.Mutex | |
| 86 | + | lockFile *os.File | |
| 87 | + | lockPath string | |
| 88 | + | persistMu sync.Once | |
| 89 | + | persistErr error | |
| 90 | + | ) | |
| 91 | + | ||
| 92 | + | // persistFn ensures the session is saved exactly once. Using sync.Once is intentional: | |
| 93 | + | // if the first persist fails, we cache the error and return it on subsequent calls | |
| 94 | + | // rather than retrying. This prevents duplicate session records and ensures consistent | |
| 95 | + | // error handling during shutdown sequences where persistFn may be called multiple times. | |
| 96 | + | persistFn := func(persistCtx context.Context) error { | |
| 97 | + | persistMu.Do(func() { | |
| 98 | + | saveCtx := corelogging.WithContext(context.Background(), logger) | |
| 99 | + | if err := sessionRepo.Save(saveCtx, session); err != nil { | |
| 100 | + | persistErr = fmt.Errorf("save session: %w", err) | |
| 101 | + | return | |
| 102 | + | } | |
| 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 | 127 | endFn := func(endCtx context.Context) error { | |
| 99 | - | endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now()) | |
| 100 | - | if lockFile != nil { | |
| 101 | - | _ = unlockAndClose(lockFile) | |
| 102 | - | _ = os.Remove(lockPath) | |
| 128 | + | _ = persistFn(endCtx) | |
| 129 | + | endErr := sessionUC.EndSession(endCtx, session.ID, time.Now()) | |
| 130 | + | ||
| 131 | + | lockMu.Lock() | |
| 132 | + | lf := lockFile | |
| 133 | + | lp := lockPath | |
| 134 | + | lockMu.Unlock() | |
| 135 | + | ||
| 136 | + | if lf != nil { | |
| 137 | + | _ = unlockAndClose(lf) | |
| 138 | + | _ = os.Remove(lp) | |
| 103 | 139 | } | |
| 104 | 140 | return endErr | |
| 105 | 141 | } | |
| 106 | 142 | ||
| 107 | - | return &BrowserSession{ | |
| 108 | - | Session: out.Session, | |
| 109 | - | Logger: out.Logger, | |
| 110 | - | LogCleanup: out.LogCleanup, | |
| 143 | + | browserSession := &BrowserSession{ | |
| 144 | + | Session: session, | |
| 145 | + | Logger: logger, | |
| 146 | + | LogCleanup: cleanup, | |
| 111 | 147 | End: endFn, | |
| 112 | - | }, sessionCtx, nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | if deferPersist { | |
| 151 | + | browserSession.Persist = persistFn | |
| 152 | + | } else { | |
| 153 | + | if err := persistFn(ctx); err != nil { | |
| 154 | + | if cleanup != nil { | |
| 155 | + | cleanup() | |
| 156 | + | } | |
| 157 | + | return nil, ctx, err | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | return browserSession, sessionCtx, nil | |
| 113 | 162 | } | |
| 114 | 163 | ||
| 115 | 164 | func endStaleActiveBrowserSessions( | |
| @@ -213,3 +262,49 @@ func unlockAndClose(f *os.File) error { | |||
| 213 | 262 | _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) | |
| 214 | 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 | 95 | settings := webkit.NewSettingsManager(ctx, cfg) | |
| 96 | 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 | 101 | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector) | |
| 99 | 102 | themeCSSText := themeManager.GetWebUIThemeCSS() | |
| 100 | 103 | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil { | |
| @@ -108,11 +111,21 @@ func BuildWebKitStack( | |||
| 108 | 111 | poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount | |
| 109 | 112 | } | |
| 110 | 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 | 118 | if filterManager != nil { | |
| 113 | 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 | 129 | return WebKitStack{ | |
| 117 | 130 | Context: wkCtx, | |
| 118 | 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 | 11 | tea "github.com/charmbracelet/bubbletea" | |
| 12 | 12 | "github.com/spf13/cobra" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/dumber/assets" | |
| 14 | 15 | "github.com/bnema/dumber/internal/cli/model" | |
| 15 | 16 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 18 | + | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 17 | 19 | "github.com/bnema/dumber/internal/logging" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| @@ -87,6 +89,13 @@ func getFaviconPath(rawURL string) string { | |||
| 87 | 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 | 99 | domain := domainurl.ExtractDomain(rawURL) | |
| 91 | 100 | if domain == "" { | |
| 92 | 101 | return "" | |
| @@ -94,7 +103,6 @@ func getFaviconPath(rawURL string) string { | |||
| 94 | 103 | ||
| 95 | 104 | ctx := app.Ctx() | |
| 96 | 105 | log := logging.FromContext(ctx) | |
| 97 | - | const normalizedSize = 32 | |
| 98 | 106 | ||
| 99 | 107 | // Check if sized PNG already exists | |
| 100 | 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 | 16 | buildInfo build.Info | |
| 17 | 17 | rootCmd = &cobra.Command{ | |
| 18 | 18 | Use: "dumber", | |
| 19 | - | Short: "A minimal web browser", | |
| 20 | - | Long: `Dumber is a minimal, keyboard-driven web browser built with GTK4 and WebKitGTK.`, | |
| 19 | + | Short: "A fully unfeatured unbloated browser for tiling WMs", | |
| 20 | + | Long: `Dumber - a dumb browser that works like your favorite terminal multiplexer. | |
| 21 | + | ||
| 22 | + | A fully unfeatured unbloated browser for tiling WMs, built with GTK4 and WebKitGTK. | |
| 23 | + | ||
| 24 | + | Features: | |
| 25 | + | - Wayland native (Sway, Hyprland, River, Niri, etc.) | |
| 26 | + | - Tabs and workspaces with split or stacked panes | |
| 27 | + | - Keyboard-driven workflow inspired by Zellij | |
| 28 | + | - GPU rendering with automatic VA-API/VDPAU detection | |
| 29 | + | - Built-in ad blocking (UBlock-based network + cosmetic filtering) | |
| 30 | + | - Launcher integration (rofi/fuzzel) with favicons | |
| 31 | + | - Search shortcuts via bangs (!g, !gi, etc.) | |
| 32 | + | - Session management with auto-save and restore | |
| 33 | + | ||
| 34 | + | Use 'dumber browse' to launch the graphical browser, or explore the | |
| 35 | + | subcommands for CLI-based operations like history search and session | |
| 36 | + | management.`, | |
| 21 | 37 | PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { | |
| 22 | - | // Skip initialization for help commands | |
| 23 | - | if cmd.Name() == "help" || cmd.Name() == "completion" { | |
| 38 | + | // Skip initialization for commands that don't need app context | |
| 39 | + | switch cmd.Name() { | |
| 40 | + | case "help", "completion", "gen-docs": | |
| 24 | 41 | 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 | 53 | IconRestore = "\uf0e2" // rotate-left (restore) | |
| 54 | 54 | IconExpand = "\uf065" // expand | |
| 55 | 55 | 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 | 79 | tl.Tabs[j].Position = j | |
| 80 | 80 | } | |
| 81 | 81 | // Update active tab if needed | |
| 82 | - | if tl.ActiveTabID == id && len(tl.Tabs) > 0 { | |
| 83 | - | if i < len(tl.Tabs) { | |
| 84 | - | tl.ActiveTabID = tl.Tabs[i].ID | |
| 82 | + | if tl.ActiveTabID == id { | |
| 83 | + | if len(tl.Tabs) > 0 { | |
| 84 | + | if i < len(tl.Tabs) { | |
| 85 | + | tl.ActiveTabID = tl.Tabs[i].ID | |
| 86 | + | } else { | |
| 87 | + | tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID | |
| 88 | + | } | |
| 85 | 89 | } else { | |
| 86 | - | tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID | |
| 90 | + | tl.ActiveTabID = "" | |
| 91 | + | tl.PreviousActiveTabID = "" | |
| 87 | 92 | } | |
| 88 | 93 | } | |
| 89 | 94 | return true | |
M internal/infrastructure/config/defaults.go +31 -33
| @@ -46,21 +46,17 @@ const ( | |||
| 46 | 46 | defaultBorderWidth = 1 | |
| 47 | 47 | defaultBorderColor = "@theme_selected_bg_color" | |
| 48 | 48 | ||
| 49 | - | // Pane mode border (Ctrl+P N - overlay) | |
| 50 | - | defaultPaneModeBorderWidth = 4 | |
| 51 | - | defaultPaneModeBorderColor = "#4A90E2" // Blue for pane mode indicator | |
| 49 | + | // Mode border width (consolidated for all modes) | |
| 50 | + | defaultModeBorderWidth = 4 | |
| 52 | 51 | ||
| 53 | - | // Tab mode border (Ctrl+P T - overlay) | |
| 54 | - | defaultTabModeBorderWidth = 4 | |
| 55 | - | defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator | |
| 52 | + | // Mode colors (used for both borders and toaster) | |
| 53 | + | defaultPaneModeColor = "#4A90E2" // Blue for pane mode | |
| 54 | + | defaultTabModeColor = "#FFA500" // Orange for tab mode | |
| 55 | + | defaultSessionModeColor = "#9B59B6" // Purple for session mode | |
| 56 | + | defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode | |
| 56 | 57 | ||
| 57 | - | // Session mode border (Ctrl+O - overlay) | |
| 58 | - | defaultSessionModeBorderWidth = 4 | |
| 59 | - | defaultSessionModeBorderColor = "#9B59B6" // Purple for session mode indicator | |
| 60 | - | ||
| 61 | - | // Resize mode border (Ctrl+N - overlay) | |
| 62 | - | defaultResizeModeBorderWidth = 4 | |
| 63 | - | defaultResizeModeBorderColor = "#00D4AA" // Cyan/teal for resize mode indicator | |
| 58 | + | // Mode indicator toaster | |
| 59 | + | defaultModeIndicatorToasterEnabled = true | |
| 64 | 60 | ||
| 65 | 61 | // Other styling | |
| 66 | 62 | defaultTransitionDuration = 120 | |
| @@ -155,17 +151,20 @@ func DefaultConfig() *Config { | |||
| 155 | 151 | DefaultWebpageZoom: 1.2, // 120% default zoom for better readability | |
| 156 | 152 | DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200% | |
| 157 | 153 | Workspace: WorkspaceConfig{ | |
| 158 | - | NewPaneURL: defaultNewPaneURL, | |
| 154 | + | NewPaneURL: defaultNewPaneURL, | |
| 155 | + | SwitchToTabOnMove: true, | |
| 159 | 156 | PaneMode: PaneModeConfig{ | |
| 160 | 157 | ActivationShortcut: defaultPaneActivationShortcut, | |
| 161 | 158 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | |
| 162 | 159 | Actions: map[string][]string{ | |
| 163 | - | "split-right": {"arrowright", "r"}, | |
| 164 | - | "split-left": {"arrowleft", "l"}, | |
| 165 | - | "split-up": {"arrowup", "u"}, | |
| 166 | - | "split-down": {"arrowdown", "d"}, | |
| 167 | - | "stack-pane": {"s"}, | |
| 168 | - | "close-pane": {"x"}, | |
| 160 | + | "split-right": {"arrowright", "r"}, | |
| 161 | + | "split-left": {"arrowleft", "l"}, | |
| 162 | + | "split-up": {"arrowup", "u"}, | |
| 163 | + | "split-down": {"arrowdown", "d"}, | |
| 164 | + | "stack-pane": {"s"}, | |
| 165 | + | "close-pane": {"x"}, | |
| 166 | + | "move-pane-to-tab": {"m"}, | |
| 167 | + | "move-pane-to-next-tab": {"M", "shift+m"}, | |
| 169 | 168 | ||
| 170 | 169 | "consume-or-expel-left": {"bracketleft", "["}, | |
| 171 | 170 | "consume-or-expel-right": {"bracketright", "]"}, | |
| @@ -235,17 +234,15 @@ func DefaultConfig() *Config { | |||
| 235 | 234 | OAuthAutoClose: true, // Auto-close OAuth popups on success | |
| 236 | 235 | }, | |
| 237 | 236 | Styling: WorkspaceStylingConfig{ | |
| 238 | - | BorderWidth: defaultBorderWidth, | |
| 239 | - | BorderColor: defaultBorderColor, | |
| 240 | - | PaneModeBorderWidth: defaultPaneModeBorderWidth, | |
| 241 | - | PaneModeBorderColor: defaultPaneModeBorderColor, | |
| 242 | - | TabModeBorderWidth: defaultTabModeBorderWidth, | |
| 243 | - | TabModeBorderColor: defaultTabModeBorderColor, | |
| 244 | - | SessionModeBorderWidth: defaultSessionModeBorderWidth, | |
| 245 | - | SessionModeBorderColor: defaultSessionModeBorderColor, | |
| 246 | - | ResizeModeBorderWidth: defaultResizeModeBorderWidth, | |
| 247 | - | ResizeModeBorderColor: defaultResizeModeBorderColor, | |
| 248 | - | TransitionDuration: defaultTransitionDuration, | |
| 237 | + | BorderWidth: defaultBorderWidth, | |
| 238 | + | BorderColor: defaultBorderColor, | |
| 239 | + | ModeBorderWidth: defaultModeBorderWidth, | |
| 240 | + | PaneModeColor: defaultPaneModeColor, | |
| 241 | + | TabModeColor: defaultTabModeColor, | |
| 242 | + | SessionModeColor: defaultSessionModeColor, | |
| 243 | + | ResizeModeColor: defaultResizeModeColor, | |
| 244 | + | ModeIndicatorToasterEnabled: defaultModeIndicatorToasterEnabled, | |
| 245 | + | TransitionDuration: defaultTransitionDuration, | |
| 249 | 246 | }, | |
| 250 | 247 | }, | |
| 251 | 248 | ContentFiltering: ContentFilteringConfig{ | |
| @@ -289,8 +286,9 @@ func DefaultConfig() *Config { | |||
| 289 | 286 | WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount, | |
| 290 | 287 | }, | |
| 291 | 288 | Update: UpdateConfig{ | |
| 292 | - | EnableOnStartup: true, // Check for updates on startup by default | |
| 293 | - | AutoDownload: false, // Conservative: don't auto-download by default | |
| 289 | + | EnableOnStartup: true, // Check for updates on startup by default | |
| 290 | + | AutoDownload: false, // Conservative: don't auto-download by default | |
| 291 | + | NotifyOnNewSettings: true, // Show toast when new config settings available | |
| 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 | 401 | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown) | |
| 402 | 402 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | |
| 403 | 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 | 405 | m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior)) | |
| 405 | 406 | m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement) | |
| 406 | 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 | 411 | m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose) | |
| 411 | 412 | m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth) | |
| 412 | 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.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor) | |
| 415 | - | m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth) | |
| 416 | - | m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor) | |
| 417 | - | m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth) | |
| 418 | - | m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor) | |
| 419 | - | m.viper.SetDefault("workspace.styling.resize_mode_border_width", defaults.Workspace.Styling.ResizeModeBorderWidth) | |
| 420 | - | m.viper.SetDefault("workspace.styling.resize_mode_border_color", defaults.Workspace.Styling.ResizeModeBorderColor) | |
| 414 | + | m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth) | |
| 415 | + | m.viper.SetDefault("workspace.styling.pane_mode_color", defaults.Workspace.Styling.PaneModeColor) | |
| 416 | + | m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor) | |
| 417 | + | m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor) | |
| 418 | + | m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor) | |
| 419 | + | m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled) | |
| 421 | 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 | 458 | func (m *Manager) setUpdateDefaults(defaults *Config) { | |
| 460 | 459 | m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup) | |
| 461 | 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 | 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 | 294 | // When enabled, updates are applied on browser exit. | |
| 295 | 295 | // Default: false | |
| 296 | 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 | 302 | // WorkspaceConfig captures layout, pane, and tab behavior preferences. | |
| @@ -313,6 +316,9 @@ type WorkspaceConfig struct { | |||
| 313 | 316 | TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"` | |
| 314 | 317 | // HideTabBarWhenSingleTab hides the tab bar when only one tab exists. | |
| 315 | 318 | 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 | 322 | // Popups configures default popup placement rules. | |
| 317 | 323 | Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"` | |
| 318 | 324 | // Styling configures workspace visual appearance. | |
| @@ -438,29 +444,21 @@ type WorkspaceStylingConfig struct { | |||
| 438 | 444 | // BorderColor for focused panes (CSS color value or theme variable) | |
| 439 | 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) | |
| 442 | - | PaneModeBorderWidth int `mapstructure:"pane_mode_border_width" yaml:"pane_mode_border_width" toml:"pane_mode_border_width" json:"pane_mode_border_width"` //nolint:lll // struct tags must stay on one line | |
| 443 | - | // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable) | |
| 444 | - | // Defaults to "#4A90E2" (blue) if not set | |
| 445 | - | PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" toml:"pane_mode_border_color" json:"pane_mode_border_color"` //nolint:lll // struct tags must stay on one line | |
| 446 | - | ||
| 447 | - | // TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay) | |
| 448 | - | TabModeBorderWidth int `mapstructure:"tab_mode_border_width" yaml:"tab_mode_border_width" toml:"tab_mode_border_width" json:"tab_mode_border_width"` //nolint:lll // struct tags must stay on one line | |
| 449 | - | // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable) | |
| 450 | - | // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor | |
| 451 | - | TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" toml:"tab_mode_border_color" json:"tab_mode_border_color"` //nolint:lll // struct tags must stay on one line | |
| 452 | - | ||
| 453 | - | // SessionModeBorderWidth in pixels for session mode indicator border (Ctrl+O overlay) | |
| 454 | - | SessionModeBorderWidth int `mapstructure:"session_mode_border_width" yaml:"session_mode_border_width" toml:"session_mode_border_width" json:"session_mode_border_width"` //nolint:lll // struct tags must stay on one line | |
| 455 | - | // SessionModeBorderColor for the session mode indicator border (CSS color value or theme variable) | |
| 456 | - | // Defaults to "#9B59B6" (purple) if not set - MUST be different from other mode colors | |
| 457 | - | SessionModeBorderColor string `mapstructure:"session_mode_border_color" yaml:"session_mode_border_color" toml:"session_mode_border_color" json:"session_mode_border_color"` //nolint:lll // struct tags must stay on one line | |
| 458 | - | ||
| 459 | - | // ResizeModeBorderWidth in pixels for resize mode indicator border (Ctrl+N overlay) | |
| 460 | - | ResizeModeBorderWidth int `mapstructure:"resize_mode_border_width" yaml:"resize_mode_border_width" toml:"resize_mode_border_width" json:"resize_mode_border_width"` //nolint:lll // struct tags must stay on one line | |
| 461 | - | // ResizeModeBorderColor for the resize mode indicator border (CSS color value or theme variable) | |
| 462 | - | // Defaults to "#00D4AA" (cyan/teal) if not set - MUST be different from other mode colors | |
| 463 | - | ResizeModeBorderColor string `mapstructure:"resize_mode_border_color" yaml:"resize_mode_border_color" toml:"resize_mode_border_color" json:"resize_mode_border_color"` //nolint:lll // struct tags must stay on one line | |
| 447 | + | // ModeBorderWidth in pixels for all mode indicator borders (pane, tab, session, resize) | |
| 448 | + | ModeBorderWidth int `mapstructure:"mode_border_width" yaml:"mode_border_width" toml:"mode_border_width" json:"mode_border_width"` //nolint:lll // struct tags must stay on one line | |
| 449 | + | ||
| 450 | + | // PaneModeColor for pane mode indicator (border and toaster). Defaults to "#4A90E2" (blue) | |
| 451 | + | PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line | |
| 452 | + | // TabModeColor for tab mode indicator (border and toaster). Defaults to "#FFA500" (orange) | |
| 453 | + | TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line | |
| 454 | + | // SessionModeColor for session mode indicator (border and toaster). Defaults to "#9B59B6" (purple) | |
| 455 | + | SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line | |
| 456 | + | // ResizeModeColor for resize mode indicator (border and toaster). Defaults to "#00D4AA" (teal) | |
| 457 | + | ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line | |
| 458 | + | ||
| 459 | + | // ModeIndicatorToasterEnabled shows a toaster notification when modal modes are active. | |
| 460 | + | // Default: true | |
| 461 | + | ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line | |
| 464 | 462 | ||
| 465 | 463 | // TransitionDuration in milliseconds for border animations | |
| 466 | 464 | 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 | 146 | if config.Workspace.Styling.BorderWidth < 0 { | |
| 147 | 147 | validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative") | |
| 148 | 148 | } | |
| 149 | - | if config.Workspace.Styling.PaneModeBorderWidth < 0 { | |
| 150 | - | validationErrors = append(validationErrors, "workspace.styling.pane_mode_border_width must be non-negative") | |
| 151 | - | } | |
| 152 | - | if config.Workspace.Styling.TabModeBorderWidth < 0 { | |
| 153 | - | validationErrors = append(validationErrors, "workspace.styling.tab_mode_border_width must be non-negative") | |
| 149 | + | if config.Workspace.Styling.ModeBorderWidth < 0 { | |
| 150 | + | validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative") | |
| 154 | 151 | } | |
| 155 | 152 | if config.Workspace.Styling.TransitionDuration < 0 { | |
| 156 | 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 | 202 | // HasOnDisk checks if a favicon exists on disk for the given domain. | |
| 176 | 203 | func (c *Cache) HasOnDisk(domain string) bool { | |
| 177 | 204 | path := c.DiskPath(domain) | |
M internal/infrastructure/favicon/service.go +36 -0
| @@ -3,10 +3,18 @@ package favicon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "strings" | |
| 6 | 7 | ||
| 8 | + | "github.com/bnema/dumber/assets" | |
| 7 | 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 | 18 | // Service implements the domain FaviconService interface. | |
| 11 | 19 | // It coordinates between the cache and fetcher components. | |
| 12 | 20 | type Service struct { | |
| @@ -30,6 +38,11 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) { | |||
| 30 | 38 | 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 | 46 | // Check cache first (memory + disk) | |
| 34 | 47 | if data, ok := s.cache.Get(domain); ok { | |
| 35 | 48 | return data, nil | |
| @@ -49,6 +62,17 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) { | |||
| 49 | 62 | 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 | 76 | // GetCached returns favicon bytes only if already cached (no external fetch). | |
| 53 | 77 | func (s *Service) GetCached(domain string) ([]byte, bool) { | |
| 54 | 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 | 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 | 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 | 39 | // Test the connection | |
| 37 | 40 | if err := db.Ping(); err != nil { | |
| 38 | 41 | _ = db.Close() | |
| @@ -59,12 +62,13 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) { | |||
| 59 | 62 | // applyPragmas configures SQLite for optimal performance. | |
| 60 | 63 | func applyPragmas(db *sql.DB) error { | |
| 61 | 64 | pragmas := []string{ | |
| 62 | - | "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access | |
| 63 | - | "PRAGMA synchronous = NORMAL", // Safe in WAL mode | |
| 64 | - | "PRAGMA cache_size = -64000", // 64MB cache | |
| 65 | - | "PRAGMA temp_store = MEMORY", // Temporary tables in RAM | |
| 66 | - | "PRAGMA mmap_size = 30000000000", // 30GB memory-mapped I/O | |
| 67 | - | "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention | |
| 65 | + | "PRAGMA journal_mode = WAL", // Write-Ahead Logging for concurrent access | |
| 66 | + | "PRAGMA synchronous = NORMAL", // Safe in WAL mode | |
| 67 | + | "PRAGMA cache_size = -64000", // 64MB cache | |
| 68 | + | "PRAGMA temp_store = MEMORY", // Temporary tables in RAM | |
| 69 | + | "PRAGMA mmap_size = 268435456", // 256MB memory-mapped I/O (reasonable for browser history) | |
| 70 | + | "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention | |
| 71 | + | "PRAGMA foreign_keys = ON", // Enable referential integrity | |
| 68 | 72 | } | |
| 69 | 73 | ||
| 70 | 74 | for _, pragma := range pragmas { | |
| @@ -76,6 +80,19 @@ func applyPragmas(db *sql.DB) error { | |||
| 76 | 80 | 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 | 96 | // Close closes the database connection gracefully. | |
| 80 | 97 | func Close(db *sql.DB) error { | |
| 81 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "sync" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 8 | "github.com/bnema/dumber/internal/logging" | |
| @@ -21,8 +20,7 @@ type WebViewFactory struct { | |||
| 21 | 20 | filterApplier FilterApplier // Optional content filter applier | |
| 22 | 21 | ||
| 23 | 22 | // Background color for WebViews (eliminates white flash) | |
| 24 | - | bgR, bgG, bgB, bgA float32 | |
| 25 | - | bgMu sync.RWMutex | |
| 23 | + | bg bgColor | |
| 26 | 24 | } | |
| 27 | 25 | ||
| 28 | 26 | // NewWebViewFactory creates a new WebViewFactory. | |
| @@ -56,9 +54,7 @@ func (f *WebViewFactory) SetFilterApplier(applier FilterApplier) { | |||
| 56 | 54 | // SetBackgroundColor sets the background color for newly created WebViews. | |
| 57 | 55 | // This color is shown before content is painted, eliminating white flash. | |
| 58 | 56 | func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) { | |
| 59 | - | f.bgMu.Lock() | |
| 60 | - | f.bgR, f.bgG, f.bgB, f.bgA = r, g, b, a | |
| 61 | - | f.bgMu.Unlock() | |
| 57 | + | f.bg.set(r, g, b, a) | |
| 62 | 58 | // Also propagate to the pool if present | |
| 63 | 59 | if f.pool != nil { | |
| 64 | 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 | 104 | // Set background color to match theme (eliminates white flash) | |
| 109 | - | f.bgMu.RLock() | |
| 110 | - | r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA | |
| 111 | - | f.bgMu.RUnlock() | |
| 112 | - | if a > 0 { | |
| 105 | + | if r, g, b, a := f.bg.get(); a > 0 { | |
| 113 | 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 | 131 | func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) { | |
| 139 | 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 | 135 | if err != nil { | |
| 143 | 136 | return nil, err | |
| 144 | 137 | } | |
| 145 | 138 | ||
| 146 | - | // Set background color to match theme (eliminates white flash) | |
| 147 | - | f.bgMu.RLock() | |
| 148 | - | r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA | |
| 149 | - | f.bgMu.RUnlock() | |
| 150 | - | if a > 0 { | |
| 151 | - | wv.SetBackgroundColor(r, g, b, a) | |
| 152 | - | } | |
| 139 | + | // Add CSS class for theme background styling (prevents white flash) | |
| 140 | + | wv.inner.AddCssClass("webview-themed") | |
| 153 | 141 | ||
| 154 | 142 | // Keep hidden until content is painted | |
| 155 | 143 | wv.inner.SetVisible(false) | |
M internal/infrastructure/webkit/injector.go +56 -5
| @@ -25,6 +25,34 @@ const themeCSSScript = `(function() { | |||
| 25 | 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 | 56 | // darkModeScript patches window.matchMedia and sets theme class on <html>. | |
| 29 | 57 | // It must be injected at document start, after __dumber_gtk_prefers_dark is set. | |
| 30 | 58 | // This script: | |
| @@ -132,6 +160,7 @@ type ContentInjector struct { | |||
| 132 | 160 | prefersDark bool | |
| 133 | 161 | themeCSSVars string // CSS custom property declarations for WebUI | |
| 134 | 162 | 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 | 166 | // NewContentInjector creates a new injector instance. | |
| @@ -171,6 +200,13 @@ func (ci *ContentInjector) SetPrefersDark(prefersDark bool) { | |||
| 171 | 200 | 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 | 210 | // InjectScripts adds the minimal dark mode detection scripts to the given content manager. | |
| 175 | 211 | // Only injects: | |
| 176 | 212 | // - window.__dumber_gtk_prefers_dark flag | |
| @@ -193,7 +229,22 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 193 | 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 | 248 | darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark) | |
| 198 | 249 | addScript( | |
| 199 | 250 | webkit.NewUserScript( | |
| @@ -206,7 +257,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 206 | 257 | "gtk-dark-mode-pref", | |
| 207 | 258 | ) | |
| 208 | 259 | ||
| 209 | - | // 2. Inject WebView ID for debugging | |
| 260 | + | // 3. Inject WebView ID for debugging | |
| 210 | 261 | if webviewID != 0 { | |
| 211 | 262 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | |
| 212 | 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 | 276 | addScript( | |
| 226 | 277 | webkit.NewUserScript( | |
| 227 | 278 | darkModeScript, | |
| @@ -233,7 +284,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 233 | 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 | 288 | if ci.themeCSSVars != "" { | |
| 238 | 289 | // Escape for JS string literal | |
| 239 | 290 | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | |
| @@ -253,7 +304,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 253 | 304 | log.Debug().Msg("theme CSS vars injection configured") | |
| 254 | 305 | } | |
| 255 | 306 | ||
| 256 | - | // 5. Inject find highlight CSS for all pages | |
| 307 | + | // 6. Inject find highlight CSS for all pages | |
| 257 | 308 | if ci.findCSS != "" { | |
| 258 | 309 | stylesheet := webkit.NewUserStyleSheet( | |
| 259 | 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 | 76 | // RegisterHandler registers a handler for a message type. | |
| 66 | 77 | func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error { | |
| 67 | 78 | if msgType == "" { | |
M internal/infrastructure/webkit/pool.go +125 -14
| @@ -2,14 +2,19 @@ package webkit | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "sync/atomic" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 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 | 18 | // FilterApplier applies content filters to a UserContentManager. | |
| 14 | 19 | // This interface decouples the pool from the filtering package. | |
| 15 | 20 | type FilterApplier interface { | |
| @@ -50,8 +55,7 @@ type WebViewPool struct { | |||
| 50 | 55 | filterApplier FilterApplier // Optional content filter applier | |
| 51 | 56 | ||
| 52 | 57 | // Background color for WebViews (eliminates white flash) | |
| 53 | - | bgR, bgG, bgB, bgA float32 | |
| 54 | - | bgMu sync.RWMutex | |
| 58 | + | bg bgColor | |
| 55 | 59 | ||
| 56 | 60 | closed atomic.Bool | |
| 57 | 61 | wg sync.WaitGroup | |
| @@ -103,9 +107,7 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) { | |||
| 103 | 107 | // SetBackgroundColor sets the background color for newly created WebViews. | |
| 104 | 108 | // This color is shown before content is painted, eliminating white flash. | |
| 105 | 109 | func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { | |
| 106 | - | p.bgMu.Lock() | |
| 107 | - | p.bgR, p.bgG, p.bgB, p.bgA = r, g, b, a | |
| 108 | - | p.bgMu.Unlock() | |
| 110 | + | p.bg.set(r, g, b, a) | |
| 109 | 111 | } | |
| 110 | 112 | ||
| 111 | 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 | 115 | log := logging.FromContext(ctx) | |
| 114 | 116 | ||
| 115 | 117 | if p.closed.Load() { | |
| 116 | - | return nil, context.Canceled | |
| 118 | + | return nil, ErrPoolClosed | |
| 117 | 119 | } | |
| 118 | 120 | ||
| 119 | 121 | // Try to get from pool first (non-blocking) | |
| 120 | 122 | select { | |
| 121 | 123 | case wv := <-p.pool: | |
| 122 | 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 | 132 | // Ensure frontend is attached even if this WebView was pooled before injection was configured. | |
| 124 | 133 | _ = wv.AttachFrontend(ctx, p.injector, p.router) | |
| 125 | 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 | 151 | func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { | |
| 143 | 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 | 155 | if err != nil { | |
| 147 | 156 | return nil, err | |
| 148 | 157 | } | |
| 149 | 158 | ||
| 150 | - | // Set background color to match theme (eliminates white flash) | |
| 151 | - | p.bgMu.RLock() | |
| 152 | - | r, g, b, a := p.bgR, p.bgG, p.bgB, p.bgA | |
| 153 | - | p.bgMu.RUnlock() | |
| 154 | - | if a > 0 { // Only set if a valid color was configured | |
| 155 | - | wv.SetBackgroundColor(r, g, b, a) | |
| 156 | - | } | |
| 159 | + | // Add CSS class for theme background styling (prevents white flash) | |
| 160 | + | wv.inner.AddCssClass("webview-themed") | |
| 157 | 161 | ||
| 158 | 162 | // Keep WebView hidden until content is painted (see content.go:onLoadCommitted) | |
| 159 | 163 | wv.inner.SetVisible(false) | |
| @@ -195,6 +199,34 @@ func (p *WebViewPool) Release(ctx context.Context, wv *WebView) { | |||
| 195 | 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 | 230 | // Prewarm creates WebViews synchronously to populate the pool. | |
| 199 | 231 | // Must be called from the GTK main thread (after GTK application is initialized). | |
| 200 | 232 | func (p *WebViewPool) Prewarm(ctx context.Context, count int) { | |
| @@ -207,6 +239,85 @@ func (p *WebViewPool) Prewarm(ctx context.Context, count int) { | |||
| 207 | 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 | 321 | // prewarmSync creates WebViews synchronously. | |
| 211 | 322 | func (p *WebViewPool) prewarmSync(ctx context.Context, count int) { | |
| 212 | 323 | log := logging.FromContext(ctx) | |
M internal/infrastructure/webkit/webview.go +18 -3
| @@ -88,6 +88,7 @@ type WebView struct { | |||
| 88 | 88 | ||
| 89 | 89 | // State (protected by mutex) | |
| 90 | 90 | destroyed atomic.Bool | |
| 91 | + | isRelated bool // true if created via NewWebViewWithRelated (shares web process with parent) | |
| 91 | 92 | uri string | |
| 92 | 93 | title string | |
| 93 | 94 | progress float64 | |
| @@ -239,7 +240,8 @@ func (a *findControllerAdapter) DisconnectSignal(id uint32) { | |||
| 239 | 240 | ||
| 240 | 241 | // NewWebView creates a new WebView with the given context and settings. | |
| 241 | 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 | 245 | log := logging.FromContext(ctx) | |
| 244 | 246 | ||
| 245 | 247 | if wkCtx == nil || !wkCtx.IsInitialized() { | |
| @@ -252,6 +254,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan | |||
| 252 | 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 | 263 | wv := &WebView{ | |
| 256 | 264 | inner: inner, | |
| 257 | 265 | ucm: inner.GetUserContentManager(), | |
| @@ -294,6 +302,7 @@ func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *Setti | |||
| 294 | 302 | ||
| 295 | 303 | wv := &WebView{ | |
| 296 | 304 | inner: inner, | |
| 305 | + | isRelated: true, // Shares web process with parent - must not terminate process on destroy | |
| 297 | 306 | ucm: inner.GetUserContentManager(), | |
| 298 | 307 | logger: log.With().Str("component", "webview-popup").Logger(), | |
| 299 | 308 | signalIDs: make([]uint32, 0, 6), | |
| @@ -981,7 +990,9 @@ func (wv *WebView) Destroy() { | |||
| 981 | 990 | ||
| 982 | 991 | // 5. Terminate the web process to free GPU resources (VA-API, DMA-BUF, GL contexts) | |
| 983 | 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 | 996 | wv.inner.TerminateWebProcess() | |
| 986 | 997 | } | |
| 987 | 998 | ||
| @@ -993,7 +1004,11 @@ func (wv *WebView) Destroy() { | |||
| 993 | 1004 | wv.ucm = nil | |
| 994 | 1005 | 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 | 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 | 1 | package xdg | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 4 | 5 | "path/filepath" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -55,4 +56,20 @@ func (a *Adapter) FilterCacheDir() (string, error) { | |||
| 55 | 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 | 75 | var _ port.XDGPaths = (*Adapter)(nil) | |
M internal/logging/logger.go +12 -3
| @@ -10,9 +10,10 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | const ( | |
| 13 | - | logFormatConsole = "console" | |
| 14 | - | logFormatJSON = "json" | |
| 15 | - | logFormatText = "text" | |
| 13 | + | logFormatConsole = "console" | |
| 14 | + | logFormatJSON = "json" | |
| 15 | + | logFormatText = "text" | |
| 16 | + | ConsoleTimeFormat = "15:04:05" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | // Config holds logging configuration | |
| @@ -146,8 +147,16 @@ func NewFromEnv() zerolog.Logger { | |||
| 146 | 147 | // This is used by main.go to create a logger from the config package's LoggingConfig | |
| 147 | 148 | // without creating an import cycle. | |
| 148 | 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 | 155 | cfg := DefaultConfig() | |
| 150 | 156 | cfg.Level = ParseLevel(level) | |
| 157 | + | if timeFormat != "" { | |
| 158 | + | cfg.TimeFormat = timeFormat | |
| 159 | + | } | |
| 151 | 160 | ||
| 152 | 161 | switch format { | |
| 153 | 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 | 48 | // GetTextureByURL returns a cached texture by extracting domain from URL. | |
| 49 | + | // For internal dumb:// URLs, returns the app logo texture. | |
| 49 | 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 | 55 | domain := domainurl.ExtractDomain(pageURL) | |
| 51 | 56 | return a.GetTexture(domain) | |
| 52 | 57 | } | |
| 53 | 58 | ||
| 54 | 59 | // GetOrFetch retrieves a favicon texture, checking caches and fetching if needed. | |
| 55 | 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 | 62 | func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) { | |
| 57 | 63 | if callback == nil { | |
| 58 | 64 | return | |
| 59 | 65 | } | |
| 60 | 66 | ||
| 61 | 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 | 76 | domain := domainurl.ExtractDomain(pageURL) | |
| 63 | 77 | ||
| 64 | 78 | // 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 | 345 | // bytesToTexture converts raw favicon bytes to a GDK texture. | |
| 315 | 346 | func bytesToTexture(data []byte) *gdk.Texture { | |
| 316 | 347 | if len(data) == 0 { | |
M internal/ui/app.go +427 -12
| @@ -87,17 +87,25 @@ type App struct { | |||
| 87 | 87 | ||
| 88 | 88 | // App-level toaster for system notifications (filter status, etc.) | |
| 89 | 89 | appToaster *component.Toaster | |
| 90 | + | // Mode indicator toaster for modal mode notifications (pane, tab, session, resize) | |
| 91 | + | modeToaster *component.Toaster | |
| 90 | 92 | ||
| 91 | 93 | // Session management | |
| 92 | 94 | sessionManager *component.SessionManager | |
| 95 | + | tabPicker *component.TabPicker | |
| 96 | + | tabPickerWidget layout.Widget | |
| 97 | + | tabPickerPaneID entity.PaneID | |
| 93 | 98 | snapshotService *snapshot.Service | |
| 94 | 99 | ||
| 95 | 100 | // Update management | |
| 96 | 101 | updateCoord *coordinator.UpdateCoordinator | |
| 97 | 102 | ||
| 98 | 103 | // ID generator for tabs/panes | |
| 99 | - | idCounter uint64 | |
| 100 | - | idMu sync.Mutex | |
| 104 | + | idCounter uint64 | |
| 105 | + | idMu sync.Mutex | |
| 106 | + | firstWebViewShownOnce sync.Once | |
| 107 | + | ||
| 108 | + | movePaneToTabUC *usecase.MovePaneToTabUseCase | |
| 101 | 109 | ||
| 102 | 110 | // lifecycle | |
| 103 | 111 | cancel context.CancelCauseFunc | |
| @@ -179,7 +187,9 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 179 | 187 | log.Debug().Msg("GTK application activated") | |
| 180 | 188 | ||
| 181 | 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 | 194 | if err := a.createMainWindow(ctx); err != nil { | |
| 185 | 195 | log.Error().Err(err).Msg("failed to create main window") | |
| @@ -195,9 +205,12 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 195 | 205 | a.initOmniboxConfig(ctx) | |
| 196 | 206 | a.initFindBarConfig() | |
| 197 | 207 | a.initSessionManager(ctx) | |
| 208 | + | a.initTabPicker(ctx) | |
| 198 | 209 | a.initSnapshotService(ctx) | |
| 199 | 210 | a.initUpdateCoordinator(ctx) | |
| 200 | 211 | a.createInitialTab(ctx) | |
| 212 | + | // Prewarm the remaining WebViews after the initial tab starts loading. | |
| 213 | + | a.prewarmWebViewPoolAsync(ctx) | |
| 201 | 214 | a.finalizeActivation(ctx) | |
| 202 | 215 | } | |
| 203 | 216 | ||
| @@ -223,19 +236,31 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) { | |||
| 223 | 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 | 242 | // Set theme background color on pool to eliminate white flash. | |
| 228 | - | // Must be done before prewarming so WebViews get the correct color. | |
| 229 | - | if a.pool != nil && a.deps != nil && a.deps.Theme != nil { | |
| 230 | - | r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA() | |
| 231 | - | a.pool.SetBackgroundColor(r, g, b, alpha) | |
| 243 | + | // Must be done before any WebView creation so WebViews get the correct color. | |
| 244 | + | if a.pool == nil { | |
| 245 | + | log.Debug().Msg("webview pool not available; skipping background color setup") | |
| 246 | + | return | |
| 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 | 260 | if a.pool == nil { | |
| 236 | 261 | return | |
| 237 | 262 | } | |
| 238 | - | a.pool.Prewarm(ctx, 0) | |
| 263 | + | a.pool.PrewarmAsync(ctx, 0) | |
| 239 | 264 | } | |
| 240 | 265 | ||
| 241 | 266 | func (a *App) createMainWindow(ctx context.Context) error { | |
| @@ -279,6 +304,18 @@ func (a *App) initAppToasterOverlay() { | |||
| 279 | 304 | return | |
| 280 | 305 | } | |
| 281 | 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 | 321 | func (a *App) initFocusAndBorderOverlay() { | |
| @@ -317,10 +354,13 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 317 | 354 | a.handleModeChange(ctx, from, to) | |
| 318 | 355 | }) | |
| 319 | 356 | a.keyboardHandler.SetShouldBypassInput(func() bool { | |
| 320 | - | // Bypass keyboard handler when session manager is visible | |
| 357 | + | // Bypass keyboard handler when modals are visible | |
| 321 | 358 | if a.sessionManager != nil && a.sessionManager.IsVisible() { | |
| 322 | 359 | return true | |
| 323 | 360 | } | |
| 361 | + | if a.tabPicker != nil && a.tabPicker.IsVisible() { | |
| 362 | + | return true | |
| 363 | + | } | |
| 324 | 364 | wsView := a.activeWorkspaceView() | |
| 325 | 365 | if wsView == nil { | |
| 326 | 366 | return false | |
| @@ -349,13 +389,23 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 349 | 389 | ||
| 350 | 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 | 402 | // Store omnibox config (omnibox is created per-pane via WorkspaceView). | |
| 353 | 403 | a.omniboxCfg = component.OmniboxConfig{ | |
| 354 | 404 | HistoryUC: a.deps.HistoryUC, | |
| 355 | 405 | FavoritesUC: a.deps.FavoritesUC, | |
| 356 | 406 | FaviconAdapter: a.faviconAdapter, | |
| 357 | 407 | CopyURLUC: a.deps.CopyURLUC, | |
| 358 | - | Shortcuts: a.deps.Config.SearchShortcuts, | |
| 408 | + | ShortcutsUC: shortcutsUC, | |
| 359 | 409 | DefaultSearch: a.deps.Config.DefaultSearchEngine, | |
| 360 | 410 | InitialBehavior: a.deps.Config.Omnibox.InitialBehavior, | |
| 361 | 411 | UIScale: a.deps.Config.DefaultUIScale, | |
| @@ -464,6 +514,280 @@ func (a *App) ToggleSessionManager(ctx context.Context) { | |||
| 464 | 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 | 791 | func (a *App) initSnapshotService(ctx context.Context) { | |
| 468 | 792 | log := logging.FromContext(ctx) | |
| 469 | 793 | ||
| @@ -630,6 +954,9 @@ func (a *App) finalizeActivation(ctx context.Context) { | |||
| 630 | 954 | ||
| 631 | 955 | // Start async filter loading after window is visible. | |
| 632 | 956 | a.initFilteringAsync(ctx) | |
| 957 | + | ||
| 958 | + | // Check for config migration after window is visible. | |
| 959 | + | a.checkConfigMigration(ctx) | |
| 633 | 960 | } | |
| 634 | 961 | ||
| 635 | 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 | 1076 | // 3. Workspace Coordinator | |
| 736 | 1077 | a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{ | |
| 737 | 1078 | PanesUC: a.panesUC, | |
| @@ -782,6 +1123,9 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 782 | 1123 | return nil | |
| 783 | 1124 | }) | |
| 784 | 1125 | ||
| 1126 | + | // Move pane use case (cross-tab) | |
| 1127 | + | a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID) | |
| 1128 | + | ||
| 785 | 1129 | // 4. Navigation Coordinator | |
| 786 | 1130 | a.navCoord = coordinator.NewNavigationCoordinator( | |
| 787 | 1131 | ctx, | |
| @@ -811,6 +1155,25 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 811 | 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 | 1177 | // 5. Keyboard Dispatcher | |
| 815 | 1178 | a.kbDispatcher = dispatcher.NewKeyboardDispatcher( | |
| 816 | 1179 | ctx, | |
| @@ -837,6 +1200,12 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 837 | 1200 | a.CloseFindBar(ctx) | |
| 838 | 1201 | 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 | 1210 | // Wire gesture handler to dispatcher (for mouse button 8/9 navigation) | |
| 842 | 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 | 1294 | if a.borderMgr != nil { | |
| 926 | 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 | 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 | 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 | 83 | // CalculateModalDimensions computes width and top margin based on parent overlay. | |
| 69 | 84 | // Returns calculated width and top margin in pixels. | |
| 70 | 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 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 12 | "github.com/bnema/dumber/internal/domain/url" | |
| 13 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 14 | 13 | "github.com/bnema/dumber/internal/logging" | |
| 15 | 14 | "github.com/bnema/dumber/internal/ui/adapter" | |
| 16 | 15 | "github.com/bnema/dumber/internal/ui/input" | |
| @@ -22,6 +21,7 @@ import ( | |||
| 22 | 21 | ||
| 23 | 22 | const ( | |
| 24 | 23 | debounceDelayMs = 150 | |
| 24 | + | endBoxSpacing = 6 | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | 27 | // ViewMode distinguishes history search from favorites display. | |
| @@ -37,6 +37,14 @@ type Suggestion struct { | |||
| 37 | 37 | URL string | |
| 38 | 38 | Title string | |
| 39 | 39 | 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 | 50 | // Favorite represents a bookmarked URL. | |
| @@ -57,6 +65,7 @@ type Omnibox struct { | |||
| 57 | 65 | historyBtn *gtk.Button | |
| 58 | 66 | favoritesBtn *gtk.Button | |
| 59 | 67 | zoomLabel *gtk.Label | |
| 68 | + | bangBadge *gtk.Label | |
| 60 | 69 | entry *gtk.SearchEntry | |
| 61 | 70 | scrolledWin *gtk.ScrolledWindow | |
| 62 | 71 | listBox *gtk.ListBox | |
| @@ -65,20 +74,23 @@ type Omnibox struct { | |||
| 65 | 74 | parentOverlay layout.OverlayWidget | |
| 66 | 75 | ||
| 67 | 76 | // State | |
| 68 | - | mu sync.RWMutex | |
| 69 | - | visible bool | |
| 70 | - | viewMode ViewMode | |
| 71 | - | selectedIndex int | |
| 72 | - | suggestions []Suggestion | |
| 73 | - | favorites []Favorite | |
| 74 | - | hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite) | |
| 77 | + | mu sync.RWMutex | |
| 78 | + | visible bool | |
| 79 | + | viewMode ViewMode | |
| 80 | + | selectedIndex int | |
| 81 | + | suggestions []Suggestion | |
| 82 | + | favorites []Favorite | |
| 83 | + | bangSuggestions []BangSuggestion | |
| 84 | + | bangMode bool | |
| 85 | + | detectedBang string | |
| 86 | + | hasNavigated bool // true if user navigated with arrow keys (enables space to toggle favorite) | |
| 75 | 87 | ||
| 76 | 88 | // Dependencies | |
| 77 | 89 | historyUC *usecase.SearchHistoryUseCase | |
| 78 | 90 | favoritesUC *usecase.ManageFavoritesUseCase | |
| 79 | 91 | faviconAdapter *adapter.FaviconAdapter | |
| 80 | 92 | copyURLUC *usecase.CopyURLUseCase | |
| 81 | - | shortcuts map[string]config.SearchShortcut | |
| 93 | + | shortcutsUC *usecase.SearchShortcutsUseCase | |
| 82 | 94 | defaultSearch string | |
| 83 | 95 | initialBehavior string | |
| 84 | 96 | ctx context.Context | |
| @@ -86,7 +98,7 @@ type Omnibox struct { | |||
| 86 | 98 | // Callbacks | |
| 87 | 99 | onNavigate func(url string) | |
| 88 | 100 | onClose func() | |
| 89 | - | onToast func(message string) | |
| 101 | + | onToast func(ctx context.Context, message string, level ToastLevel) | |
| 90 | 102 | ||
| 91 | 103 | // Debouncing | |
| 92 | 104 | debounceTimer *time.Timer | |
| @@ -114,12 +126,12 @@ type OmniboxConfig struct { | |||
| 114 | 126 | FavoritesUC *usecase.ManageFavoritesUseCase | |
| 115 | 127 | FaviconAdapter *adapter.FaviconAdapter | |
| 116 | 128 | CopyURLUC *usecase.CopyURLUseCase | |
| 117 | - | Shortcuts map[string]config.SearchShortcut | |
| 129 | + | ShortcutsUC *usecase.SearchShortcutsUseCase | |
| 118 | 130 | DefaultSearch string | |
| 119 | 131 | InitialBehavior string | |
| 120 | - | UIScale float64 // UI scale for favicon sizing | |
| 121 | - | OnNavigate func(url string) // Callback when user navigates via omnibox | |
| 122 | - | OnToast func(message string) // Callback to show toast notification | |
| 132 | + | UIScale float64 // UI scale for favicon sizing | |
| 133 | + | OnNavigate func(url string) // Callback when user navigates via omnibox | |
| 134 | + | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification | |
| 123 | 135 | } | |
| 124 | 136 | ||
| 125 | 137 | // NewOmnibox creates a new native GTK4 omnibox widget. | |
| @@ -139,7 +151,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 139 | 151 | favoritesUC: cfg.FavoritesUC, | |
| 140 | 152 | faviconAdapter: cfg.FaviconAdapter, | |
| 141 | 153 | copyURLUC: cfg.CopyURLUC, | |
| 142 | - | shortcuts: cfg.Shortcuts, | |
| 154 | + | shortcutsUC: cfg.ShortcutsUC, | |
| 143 | 155 | defaultSearch: cfg.DefaultSearch, | |
| 144 | 156 | initialBehavior: cfg.InitialBehavior, | |
| 145 | 157 | onToast: cfg.OnToast, | |
| @@ -350,12 +362,16 @@ func (o *Omnibox) initHeader() error { | |||
| 350 | 362 | o.retainedCallbacks = append(o.retainedCallbacks, favoritesClickCb) | |
| 351 | 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 | 371 | o.zoomLabel = gtk.NewLabel(nil) | |
| 354 | 372 | if o.zoomLabel != nil { | |
| 355 | 373 | o.zoomLabel.AddCssClass("omnibox-zoom-indicator") | |
| 356 | 374 | o.zoomLabel.SetVisible(false) | |
| 357 | - | o.zoomLabel.SetHalign(gtk.AlignEndValue) | |
| 358 | - | o.zoomLabel.SetHexpand(true) | |
| 359 | 375 | } | |
| 360 | 376 | return nil | |
| 361 | 377 | } | |
| @@ -368,7 +384,7 @@ func (o *Omnibox) initEntry() error { | |||
| 368 | 384 | o.entry.AddCssClass("omnibox-entry") | |
| 369 | 385 | o.entry.SetHexpand(true) | |
| 370 | 386 | ||
| 371 | - | placeholder := "Search history or enter URL..." | |
| 387 | + | placeholder := "Search history or enter URL… (! lists bangs)" | |
| 372 | 388 | o.entry.SetPlaceholderText(&placeholder) | |
| 373 | 389 | return nil | |
| 374 | 390 | } | |
| @@ -412,8 +428,18 @@ func (o *Omnibox) initList() error { | |||
| 412 | 428 | func (o *Omnibox) assembleWidgets() { | |
| 413 | 429 | o.headerBox.Append(&o.historyBtn.Widget) | |
| 414 | 430 | o.headerBox.Append(&o.favoritesBtn.Widget) | |
| 415 | - | if o.zoomLabel != nil { | |
| 416 | - | o.headerBox.Append(&o.zoomLabel.Widget) | |
| 431 | + | ||
| 432 | + | endBox := gtk.NewBox(gtk.OrientationHorizontalValue, endBoxSpacing) | |
| 433 | + | if endBox != nil { | |
| 434 | + | endBox.SetHexpand(true) | |
| 435 | + | endBox.SetHalign(gtk.AlignEndValue) | |
| 436 | + | if o.bangBadge != nil { | |
| 437 | + | endBox.Append(&o.bangBadge.Widget) | |
| 438 | + | } | |
| 439 | + | if o.zoomLabel != nil { | |
| 440 | + | endBox.Append(&o.zoomLabel.Widget) | |
| 441 | + | } | |
| 442 | + | o.headerBox.Append(&endBox.Widget) | |
| 417 | 443 | } | |
| 418 | 444 | ||
| 419 | 445 | o.scrolledWin.SetChild(&o.listBox.Widget) | |
| @@ -577,6 +603,14 @@ func (o *Omnibox) performSearch() { | |||
| 577 | 603 | o.lastQuery = query | |
| 578 | 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 | 614 | if mode == ViewModeFavorites { | |
| 581 | 615 | o.loadFavorites(query) | |
| 582 | 616 | return | |
| @@ -596,21 +630,46 @@ func (o *Omnibox) performSearch() { | |||
| 596 | 630 | return | |
| 597 | 631 | } | |
| 598 | 632 | ||
| 599 | - | searchInput := usecase.SearchInput{ | |
| 600 | - | Query: query, | |
| 601 | - | Limit: OmniboxListDefaults.MaxResults, | |
| 633 | + | // Run history search and favorite URL fetch in parallel | |
| 634 | + | type searchResult struct { | |
| 635 | + | output *usecase.SearchOutput | |
| 636 | + | err error | |
| 602 | 637 | } | |
| 603 | - | output, err := o.historyUC.Search(ctx, searchInput) | |
| 604 | - | if err != nil { | |
| 605 | - | log.Error().Err(err).Msg("history search failed") | |
| 638 | + | searchCh := make(chan searchResult, 1) | |
| 639 | + | favCh := make(chan map[string]struct{}, 1) | |
| 640 | + | ||
| 641 | + | go func() { | |
| 642 | + | searchInput := usecase.SearchInput{ | |
| 643 | + | Query: query, | |
| 644 | + | Limit: OmniboxListDefaults.MaxResults, | |
| 645 | + | } | |
| 646 | + | output, err := o.historyUC.Search(ctx, searchInput) | |
| 647 | + | searchCh <- searchResult{output, err} | |
| 648 | + | }() | |
| 649 | + | ||
| 650 | + | go func() { | |
| 651 | + | favCh <- o.getFavoriteURLs(ctx) | |
| 652 | + | }() | |
| 653 | + | ||
| 654 | + | // Wait for both results | |
| 655 | + | sr := <-searchCh | |
| 656 | + | favoriteURLs := <-favCh | |
| 657 | + | ||
| 658 | + | if sr.err != nil { | |
| 659 | + | log.Error().Err(sr.err).Msg("history search failed") | |
| 660 | + | return | |
| 661 | + | } | |
| 662 | + | if sr.output == nil { | |
| 606 | 663 | return | |
| 607 | 664 | } | |
| 608 | 665 | ||
| 609 | - | suggestions := make([]Suggestion, 0, len(output.Matches)) | |
| 610 | - | for _, r := range output.Matches { | |
| 666 | + | suggestions := make([]Suggestion, 0, len(sr.output.Matches)) | |
| 667 | + | for _, r := range sr.output.Matches { | |
| 668 | + | _, isFav := favoriteURLs[r.Entry.URL] | |
| 611 | 669 | suggestions = append(suggestions, Suggestion{ | |
| 612 | - | URL: r.Entry.URL, | |
| 613 | - | Title: r.Entry.Title, | |
| 670 | + | URL: r.Entry.URL, | |
| 671 | + | Title: r.Entry.Title, | |
| 672 | + | IsFavorite: isFav, | |
| 614 | 673 | }) | |
| 615 | 674 | } | |
| 616 | 675 | ||
| @@ -636,17 +695,43 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 636 | 695 | return | |
| 637 | 696 | ||
| 638 | 697 | case "most_visited", "recent", "": | |
| 639 | - | // TODO: Implement GetMostVisited in use case if needed | |
| 640 | - | results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0) | |
| 641 | - | if err != nil { | |
| 642 | - | log.Error().Err(err).Msg("failed to load recent history") | |
| 698 | + | // Run history fetch and favorite URL fetch in parallel | |
| 699 | + | type historyResult struct { | |
| 700 | + | results []*entity.HistoryEntry | |
| 701 | + | err error | |
| 702 | + | } | |
| 703 | + | historyCh := make(chan historyResult, 1) | |
| 704 | + | favCh := make(chan map[string]struct{}, 1) | |
| 705 | + | ||
| 706 | + | go func() { | |
| 707 | + | // TODO: Implement GetMostVisited in use case if needed | |
| 708 | + | results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0) | |
| 709 | + | historyCh <- historyResult{results, err} | |
| 710 | + | }() | |
| 711 | + | ||
| 712 | + | go func() { | |
| 713 | + | favCh <- o.getFavoriteURLs(ctx) | |
| 714 | + | }() | |
| 715 | + | ||
| 716 | + | // Wait for both results | |
| 717 | + | hr := <-historyCh | |
| 718 | + | favoriteURLs := <-favCh | |
| 719 | + | ||
| 720 | + | if hr.err != nil { | |
| 721 | + | log.Error().Err(hr.err).Msg("failed to load recent history") | |
| 643 | 722 | return | |
| 644 | 723 | } | |
| 645 | - | suggestions = make([]Suggestion, 0, len(results)) | |
| 646 | - | for _, r := range results { | |
| 724 | + | if hr.results == nil { | |
| 725 | + | return | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | suggestions = make([]Suggestion, 0, len(hr.results)) | |
| 729 | + | for _, r := range hr.results { | |
| 730 | + | _, isFav := favoriteURLs[r.URL] | |
| 647 | 731 | suggestions = append(suggestions, Suggestion{ | |
| 648 | - | URL: r.URL, | |
| 649 | - | Title: r.Title, | |
| 732 | + | URL: r.URL, | |
| 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 | 878 | // updateSuggestions updates the list with history suggestions. | |
| 697 | 879 | func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | |
| 698 | 880 | o.mu.Lock() | |
| 881 | + | o.bangMode = false | |
| 882 | + | o.bangSuggestions = nil | |
| 699 | 883 | o.suggestions = suggestions | |
| 700 | 884 | o.selectedIndex = -1 | |
| 701 | 885 | o.mu.Unlock() | |
| @@ -718,6 +902,8 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion) { | |||
| 718 | 902 | // updateFavorites updates the list with favorites. | |
| 719 | 903 | func (o *Omnibox) updateFavorites(favorites []Favorite) { | |
| 720 | 904 | o.mu.Lock() | |
| 905 | + | o.bangMode = false | |
| 906 | + | o.bangSuggestions = nil | |
| 721 | 907 | o.favorites = favorites | |
| 722 | 908 | o.selectedIndex = -1 | |
| 723 | 909 | o.mu.Unlock() | |
| @@ -746,9 +932,18 @@ func (o *Omnibox) rebuildList() { | |||
| 746 | 932 | mode := o.viewMode | |
| 747 | 933 | suggestions := o.suggestions | |
| 748 | 934 | favorites := o.favorites | |
| 935 | + | bangMode := o.bangMode | |
| 936 | + | bangSuggestions := o.bangSuggestions | |
| 749 | 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 | 947 | for i, s := range suggestions { | |
| 753 | 948 | row := o.createSuggestionRow(s, i) | |
| 754 | 949 | if row != nil { | |
| @@ -779,7 +974,9 @@ func (o *Omnibox) rebuildList() { | |||
| 779 | 974 | // Re-trigger resize with accurate measurements | |
| 780 | 975 | o.mu.RLock() | |
| 781 | 976 | var count int | |
| 782 | - | if o.viewMode == ViewModeHistory { | |
| 977 | + | if o.bangMode { | |
| 978 | + | count = len(o.bangSuggestions) | |
| 979 | + | } else if o.viewMode == ViewModeHistory { | |
| 783 | 980 | count = len(o.suggestions) | |
| 784 | 981 | } else { | |
| 785 | 982 | count = len(o.favorites) | |
| @@ -820,7 +1017,14 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image { | |||
| 820 | 1017 | } | |
| 821 | 1018 | ||
| 822 | 1019 | // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge. | |
| 1020 | + | // Uses rawURL for both favicon fetching and display. | |
| 823 | 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 | 1028 | row := gtk.NewListBoxRow() | |
| 825 | 1029 | if row == nil { | |
| 826 | 1030 | return nil | |
| @@ -835,7 +1039,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index | |||
| 835 | 1039 | hbox.SetHexpand(true) | |
| 836 | 1040 | ||
| 837 | 1041 | // Favicon image (vertically centered) | |
| 838 | - | if favicon := o.createFaviconImage(rawURL, fallbackIcon); favicon != nil { | |
| 1042 | + | if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil { | |
| 839 | 1043 | favicon.SetValign(gtk.AlignCenterValue) | |
| 840 | 1044 | hbox.Append(&favicon.Widget) | |
| 841 | 1045 | } | |
| @@ -851,7 +1055,7 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index | |||
| 851 | 1055 | // Title label (or URL if no title) | |
| 852 | 1056 | displayTitle := title | |
| 853 | 1057 | if displayTitle == "" { | |
| 854 | - | displayTitle = rawURL | |
| 1058 | + | displayTitle = displayURL | |
| 855 | 1059 | } | |
| 856 | 1060 | titleLabel := gtk.NewLabel(nil) | |
| 857 | 1061 | if titleLabel != nil { | |
| @@ -863,10 +1067,10 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index | |||
| 863 | 1067 | } | |
| 864 | 1068 | ||
| 865 | 1069 | // URL label (only if title exists and differs from URL) | |
| 866 | - | if title != "" && title != rawURL { | |
| 1070 | + | if title != "" && title != displayURL { | |
| 867 | 1071 | urlLabel := gtk.NewLabel(nil) | |
| 868 | 1072 | if urlLabel != nil { | |
| 869 | - | urlLabel.SetText(rawURL) | |
| 1073 | + | urlLabel.SetText(displayURL) | |
| 870 | 1074 | urlLabel.AddCssClass("omnibox-suggestion-url") | |
| 871 | 1075 | urlLabel.SetHalign(gtk.AlignStartValue) | |
| 872 | 1076 | urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | |
| @@ -898,7 +1102,11 @@ func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index | |||
| 898 | 1102 | ||
| 899 | 1103 | // createSuggestionRow creates a ListBoxRow for a suggestion. | |
| 900 | 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 | 1112 | // createFavoriteRow creates a ListBoxRow for a favorite. | |
| @@ -906,6 +1114,16 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow { | |||
| 906 | 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 | 1127 | // selectIndex selects a row by index. | |
| 910 | 1128 | func (o *Omnibox) selectIndex(index int) { | |
| 911 | 1129 | // Note: Don't hold mutex when calling SelectRow - it triggers rowSelectedCb | |
| @@ -922,8 +1140,11 @@ func (o *Omnibox) selectNext() { | |||
| 922 | 1140 | o.mu.Lock() | |
| 923 | 1141 | current := o.selectedIndex | |
| 924 | 1142 | mode := o.viewMode | |
| 1143 | + | bangMode := o.bangMode | |
| 925 | 1144 | var maxIndex int | |
| 926 | - | if mode == ViewModeHistory { | |
| 1145 | + | if bangMode { | |
| 1146 | + | maxIndex = len(o.bangSuggestions) - 1 | |
| 1147 | + | } else if mode == ViewModeHistory { | |
| 927 | 1148 | maxIndex = len(o.suggestions) - 1 | |
| 928 | 1149 | } else { | |
| 929 | 1150 | maxIndex = len(o.favorites) - 1 | |
| @@ -947,8 +1168,11 @@ func (o *Omnibox) selectPrevious() { | |||
| 947 | 1168 | o.mu.Lock() | |
| 948 | 1169 | current := o.selectedIndex | |
| 949 | 1170 | mode := o.viewMode | |
| 1171 | + | bangMode := o.bangMode | |
| 950 | 1172 | var maxIndex int | |
| 951 | - | if mode == ViewModeHistory { | |
| 1173 | + | if bangMode { | |
| 1174 | + | maxIndex = len(o.bangSuggestions) - 1 | |
| 1175 | + | } else if mode == ViewModeHistory { | |
| 952 | 1176 | maxIndex = len(o.suggestions) - 1 | |
| 953 | 1177 | } else { | |
| 954 | 1178 | maxIndex = len(o.favorites) - 1 | |
| @@ -981,9 +1205,37 @@ func (o *Omnibox) navigateToSelected() { | |||
| 981 | 1205 | idx := o.selectedIndex | |
| 982 | 1206 | suggestions := o.suggestions | |
| 983 | 1207 | favorites := o.favorites | |
| 1208 | + | bangMode := o.bangMode | |
| 1209 | + | bangSuggestions := o.bangSuggestions | |
| 984 | 1210 | o.mu.RUnlock() | |
| 985 | 1211 | ||
| 986 | 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 | 1239 | var targetURL string | |
| 988 | 1240 | ||
| 989 | 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 | 1293 | if mode == ViewModeHistory { | |
| 1042 | - | // Add to favorites | |
| 1294 | + | // Toggle favorite in history mode | |
| 1043 | 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 | 1297 | return | |
| 1046 | 1298 | } | |
| 1047 | 1299 | ||
| 1048 | 1300 | s := suggestions[idx] | |
| 1049 | 1301 | if s.URL == "" { | |
| 1050 | - | log.Debug().Msg("add favorite: empty URL") | |
| 1302 | + | log.Debug().Msg("toggle favorite: empty URL") | |
| 1051 | 1303 | return | |
| 1052 | 1304 | } | |
| 1053 | 1305 | ||
| @@ -1055,20 +1307,39 @@ func (o *Omnibox) toggleFavorite() { | |||
| 1055 | 1307 | ctx := o.ctx | |
| 1056 | 1308 | goLog := logging.FromContext(ctx) | |
| 1057 | 1309 | ||
| 1058 | - | goLog.Debug().Str("url", s.URL).Str("title", s.Title).Msg("adding to favorites") | |
| 1059 | - | ||
| 1060 | - | addInput := usecase.AddFavoriteInput{ | |
| 1061 | - | URL: s.URL, | |
| 1062 | - | Title: s.Title, | |
| 1063 | - | } | |
| 1064 | - | ||
| 1065 | - | fav, err := o.favoritesUC.Add(ctx, addInput) | |
| 1310 | + | result, err := o.favoritesUC.Toggle(ctx, s.URL, s.Title) | |
| 1066 | 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 | 1324 | 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 | 1344 | } else { | |
| 1074 | 1345 | // Remove from favorites | |
| @@ -1092,17 +1363,46 @@ func (o *Omnibox) toggleFavorite() { | |||
| 1092 | 1363 | err := o.favoritesUC.Remove(ctx, entity.FavoriteID(f.ID)) | |
| 1093 | 1364 | if err != nil { | |
| 1094 | 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 | 1374 | return | |
| 1096 | 1375 | } | |
| 1097 | 1376 | ||
| 1098 | 1377 | log.Info().Int64("id", f.ID).Str("url", f.URL).Msg("favorite removed from omnibox") | |
| 1099 | 1378 | ||
| 1100 | - | // Refresh favorites list | |
| 1379 | + | // Refresh favorites list and show toast | |
| 1101 | 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 | 1406 | // yankSelectedURL copies the URL of the selected item to clipboard. | |
| 1107 | 1407 | func (o *Omnibox) yankSelectedURL() { | |
| 1108 | 1408 | log := logging.FromContext(o.ctx) | |
| @@ -1148,7 +1448,7 @@ func (o *Omnibox) yankSelectedURL() { | |||
| 1148 | 1448 | // Show toast notification on success (must run on GTK main thread) | |
| 1149 | 1449 | if o.onToast != nil { | |
| 1150 | 1450 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1151 | - | o.onToast("URL copied") | |
| 1451 | + | o.onToast(ctx, "URL copied", ToastSuccess) | |
| 1152 | 1452 | return false // Don't repeat | |
| 1153 | 1453 | }) | |
| 1154 | 1454 | glib.IdleAdd(&cb, 0) | |
| @@ -1158,16 +1458,11 @@ func (o *Omnibox) yankSelectedURL() { | |||
| 1158 | 1458 | ||
| 1159 | 1459 | // buildURL constructs a URL from text, handling search shortcuts. | |
| 1160 | 1460 | func (o *Omnibox) buildURL(text string) string { | |
| 1161 | - | return url.BuildSearchURL(text, o.shortcutURLs(), o.defaultSearch) | |
| 1162 | - | } | |
| 1163 | - | ||
| 1164 | - | // shortcutURLs returns a map of shortcut keys to URL templates. | |
| 1165 | - | func (o *Omnibox) shortcutURLs() map[string]string { | |
| 1166 | - | result := make(map[string]string, len(o.shortcuts)) | |
| 1167 | - | for key, shortcut := range o.shortcuts { | |
| 1168 | - | result[key] = shortcut.URL | |
| 1461 | + | var shortcutURLs map[string]string | |
| 1462 | + | if o.shortcutsUC != nil { | |
| 1463 | + | shortcutURLs = o.shortcutsUC.ShortcutURLs() | |
| 1169 | 1464 | } | |
| 1170 | - | return result | |
| 1465 | + | return url.BuildSearchURL(text, shortcutURLs, o.defaultSearch) | |
| 1171 | 1466 | } | |
| 1172 | 1467 | ||
| 1173 | 1468 | // toggleViewMode switches between history and favorites. | |
| @@ -1274,6 +1569,7 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||
| 1274 | 1569 | o.outerBox.SetVisible(false) | |
| 1275 | 1570 | ||
| 1276 | 1571 | // Clear state | |
| 1572 | + | o.clearBangState() | |
| 1277 | 1573 | o.entry.SetText("") | |
| 1278 | 1574 | o.listBox.RemoveAll() | |
| 1279 | 1575 | ||
| @@ -1340,6 +1636,21 @@ func (o *Omnibox) idleAddUpdateSuggestions(suggestions []Suggestion) { | |||
| 1340 | 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 | 1654 | // idleAddUpdateFavorites schedules updateFavorites on the GTK main thread. | |
| 1344 | 1655 | func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) { | |
| 1345 | 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 | 25 | progressBar *ProgressBar // Loading progress indicator | |
| 26 | 26 | toaster *Toaster // Toast notification overlay | |
| 27 | 27 | linkStatus *LinkStatusOverlay // Link hover URL overlay | |
| 28 | + | loading *LoadingSkeleton // Placeholder shown until WebView paints | |
| 28 | 29 | paneID entity.PaneID | |
| 29 | 30 | isActive bool | |
| 30 | 31 | ||
| @@ -51,6 +52,15 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg | |||
| 51 | 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 | 64 | // Create border overlay box (used for active pane indication) | |
| 55 | 65 | // This is an empty box that gets styled via CSS to show a border | |
| 56 | 66 | borderBox := factory.NewBox(layout.OrientationVertical, 0) | |
| @@ -75,6 +85,7 @@ func NewPaneView(factory layout.WidgetFactory, paneID entity.PaneID, webViewWidg | |||
| 75 | 85 | webViewWidget: webViewWidget, | |
| 76 | 86 | borderBox: borderBox, | |
| 77 | 87 | progressBar: nil, // Created lazily in ensureProgressBar() | |
| 88 | + | loading: loading, | |
| 78 | 89 | paneID: paneID, | |
| 79 | 90 | isActive: false, | |
| 80 | 91 | } | |
| @@ -133,13 +144,28 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) { | |||
| 133 | 144 | pv.webViewWidget = widget | |
| 134 | 145 | ||
| 135 | 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 | 156 | // Unparent widget from any previous parent (critical for rebuild scenarios) | |
| 137 | 157 | // In GTK4, a widget can only have one parent at a time | |
| 138 | - | if widget.GetParent() != nil { | |
| 158 | + | if hadParent { | |
| 139 | 159 | widget.Unparent() | |
| 140 | 160 | } | |
| 141 | - | widget.SetVisible(true) | |
| 142 | 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 | 356 | // ensureToaster creates the toaster lazily on first use. | |
| 320 | 357 | // Must be called with write lock held. | |
| 321 | 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 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/mock" | |
| 7 | 8 | "github.com/stretchr/testify/require" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -19,6 +20,11 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) { | |||
| 19 | 20 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 20 | 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 | 28 | paneID := entity.PaneID("pane-1") | |
| 23 | 29 | ||
| 24 | 30 | mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once() | |
| @@ -28,6 +34,8 @@ func TestNewPaneView_CreatesOverlay(t *testing.T) { | |||
| 28 | 34 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 29 | 35 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | |
| 30 | 36 | ||
| 37 | + | setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) | |
| 38 | + | ||
| 31 | 39 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 32 | 40 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | |
| 33 | 41 | mockBorderBox.EXPECT().SetCanTarget(false).Once() | |
| @@ -53,6 +61,11 @@ func TestNewPaneView_NilWebView(t *testing.T) { | |||
| 53 | 61 | mockOverlay := mocks.NewMockOverlayWidget(t) | |
| 54 | 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 | 69 | paneID := entity.PaneID("pane-1") | |
| 57 | 70 | ||
| 58 | 71 | mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once() | |
| @@ -62,6 +75,8 @@ func TestNewPaneView_NilWebView(t *testing.T) { | |||
| 62 | 75 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 63 | 76 | // SetChild should NOT be called when webview is nil | |
| 64 | 77 | ||
| 78 | + | setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) | |
| 79 | + | ||
| 65 | 80 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 66 | 81 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | |
| 67 | 82 | mockBorderBox.EXPECT().SetCanTarget(false).Once() | |
| @@ -87,7 +102,7 @@ func TestSetActive_True_AddsCSSClass(t *testing.T) { | |||
| 87 | 102 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 88 | 103 | mockWebView := mocks.NewMockWidget(t) | |
| 89 | 104 | ||
| 90 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 105 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 91 | 106 | ||
| 92 | 107 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 93 | 108 | ||
| @@ -108,7 +123,7 @@ func TestSetActive_False_RemovesCSSClass(t *testing.T) { | |||
| 108 | 123 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 109 | 124 | mockWebView := mocks.NewMockWidget(t) | |
| 110 | 125 | ||
| 111 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 126 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 112 | 127 | ||
| 113 | 128 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 114 | 129 | ||
| @@ -133,7 +148,7 @@ func TestSetActive_NoChangeWhenSameState(t *testing.T) { | |||
| 133 | 148 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 134 | 149 | mockWebView := mocks.NewMockWidget(t) | |
| 135 | 150 | ||
| 136 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 151 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 137 | 152 | ||
| 138 | 153 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 139 | 154 | ||
| @@ -152,7 +167,7 @@ func TestPaneID_ReturnsPaneID(t *testing.T) { | |||
| 152 | 167 | mockWebView := mocks.NewMockWidget(t) | |
| 153 | 168 | ||
| 154 | 169 | paneID := entity.PaneID("test-pane-123") | |
| 155 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 170 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 156 | 171 | ||
| 157 | 172 | pv := component.NewPaneView(mockFactory, paneID, mockWebView) | |
| 158 | 173 | ||
| @@ -170,7 +185,7 @@ func TestWebViewWidget_ReturnsWebView(t *testing.T) { | |||
| 170 | 185 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 171 | 186 | mockWebView := mocks.NewMockWidget(t) | |
| 172 | 187 | ||
| 173 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 188 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 174 | 189 | ||
| 175 | 190 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 176 | 191 | ||
| @@ -189,14 +204,13 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) { | |||
| 189 | 204 | mockOldWebView := mocks.NewMockWidget(t) | |
| 190 | 205 | mockNewWebView := mocks.NewMockWidget(t) | |
| 191 | 206 | ||
| 192 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockOldWebView) | |
| 207 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockOldWebView) | |
| 193 | 208 | ||
| 194 | 209 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockOldWebView) | |
| 195 | 210 | ||
| 196 | 211 | // Expect removal of old widget and addition of new | |
| 197 | 212 | mockOverlay.EXPECT().SetChild(nil).Once() | |
| 198 | 213 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | |
| 199 | - | mockNewWebView.EXPECT().SetVisible(true).Once() | |
| 200 | 214 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | |
| 201 | 215 | ||
| 202 | 216 | // Act | |
| @@ -213,13 +227,12 @@ func TestSetWebViewWidget_FromNil(t *testing.T) { | |||
| 213 | 227 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 214 | 228 | mockNewWebView := mocks.NewMockWidget(t) | |
| 215 | 229 | ||
| 216 | - | setupPaneViewMocksNoWebView(mockFactory, mockOverlay, mockBorderBox) | |
| 230 | + | setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox) | |
| 217 | 231 | ||
| 218 | 232 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil) | |
| 219 | 233 | ||
| 220 | 234 | // Expect only setting new child (no removal since old was nil) | |
| 221 | 235 | mockNewWebView.EXPECT().GetParent().Return(nil).Once() | |
| 222 | - | mockNewWebView.EXPECT().SetVisible(true).Once() | |
| 223 | 236 | mockOverlay.EXPECT().SetChild(mockNewWebView).Once() | |
| 224 | 237 | ||
| 225 | 238 | // Act | |
| @@ -236,7 +249,7 @@ func TestGrabFocus_DelegatesToWebView(t *testing.T) { | |||
| 236 | 249 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 237 | 250 | mockWebView := mocks.NewMockWidget(t) | |
| 238 | 251 | ||
| 239 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 252 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 240 | 253 | ||
| 241 | 254 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 242 | 255 | ||
| @@ -255,7 +268,7 @@ func TestGrabFocus_NilWebView_ReturnsFalse(t *testing.T) { | |||
| 255 | 268 | mockOverlay := mocks.NewMockOverlayWidget(t) | |
| 256 | 269 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 257 | 270 | ||
| 258 | - | setupPaneViewMocksNoWebView(mockFactory, mockOverlay, mockBorderBox) | |
| 271 | + | setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox) | |
| 259 | 272 | ||
| 260 | 273 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil) | |
| 261 | 274 | ||
| @@ -273,7 +286,7 @@ func TestHasFocus_DelegatesToWebView(t *testing.T) { | |||
| 273 | 286 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 274 | 287 | mockWebView := mocks.NewMockWidget(t) | |
| 275 | 288 | ||
| 276 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 289 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 277 | 290 | ||
| 278 | 291 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 279 | 292 | ||
| @@ -292,7 +305,7 @@ func TestHasFocus_NilWebView_ReturnsFalse(t *testing.T) { | |||
| 292 | 305 | mockOverlay := mocks.NewMockOverlayWidget(t) | |
| 293 | 306 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 294 | 307 | ||
| 295 | - | setupPaneViewMocksNoWebView(mockFactory, mockOverlay, mockBorderBox) | |
| 308 | + | setupPaneViewMocksNoWebView(t, mockFactory, mockOverlay, mockBorderBox) | |
| 296 | 309 | ||
| 297 | 310 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), nil) | |
| 298 | 311 | ||
| @@ -310,7 +323,7 @@ func TestWidget_ReturnsOverlay(t *testing.T) { | |||
| 310 | 323 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 311 | 324 | mockWebView := mocks.NewMockWidget(t) | |
| 312 | 325 | ||
| 313 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 326 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 314 | 327 | ||
| 315 | 328 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 316 | 329 | ||
| @@ -328,7 +341,7 @@ func TestOverlay_ReturnsOverlayWidget(t *testing.T) { | |||
| 328 | 341 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 329 | 342 | mockWebView := mocks.NewMockWidget(t) | |
| 330 | 343 | ||
| 331 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 344 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 332 | 345 | ||
| 333 | 346 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 334 | 347 | ||
| @@ -346,7 +359,7 @@ func TestShow_DelegatesToOverlay(t *testing.T) { | |||
| 346 | 359 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 347 | 360 | mockWebView := mocks.NewMockWidget(t) | |
| 348 | 361 | ||
| 349 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 362 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 350 | 363 | ||
| 351 | 364 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 352 | 365 | ||
| @@ -365,7 +378,7 @@ func TestHide_DelegatesToOverlay(t *testing.T) { | |||
| 365 | 378 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 366 | 379 | mockWebView := mocks.NewMockWidget(t) | |
| 367 | 380 | ||
| 368 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 381 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 369 | 382 | ||
| 370 | 383 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 371 | 384 | ||
| @@ -384,7 +397,7 @@ func TestSetVisible_DelegatesToOverlay(t *testing.T) { | |||
| 384 | 397 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 385 | 398 | mockWebView := mocks.NewMockWidget(t) | |
| 386 | 399 | ||
| 387 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 400 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 388 | 401 | ||
| 389 | 402 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 390 | 403 | ||
| @@ -403,7 +416,7 @@ func TestIsVisible_DelegatesToOverlay(t *testing.T) { | |||
| 403 | 416 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 404 | 417 | mockWebView := mocks.NewMockWidget(t) | |
| 405 | 418 | ||
| 406 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 419 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 407 | 420 | ||
| 408 | 421 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 409 | 422 | ||
| @@ -423,7 +436,7 @@ func TestAddCssClass_DelegatesToOverlay(t *testing.T) { | |||
| 423 | 436 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 424 | 437 | mockWebView := mocks.NewMockWidget(t) | |
| 425 | 438 | ||
| 426 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 439 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 427 | 440 | ||
| 428 | 441 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 429 | 442 | ||
| @@ -442,7 +455,7 @@ func TestRemoveCssClass_DelegatesToOverlay(t *testing.T) { | |||
| 442 | 455 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 443 | 456 | mockWebView := mocks.NewMockWidget(t) | |
| 444 | 457 | ||
| 445 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 458 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 446 | 459 | ||
| 447 | 460 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 448 | 461 | ||
| @@ -461,7 +474,7 @@ func TestSetOnFocusIn_SetsCallback(t *testing.T) { | |||
| 461 | 474 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 462 | 475 | mockWebView := mocks.NewMockWidget(t) | |
| 463 | 476 | ||
| 464 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 477 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 465 | 478 | ||
| 466 | 479 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 467 | 480 | ||
| @@ -481,7 +494,7 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) { | |||
| 481 | 494 | mockBorderBox := mocks.NewMockBoxWidget(t) | |
| 482 | 495 | mockWebView := mocks.NewMockWidget(t) | |
| 483 | 496 | ||
| 484 | - | setupPaneViewMocks(mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 497 | + | setupPaneViewMocks(t, mockFactory, mockOverlay, mockBorderBox, mockWebView) | |
| 485 | 498 | ||
| 486 | 499 | pv := component.NewPaneView(mockFactory, entity.PaneID("pane-1"), mockWebView) | |
| 487 | 500 | ||
| @@ -494,13 +507,73 @@ func TestSetOnFocusOut_SetsCallback(t *testing.T) { | |||
| 494 | 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 | 564 | // Helper function to setup common mock expectations for PaneView creation | |
| 498 | 565 | func setupPaneViewMocks( | |
| 566 | + | t *testing.T, | |
| 499 | 567 | mockFactory *mocks.MockWidgetFactory, | |
| 500 | 568 | mockOverlay *mocks.MockOverlayWidget, | |
| 501 | 569 | mockBorderBox *mocks.MockBoxWidget, | |
| 502 | 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 | 577 | mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once() | |
| 505 | 578 | mockOverlay.EXPECT().SetHexpand(true).Once() | |
| 506 | 579 | mockOverlay.EXPECT().SetVexpand(true).Once() | |
| @@ -508,6 +581,8 @@ func setupPaneViewMocks( | |||
| 508 | 581 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 509 | 582 | mockOverlay.EXPECT().SetChild(mockWebView).Once() | |
| 510 | 583 | ||
| 584 | + | setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) | |
| 585 | + | ||
| 511 | 586 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 512 | 587 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | |
| 513 | 588 | mockBorderBox.EXPECT().SetCanTarget(false).Once() | |
| @@ -520,16 +595,24 @@ func setupPaneViewMocks( | |||
| 520 | 595 | } | |
| 521 | 596 | ||
| 522 | 597 | func setupPaneViewMocksNoWebView( | |
| 598 | + | t *testing.T, | |
| 523 | 599 | mockFactory *mocks.MockWidgetFactory, | |
| 524 | 600 | mockOverlay *mocks.MockOverlayWidget, | |
| 525 | 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 | 608 | mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once() | |
| 528 | 609 | mockOverlay.EXPECT().SetHexpand(true).Once() | |
| 529 | 610 | mockOverlay.EXPECT().SetVexpand(true).Once() | |
| 530 | 611 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 531 | 612 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() // Theme background | |
| 532 | 613 | ||
| 614 | + | setupLoadingSkeletonMocks(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) | |
| 615 | + | ||
| 533 | 616 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 534 | 617 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | |
| 535 | 618 | mockBorderBox.EXPECT().SetCanTarget(false).Once() | |
M internal/ui/component/progress_bar.go +45 -1
| @@ -15,11 +15,14 @@ const ( | |||
| 15 | 15 | progressStep = 0.02 | |
| 16 | 16 | // Animation interval in milliseconds | |
| 17 | 17 | progressIntervalMs = 16 // ~60fps | |
| 18 | + | // Timeout to auto-hide progress bar if stuck (30 seconds) | |
| 19 | + | progressTimeoutMs = 30000 | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| 20 | 22 | // ProgressBar displays a slim loading progress indicator at the bottom of a pane. | |
| 21 | 23 | // Uses native GtkProgressBar with "osd" styling for overlay appearance. | |
| 22 | 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 | 26 | type ProgressBar struct { | |
| 24 | 27 | progressBar layout.ProgressBarWidget | |
| 25 | 28 | ||
| @@ -27,6 +30,7 @@ type ProgressBar struct { | |||
| 27 | 30 | currentValue float64 // Current displayed value | |
| 28 | 31 | targetValue float64 // Target value to animate towards | |
| 29 | 32 | animationTimer uint // Timer source ID for animation | |
| 33 | + | timeoutTimer uint // Timer source ID for auto-hide timeout | |
| 30 | 34 | ||
| 31 | 35 | mu sync.Mutex | |
| 32 | 36 | } | |
| @@ -127,7 +131,7 @@ func (pb *ProgressBar) startAnimation() { | |||
| 127 | 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 | 135 | func (pb *ProgressBar) Show() { | |
| 132 | 136 | pb.mu.Lock() | |
| 133 | 137 | defer pb.mu.Unlock() | |
| @@ -136,6 +140,34 @@ func (pb *ProgressBar) Show() { | |||
| 136 | 140 | pb.visible = true | |
| 137 | 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 | 173 | // Hide makes the progress bar invisible and resets state. | |
| @@ -143,6 +175,12 @@ func (pb *ProgressBar) Hide() { | |||
| 143 | 175 | pb.mu.Lock() | |
| 144 | 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 | 184 | if pb.visible { | |
| 147 | 185 | pb.visible = false | |
| 148 | 186 | pb.progressBar.SetVisible(false) | |
| @@ -153,6 +191,12 @@ func (pb *ProgressBar) Hide() { | |||
| 153 | 191 | 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 | 200 | // Reset values | |
| 157 | 201 | pb.currentValue = 0 | |
| 158 | 202 | 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 | 134 | // Toaster displays toast notifications in an overlay. | |
| 52 | 135 | // It supports different notification levels and auto-dismissal. | |
| 53 | 136 | // When a new toast is shown while one is already visible, the text | |
| @@ -60,18 +143,26 @@ type Toaster struct { | |||
| 60 | 143 | visible bool | |
| 61 | 144 | 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 | 154 | mu sync.Mutex | |
| 64 | 155 | } | |
| 65 | 156 | ||
| 66 | 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 | 159 | func NewToaster(factory layout.WidgetFactory) *Toaster { | |
| 69 | 160 | // Create container box for styling and positioning | |
| 70 | 161 | container := factory.NewBox(layout.OrientationHorizontal, 0) | |
| 71 | 162 | container.AddCssClass("toast") | |
| 72 | 163 | container.AddCssClass("toast-info") // Default level | |
| 73 | 164 | ||
| 74 | - | // Position in top-left with margin | |
| 165 | + | // Position in top-left with margin (default) | |
| 75 | 166 | container.SetHalign(gtk.AlignStartValue) | |
| 76 | 167 | container.SetValign(gtk.AlignStartValue) | |
| 77 | 168 | ||
| @@ -98,17 +189,28 @@ func NewToaster(factory layout.WidgetFactory) *Toaster { | |||
| 98 | 189 | label: label, | |
| 99 | 190 | currentLevel: ToastInfo, | |
| 100 | 191 | visible: false, | |
| 192 | + | currentOpts: defaultToastOptions(), | |
| 101 | 193 | } | |
| 102 | 194 | } | |
| 103 | 195 | ||
| 104 | 196 | // Show displays a toast notification with the given message and level. | |
| 105 | 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 | 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 | 208 | t.mu.Lock() | |
| 110 | 209 | defer t.mu.Unlock() | |
| 111 | 210 | ||
| 211 | + | // Clear previous custom styles if any | |
| 212 | + | t.clearCustomStyle() | |
| 213 | + | ||
| 112 | 214 | // Update level CSS class if changed | |
| 113 | 215 | if t.currentLevel != level { | |
| 114 | 216 | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |
| @@ -116,6 +218,17 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) { | |||
| 116 | 218 | 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 | 232 | // Update message text | |
| 120 | 233 | t.label.SetText(message) | |
| 121 | 234 | ||
| @@ -131,12 +244,18 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) { | |||
| 131 | 244 | t.container.SetVisible(true) | |
| 132 | 245 | } | |
| 133 | 246 | ||
| 134 | - | // Start new dismiss timer | |
| 135 | - | t.startDismissTimer(ctx) | |
| 247 | + | // Start new dismiss timer (if duration > 0) | |
| 248 | + | if options.Duration > 0 { | |
| 249 | + | t.startDismissTimerWithDuration(ctx, options.Duration) | |
| 250 | + | } | |
| 251 | + | // Duration == 0 means persistent, no timer | |
| 252 | + | ||
| 253 | + | t.currentOpts = options | |
| 136 | 254 | ||
| 137 | 255 | log.Debug(). | |
| 138 | 256 | Str("toast_message", message). | |
| 139 | 257 | Int("toast_level", int(level)). | |
| 258 | + | Int("duration", options.Duration). | |
| 140 | 259 | Msg("toast shown") | |
| 141 | 260 | } | |
| 142 | 261 | ||
| @@ -167,13 +286,125 @@ func (t *Toaster) hide() { | |||
| 167 | 286 | t.dismissTimer = 0 | |
| 168 | 287 | } | |
| 169 | 288 | ||
| 289 | + | // Clear custom styles | |
| 290 | + | t.clearCustomStyle() | |
| 291 | + | ||
| 170 | 292 | t.visible = false | |
| 171 | 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 | 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 | 408 | log := logging.FromContext(ctx) | |
| 178 | 409 | ||
| 179 | 410 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| @@ -188,7 +419,7 @@ func (t *Toaster) startDismissTimer(ctx context.Context) { | |||
| 188 | 419 | 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 | 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 | 557 | // Create omnibox with pane-specific toast callback | |
| 558 | 558 | cfg := wv.omniboxCfg | |
| 559 | - | cfg.OnToast = func(message string) { | |
| 560 | - | pv.ShowToast(ctx, message, ToastSuccess) | |
| 559 | + | cfg.OnToast = func(toastCtx context.Context, message string, level ToastLevel) { | |
| 560 | + | pv.ShowToast(toastCtx, message, level) | |
| 561 | 561 | } | |
| 562 | 562 | omnibox := NewOmnibox(ctx, cfg) | |
| 563 | 563 | if omnibox == nil { | |
| @@ -608,7 +608,13 @@ func (wv *WorkspaceView) hideOmniboxInternal() { | |||
| 608 | 608 | ||
| 609 | 609 | // Remove from pane overlay | |
| 610 | 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 | 620 | // Clear references | |
| @@ -715,7 +721,11 @@ func (wv *WorkspaceView) hideFindBarInternal() { | |||
| 715 | 721 | wv.findBar.Hide() | |
| 716 | 722 | ||
| 717 | 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 | 731 | wv.findBar = nil | |
M internal/ui/component/workspace_view_test.go +60 -0
| @@ -14,6 +14,59 @@ import ( | |||
| 14 | 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 | 70 | func setupWorkspaceViewBase(t *testing.T, mockFactory *mocks.MockWidgetFactory) (context.Context, *mocks.MockBoxWidget) { | |
| 18 | 71 | ctx := context.Background() | |
| 19 | 72 | mockContainer := mocks.NewMockBoxWidget(t) | |
| @@ -37,12 +90,19 @@ func setupWorkspacePaneViewMocks(t *testing.T, mockFactory *mocks.MockWidgetFact | |||
| 37 | 90 | mockOverlay := mocks.NewMockOverlayWidget(t) | |
| 38 | 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 | 98 | mockFactory.EXPECT().NewOverlay().Return(mockOverlay).Once() | |
| 41 | 99 | mockOverlay.EXPECT().SetHexpand(true).Once() | |
| 42 | 100 | mockOverlay.EXPECT().SetVexpand(true).Once() | |
| 43 | 101 | mockOverlay.EXPECT().SetVisible(true).Once() | |
| 44 | 102 | mockOverlay.EXPECT().AddCssClass("pane-overlay").Once() | |
| 45 | 103 | ||
| 104 | + | setupLoadingSkeletonMocksWorkspace(mockFactory, mockOverlay, mockLoadingContainer, mockLoadingContent, mockLoadingSpinner, mockLoadingLogo) | |
| 105 | + | ||
| 46 | 106 | mockFactory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(mockBorderBox).Once() | |
| 47 | 107 | mockBorderBox.EXPECT().SetCanFocus(false).Once() | |
| 48 | 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 | 52 | // Callback when active pane title changes (for window title updates) | |
| 53 | 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 | 61 | // Gesture action handler for mouse button navigation | |
| 56 | 62 | gestureActionHandler input.ActionHandler | |
| 57 | 63 | ||
| @@ -72,6 +78,9 @@ type ContentCoordinator struct { | |||
| 72 | 78 | ||
| 73 | 79 | // Idle inhibitor for fullscreen video playback | |
| 74 | 80 | 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 | 86 | // NewContentCoordinator creates a new ContentCoordinator. | |
| @@ -94,6 +103,7 @@ func NewContentCoordinator( | |||
| 94 | 103 | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 95 | 104 | paneTitles: make(map[entity.PaneID]string), | |
| 96 | 105 | navOrigins: make(map[entity.PaneID]string), | |
| 106 | + | pendingReveal: make(map[entity.PaneID]bool), | |
| 97 | 107 | getActiveWS: getActiveWS, | |
| 98 | 108 | pendingPopups: make(map[port.WebViewID]*PendingPopup), | |
| 99 | 109 | } | |
| @@ -119,6 +129,11 @@ func (c *ContentCoordinator) SetOnWindowTitleChanged(fn func(title string)) { | |||
| 119 | 129 | 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 | 137 | // SetGestureActionHandler sets the callback for mouse button navigation gestures. | |
| 123 | 138 | func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) { | |
| 124 | 139 | c.gestureActionHandler = handler | |
| @@ -129,6 +144,11 @@ func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) { | |||
| 129 | 144 | 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 | 152 | // EnsureWebView acquires or reuses a WebView for the given pane. | |
| 133 | 153 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | |
| 134 | 154 | log := logging.FromContext(ctx) | |
| @@ -674,18 +694,46 @@ func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID en | |||
| 674 | 694 | func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | |
| 675 | 695 | log := logging.FromContext(ctx) | |
| 676 | 696 | ||
| 697 | + | url := wv.URI() | |
| 698 | + | if url == "" { | |
| 699 | + | return | |
| 700 | + | } | |
| 701 | + | ||
| 677 | 702 | // Show the WebView now that content is being painted | |
| 678 | 703 | // (WebViews are hidden on creation to avoid white flash) | |
| 679 | - | if inner := wv.Widget(); inner != nil { | |
| 680 | - | inner.SetVisible(true) | |
| 681 | - | log.Debug().Str("pane_id", string(paneID)).Msg("webview shown on load committed") | |
| 704 | + | // Skip showing if this is about:blank but the pane is loading a different URL | |
| 705 | + | // This prevents the brief flash of about:blank during initial navigation | |
| 706 | + | shouldShow := true | |
| 707 | + | if url == "about:blank" { | |
| 708 | + | // Get the pane's intended URI from the workspace | |
| 709 | + | ws, _ := c.getActiveWS() | |
| 710 | + | if ws != nil { | |
| 711 | + | if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil { | |
| 712 | + | // Don't show about:blank if the pane is supposed to load a different URL | |
| 713 | + | if paneNode.Pane.URI != "" && paneNode.Pane.URI != "about:blank" { | |
| 714 | + | shouldShow = false | |
| 715 | + | log.Debug(). | |
| 716 | + | Str("pane_id", string(paneID)). | |
| 717 | + | Str("pane_uri", paneNode.Pane.URI). | |
| 718 | + | Msg("skipping webview show for about:blank (pane loading different URL)") | |
| 719 | + | } | |
| 720 | + | } | |
| 721 | + | } | |
| 682 | 722 | } | |
| 683 | 723 | ||
| 684 | - | url := wv.URI() | |
| 685 | - | if url == "" { | |
| 724 | + | if !shouldShow { | |
| 725 | + | // Avoid updating UI/domain state to about:blank when we know the pane is | |
| 726 | + | // navigating to a different URL. This prevents the omnibox/window title from | |
| 727 | + | // briefly showing about:blank on cold start. | |
| 728 | + | c.clearPendingReveal(paneID) | |
| 686 | 729 | 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 | 737 | // Update domain model with current URI for session snapshots | |
| 690 | 738 | c.updatePaneURI(paneID, url) | |
| 691 | 739 | ||
| @@ -754,10 +802,16 @@ func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) { | |||
| 754 | 802 | if paneView != nil { | |
| 755 | 803 | paneView.SetLoading(false) | |
| 756 | 804 | } | |
| 805 | + | ||
| 806 | + | c.revealIfPending(context.Background(), paneID, "", "load-finished") | |
| 757 | 807 | } | |
| 758 | 808 | ||
| 759 | 809 | // onProgressChanged updates the progress bar with current load progress. | |
| 760 | 810 | func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) { | |
| 811 | + | if progress > 0 { | |
| 812 | + | c.revealIfPending(context.Background(), paneID, "", "progress") | |
| 813 | + | } | |
| 814 | + | ||
| 761 | 815 | _, wsView := c.getActiveWS() | |
| 762 | 816 | if wsView == nil { | |
| 763 | 817 | 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 | 869 | // onLinkHover updates the link status overlay when hovering over links. | |
| 773 | 870 | func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) { | |
| 774 | 871 | _, wsView := c.getActiveWS() | |
| @@ -1251,6 +1348,9 @@ func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, pa | |||
| 1251 | 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 | 1354 | return false // Allow fullscreen | |
| 1255 | 1355 | } | |
| 1256 | 1356 | ||
| @@ -1260,6 +1360,9 @@ func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, pa | |||
| 1260 | 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 | 1366 | 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 | 19 | // This struct is created once at startup and passed to UI components. | |
| 20 | 20 | type Dependencies struct { | |
| 21 | 21 | // Core context and configuration | |
| 22 | - | Ctx context.Context | |
| 23 | - | Config *config.Config | |
| 24 | - | InitialURL string // URL to open on startup (optional) | |
| 25 | - | RestoreSessionID string // Session ID to restore on startup (optional) | |
| 22 | + | Ctx context.Context | |
| 23 | + | Config *config.Config | |
| 24 | + | InitialURL string // URL to open on startup (optional) | |
| 25 | + | RestoreSessionID string // Session ID to restore on startup (optional) | |
| 26 | + | OnFirstWebViewShown func(context.Context) | |
| 26 | 27 | ||
| 27 | 28 | // Theme management | |
| 28 | 29 | Theme *theme.Manager | |
M internal/ui/dispatcher/keyboard.go +44 -12
| @@ -17,18 +17,20 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | // KeyboardDispatcher routes keyboard actions to appropriate coordinators. | |
| 19 | 19 | type KeyboardDispatcher struct { | |
| 20 | - | tabCoord *coordinator.TabCoordinator | |
| 21 | - | wsCoord *coordinator.WorkspaceCoordinator | |
| 22 | - | navCoord *coordinator.NavigationCoordinator | |
| 23 | - | zoomUC *usecase.ManageZoomUseCase | |
| 24 | - | copyURLUC *usecase.CopyURLUseCase | |
| 25 | - | actionHandlers map[input.Action]func(ctx context.Context) error | |
| 26 | - | onQuit func() | |
| 27 | - | onFindOpen func(ctx context.Context) error | |
| 28 | - | onFindNext func(ctx context.Context) error | |
| 29 | - | onFindPrev func(ctx context.Context) error | |
| 30 | - | onFindClose func(ctx context.Context) error | |
| 31 | - | onSessionOpen func(ctx context.Context) error | |
| 20 | + | tabCoord *coordinator.TabCoordinator | |
| 21 | + | wsCoord *coordinator.WorkspaceCoordinator | |
| 22 | + | navCoord *coordinator.NavigationCoordinator | |
| 23 | + | zoomUC *usecase.ManageZoomUseCase | |
| 24 | + | copyURLUC *usecase.CopyURLUseCase | |
| 25 | + | actionHandlers map[input.Action]func(ctx context.Context) error | |
| 26 | + | onQuit func() | |
| 27 | + | onFindOpen func(ctx context.Context) error | |
| 28 | + | onFindNext func(ctx context.Context) error | |
| 29 | + | onFindPrev func(ctx context.Context) error | |
| 30 | + | onFindClose func(ctx context.Context) error | |
| 31 | + | onSessionOpen func(ctx context.Context) error | |
| 32 | + | onMovePaneToTab func(ctx context.Context) error | |
| 33 | + | onMovePaneToNext func(ctx context.Context) error | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | 36 | // NewKeyboardDispatcher creates a new KeyboardDispatcher. | |
| @@ -84,6 +86,14 @@ func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error | |||
| 84 | 86 | 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 | 97 | func (d *KeyboardDispatcher) initActionHandlers() { | |
| 88 | 98 | const ( | |
| 89 | 99 | firstTabIndex = 0 | |
| @@ -128,6 +138,12 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 128 | 138 | input.ActionSplitDown: func(ctx context.Context) error { return d.wsCoord.Split(ctx, usecase.SplitDown) }, | |
| 129 | 139 | input.ActionClosePane: d.wsCoord.ClosePane, | |
| 130 | 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 | 147 | input.ActionConsumeOrExpelLeft: func(ctx context.Context) error { | |
| 132 | 148 | return d.wsCoord.ConsumeOrExpelPane(ctx, usecase.ConsumeOrExpelLeft) | |
| 133 | 149 | }, | |
| @@ -239,6 +255,22 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { | |||
| 239 | 255 | 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 | 274 | func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error { | |
| 243 | 275 | if ctx == nil { | |
| 244 | 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 | 140 | // Build KeyBinding from event | |
| 141 | 141 | 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 | 150 | binding := KeyBinding{ | |
| 143 | 151 | Keyval: keyval, | |
| 144 | 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 | 61 | // ModalState manages the current input mode with optional timeout. | |
| 46 | 62 | type ModalState struct { | |
| 47 | 63 | mode Mode | |
M internal/ui/input/shortcuts.go +32 -11
| @@ -134,6 +134,9 @@ const ( | |||
| 134 | 134 | ActionClosePane Action = "close_pane" | |
| 135 | 135 | ActionStackPane Action = "stack_pane" | |
| 136 | 136 | ||
| 137 | + | ActionMovePaneToTab Action = "move_pane_to_tab" | |
| 138 | + | ActionMovePaneToNextTab Action = "move_pane_to_next_tab" | |
| 139 | + | ||
| 137 | 140 | ActionConsumeOrExpelLeft Action = "consume_or_expel_left" | |
| 138 | 141 | ActionConsumeOrExpelRight Action = "consume_or_expel_right" | |
| 139 | 142 | ActionConsumeOrExpelUp Action = "consume_or_expel_up" | |
| @@ -353,7 +356,7 @@ func (s *ShortcutSet) registerStandardShortcuts() { | |||
| 353 | 356 | s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext | |
| 354 | 357 | s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev | |
| 355 | 358 | s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload | |
| 356 | - | s.Global[KeyBinding{uint(gdk.KEY_R), ModCtrl | ModShift}] = ActionHardReload | |
| 359 | + | s.Global[KeyBinding{uint('r'), ModCtrl | ModShift}] = ActionHardReload | |
| 357 | 360 | s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload | |
| 358 | 361 | s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload | |
| 359 | 362 | s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools | |
| @@ -365,7 +368,7 @@ func (s *ShortcutSet) registerStandardShortcuts() { | |||
| 365 | 368 | s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset | |
| 366 | 369 | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | |
| 367 | 370 | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | |
| 368 | - | s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL | |
| 371 | + | s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL | |
| 369 | 372 | // Session management - direct shortcut to open session manager | |
| 370 | 373 | s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager | |
| 371 | 374 | } | |
| @@ -429,12 +432,14 @@ var configActionToAction = map[string]Action{ | |||
| 429 | 432 | "rename-tab": ActionRenameTab, | |
| 430 | 433 | ||
| 431 | 434 | // Pane actions | |
| 432 | - | "split-right": ActionSplitRight, | |
| 433 | - | "split-left": ActionSplitLeft, | |
| 434 | - | "split-up": ActionSplitUp, | |
| 435 | - | "split-down": ActionSplitDown, | |
| 436 | - | "close-pane": ActionClosePane, | |
| 437 | - | "stack-pane": ActionStackPane, | |
| 435 | + | "split-right": ActionSplitRight, | |
| 436 | + | "split-left": ActionSplitLeft, | |
| 437 | + | "split-up": ActionSplitUp, | |
| 438 | + | "split-down": ActionSplitDown, | |
| 439 | + | "close-pane": ActionClosePane, | |
| 440 | + | "stack-pane": ActionStackPane, | |
| 441 | + | "move-pane-to-tab": ActionMovePaneToTab, | |
| 442 | + | "move-pane-to-next-tab": ActionMovePaneToNextTab, | |
| 438 | 443 | ||
| 439 | 444 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |
| 440 | 445 | "consume-or-expel-right": ActionConsumeOrExpelRight, | |
| @@ -484,7 +489,10 @@ func ParseKeyString(s string) (KeyBinding, bool) { | |||
| 484 | 489 | 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 | 496 | if s == "+" { | |
| 489 | 497 | return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true | |
| 490 | 498 | } | |
| @@ -499,7 +507,9 @@ func ParseKeyString(s string) (KeyBinding, bool) { | |||
| 499 | 507 | if part == "" { | |
| 500 | 508 | continue | |
| 501 | 509 | } | |
| 502 | - | switch part { | |
| 510 | + | ||
| 511 | + | lower := strings.ToLower(part) | |
| 512 | + | switch lower { | |
| 503 | 513 | case "ctrl", "control": | |
| 504 | 514 | modifiers |= ModCtrl | |
| 505 | 515 | case "shift": | |
| @@ -523,6 +533,12 @@ func ParseKeyString(s string) (KeyBinding, bool) { | |||
| 523 | 533 | 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 | 542 | keyval, ok := stringToKeyval(keyPart) | |
| 527 | 543 | if !ok { | |
| 528 | 544 | return KeyBinding{}, false | |
| @@ -536,7 +552,11 @@ func ParseKeyString(s string) (KeyBinding, bool) { | |||
| 536 | 552 | ||
| 537 | 553 | // stringToKeyval converts a key name to its GDK keyval. | |
| 538 | 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 | 560 | return keyval, true | |
| 541 | 561 | } | |
| 542 | 562 | ||
| @@ -588,6 +608,7 @@ func ShouldAutoExitMode(action Action) bool { | |||
| 588 | 608 | case ActionNewTab, ActionCloseTab, ActionRenameTab, | |
| 589 | 609 | ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown, | |
| 590 | 610 | ActionClosePane, ActionStackPane, | |
| 611 | + | ActionMovePaneToTab, ActionMovePaneToNextTab, | |
| 591 | 612 | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown, | |
| 592 | 613 | ActionOpenSessionManager: | |
| 593 | 614 | return true | |
M internal/ui/input/shortcuts_test.go +16 -1
| @@ -207,9 +207,11 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) { | |||
| 207 | 207 | input1 string | |
| 208 | 208 | input2 string | |
| 209 | 209 | }{ | |
| 210 | - | {"ctrl+t", "CTRL+T"}, | |
| 210 | + | // Modifiers and named keys are case-insensitive. | |
| 211 | + | {"ctrl+t", "CTRL+t"}, | |
| 211 | 212 | {"Escape", "escape"}, | |
| 212 | 213 | {"Tab", "TAB"}, | |
| 214 | + | // If shift is explicitly present, the key letter case shouldn't matter. | |
| 213 | 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 | 246 | func TestShortcutSet_Lookup(t *testing.T) { | |
| 234 | 247 | set := &ShortcutSet{ | |
| 235 | 248 | Global: ShortcutTable{ | |
| @@ -336,6 +349,8 @@ func TestShouldAutoExitMode(t *testing.T) { | |||
| 336 | 349 | ActionSplitDown, | |
| 337 | 350 | ActionClosePane, | |
| 338 | 351 | ActionStackPane, | |
| 352 | + | ActionMovePaneToTab, | |
| 353 | + | ActionMovePaneToNextTab, | |
| 339 | 354 | } | |
| 340 | 355 | ||
| 341 | 356 | stayActions := []Action{ | |
M internal/ui/layout/gtk_widgets.go +66 -0
| @@ -18,6 +18,7 @@ var ( | |||
| 18 | 18 | _ ButtonWidget = (*gtkButton)(nil) | |
| 19 | 19 | _ ImageWidget = (*gtkImage)(nil) | |
| 20 | 20 | _ ProgressBarWidget = (*gtkProgressBar)(nil) | |
| 21 | + | _ SpinnerWidget = (*gtkSpinner)(nil) | |
| 21 | 22 | _ WidgetFactory = (*GtkWidgetFactory)(nil) | |
| 22 | 23 | ) | |
| 23 | 24 | ||
| @@ -687,6 +688,66 @@ func (p *gtkProgressBar) ComputePoint(target Widget) (x, y float64, ok bool) { | |||
| 687 | 688 | func (p *gtkProgressBar) SetFraction(fraction float64) { p.inner.SetFraction(fraction) } | |
| 688 | 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 >kWidget{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 | 751 | // GtkWidgetFactory creates real GTK widgets. | |
| 691 | 752 | type GtkWidgetFactory struct{} | |
| 692 | 753 | ||
| @@ -736,6 +797,11 @@ func (f *GtkWidgetFactory) NewProgressBar() ProgressBarWidget { | |||
| 736 | 797 | return >kProgressBar{inner: progressBar} | |
| 737 | 798 | } | |
| 738 | 799 | ||
| 800 | + | func (f *GtkWidgetFactory) NewSpinner() SpinnerWidget { | |
| 801 | + | spinner := gtk.NewSpinner() | |
| 802 | + | return >kSpinner{inner: spinner} | |
| 803 | + | } | |
| 804 | + | ||
| 739 | 805 | func (f *GtkWidgetFactory) WrapWidget(w *gtk.Widget) Widget { | |
| 740 | 806 | if w == nil { | |
| 741 | 807 | 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 | 11518 | 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 | 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 | 12801 | // The first argument is typically a *testing.T value. | |
| 11523 | 12802 | func NewMockWidgetFactory(t interface { | |
| @@ -11894,6 +13173,52 @@ func (_c *MockWidgetFactory_NewProgressBar_Call) RunAndReturn(run func() layout. | |||
| 11894 | 13173 | 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 | 13222 | // WrapWidget provides a mock function for the type MockWidgetFactory | |
| 11898 | 13223 | func (_mock *MockWidgetFactory) WrapWidget(w *gtk.Widget) layout.Widget { | |
| 11899 | 13224 | ret := _mock.Called(w) | |
M internal/ui/layout/widgets.go +11 -0
| @@ -211,6 +211,16 @@ type ProgressBarWidget interface { | |||
| 211 | 211 | 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 | 224 | // WidgetFactory creates widget instances. | |
| 215 | 225 | // This abstraction allows tests to inject mock factories. | |
| 216 | 226 | type WidgetFactory interface { | |
| @@ -224,6 +234,7 @@ type WidgetFactory interface { | |||
| 224 | 234 | NewButton() ButtonWidget | |
| 225 | 235 | NewImage() ImageWidget | |
| 226 | 236 | NewProgressBar() ProgressBarWidget | |
| 237 | + | NewSpinner() SpinnerWidget | |
| 227 | 238 | ||
| 228 | 239 | // Wrap existing GTK widget | |
| 229 | 240 | WrapWidget(w *gtk.Widget) Widget | |
M internal/ui/theme/css.go +114 -13
| @@ -50,7 +50,14 @@ window, tooltip, popover { | |||
| 50 | 50 | ||
| 51 | 51 | // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts. | |
| 52 | 52 | // Scale affects font sizes and widget padding/margins proportionally. | |
| 53 | + | // Uses default mode colors. | |
| 53 | 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 | 61 | if scale <= 0 { | |
| 55 | 62 | scale = 1.0 | |
| 56 | 63 | } | |
| @@ -68,6 +75,7 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st | |||
| 68 | 75 | sb.WriteString(":root {\n") | |
| 69 | 76 | sb.WriteString(p.ToCSSVars()) | |
| 70 | 77 | sb.WriteString(FontCSSVars(fonts)) | |
| 78 | + | sb.WriteString(modeColors.ToCSSVars()) | |
| 71 | 79 | sb.WriteString("}\n\n") | |
| 72 | 80 | ||
| 73 | 81 | // Global font styling | |
| @@ -114,6 +122,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st | |||
| 114 | 122 | ||
| 115 | 123 | // Session manager styling | |
| 116 | 124 | sb.WriteString(generateSessionManagerCSS(p)) | |
| 125 | + | sb.WriteString("\n") | |
| 126 | + | ||
| 127 | + | // Tab picker styling | |
| 128 | + | sb.WriteString(generateTabPickerCSS(p)) | |
| 117 | 129 | ||
| 118 | 130 | return sb.String() | |
| 119 | 131 | } | |
| @@ -287,6 +299,22 @@ entry.omnibox-entry > *:focus-visible { | |||
| 287 | 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 | 318 | /* Favicon in omnibox rows */ | |
| 291 | 319 | .omnibox-favicon { | |
| 292 | 320 | min-width: 1em; | |
| @@ -311,15 +339,15 @@ entry.omnibox-entry > *:focus-visible { | |||
| 311 | 339 | font-weight: 500; | |
| 312 | 340 | } | |
| 313 | 341 | ||
| 314 | - | /* URL text below title */ | |
| 315 | - | .omnibox-suggestion-url { | |
| 342 | + | /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */ | |
| 343 | + | .omnibox-row .omnibox-suggestion-url { | |
| 316 | 344 | font-size: 0.75em; | |
| 317 | - | color: var(--muted); | |
| 345 | + | color: alpha(var(--muted), 0.65); | |
| 318 | 346 | font-weight: 400; | |
| 319 | 347 | } | |
| 320 | 348 | ||
| 321 | 349 | .omnibox-row:selected .omnibox-suggestion-url { | |
| 322 | - | color: var(--muted); | |
| 350 | + | color: alpha(var(--muted), 0.75); | |
| 323 | 351 | } | |
| 324 | 352 | ||
| 325 | 353 | /* Keyboard shortcut badge */ | |
| @@ -344,6 +372,16 @@ entry.omnibox-entry > *:focus-visible { | |||
| 344 | 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 | 385 | /* Zoom indicator in omnibox header */ | |
| 348 | 386 | .omnibox-zoom-indicator { | |
| 349 | 387 | background-color: alpha(var(--accent), 0.2); | |
| @@ -354,6 +392,19 @@ entry.omnibox-entry > *:focus-visible { | |||
| 354 | 392 | font-weight: 600; | |
| 355 | 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 | 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 | 524 | /* Pane border - default transparent */ | |
| 451 | 525 | .pane-border { | |
| 452 | 526 | border: 0.0625em solid transparent; | |
| @@ -462,26 +536,26 @@ func generatePaneCSS(p Palette) string { | |||
| 462 | 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 | 540 | .pane-mode-active { | |
| 467 | 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 | 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 | 547 | .tab-mode-active { | |
| 474 | 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 | 550 | border-radius: 0; | |
| 477 | 551 | } | |
| 478 | 552 | ||
| 479 | - | /* Resize mode active - thick teal inset border */ | |
| 553 | + | /* Resize mode active - thick inset border */ | |
| 480 | 554 | .resize-mode-active { | |
| 481 | 555 | background-color: transparent; | |
| 482 | 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; | |
| 484 | - | outline: 0.25em solid #00D4AA; | |
| 557 | + | box-shadow: inset 0 0 0 0.25em var(--resize-mode-color); | |
| 558 | + | outline: 0.25em solid var(--resize-mode-color); | |
| 485 | 559 | outline-offset: -0.25em; | |
| 486 | 560 | border-radius: 0; | |
| 487 | 561 | } | |
| @@ -578,6 +652,7 @@ progressbar.osd progress { | |||
| 578 | 652 | // generateSessionManagerCSS creates session manager modal styles. | |
| 579 | 653 | // Uses em units for scalable UI, matches omnibox styling patterns. | |
| 580 | 654 | func generateSessionManagerCSS(p Palette) string { | |
| 655 | + | _ = p | |
| 581 | 656 | return `/* ===== Session Manager Styling ===== */ | |
| 582 | 657 | ||
| 583 | 658 | /* Session manager outer container - for positioning in overlay */ | |
| @@ -728,10 +803,10 @@ row:selected .session-manager-row { | |||
| 728 | 803 | font-family: var(--font-mono); | |
| 729 | 804 | } | |
| 730 | 805 | ||
| 731 | - | /* Session mode border (purple) */ | |
| 806 | + | /* Session mode border */ | |
| 732 | 807 | .session-mode-active { | |
| 733 | 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 | 810 | border-radius: 0; | |
| 736 | 811 | } | |
| 737 | 812 | ` | |
| @@ -777,6 +852,32 @@ func generateToasterCSS(p Palette) string { | |||
| 777 | 852 | background-color: alpha(var(--destructive), 0.9); | |
| 778 | 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 | 18 | darkPalette Palette // Dark theme colors | |
| 19 | 19 | uiScale float64 // UI scaling factor (1.0 = 100%) | |
| 20 | 20 | fonts FontConfig | |
| 21 | + | modeColors ModeColors // Modal mode indicator colors | |
| 21 | 22 | cssProvider *gtk.CssProvider | |
| 22 | 23 | } | |
| 23 | 24 | ||
| @@ -56,6 +57,9 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager { | |||
| 56 | 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 | 63 | log.Debug(). | |
| 60 | 64 | Str("scheme", scheme). | |
| 61 | 65 | Bool("prefers_dark", prefersDark). | |
| @@ -71,6 +75,22 @@ func NewManager(ctx context.Context, cfg *config.Config) *Manager { | |||
| 71 | 75 | darkPalette: darkPalette, | |
| 72 | 76 | uiScale: uiScale, | |
| 73 | 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 | 124 | 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 | 132 | // GetWebUIThemeCSS returns CSS text that defines both light and dark variables. | |
| 108 | 133 | // WebUI uses `:root` for light and `.dark` overrides for dark. | |
| 109 | 134 | func (m *Manager) GetWebUIThemeCSS() string { | |
| @@ -121,9 +146,9 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | |||
| 121 | 146 | return | |
| 122 | 147 | } | |
| 123 | 148 | ||
| 124 | - | // Generate CSS with current palette, UI scale and fonts | |
| 149 | + | // Generate CSS with current palette, UI scale, fonts, and mode colors | |
| 125 | 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 | 153 | // Create CSS provider if needed | |
| 129 | 154 | if m.cssProvider == nil { | |
| @@ -197,6 +222,9 @@ func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, disp | |||
| 197 | 222 | MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont), | |
| 198 | 223 | } | |
| 199 | 224 | ||
| 225 | + | // Update mode colors | |
| 226 | + | m.modeColors = modeColorsFromConfig(cfg) | |
| 227 | + | ||
| 200 | 228 | log.Info(). | |
| 201 | 229 | Str("scheme", m.scheme). | |
| 202 | 230 | Bool("prefers_dark", m.prefersDark). | |
M internal/ui/theme/palette.go +28 -0
| @@ -24,6 +24,34 @@ type Palette struct { | |||
| 24 | 24 | 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 | 55 | // DefaultDarkPalette returns the default dark theme palette. | |
| 28 | 56 | func DefaultDarkPalette() Palette { | |
| 29 | 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 | 21 | const getFaviconUrl = (fav: Favorite): string => { | |
| 22 | 22 | // Use provided favicon_url if available | |
| 23 | 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 | 28 | // Construct DuckDuckGo favicon URL from domain | |
| 25 | 29 | const domain = getDomain(fav.url); | |
| 26 | 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 | 23 | const getFaviconUrl = (item: HistoryEntry): string => { | |
| 24 | 24 | // Use provided favicon_url if available | |
| 25 | 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 | 30 | // Construct DuckDuckGo favicon URL from domain | |
| 27 | 31 | const domain = getDomain(item.url).replace(/^www\./, ''); | |
| 28 | 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 | 2 | import { svelte } from "@sveltejs/vite-plugin-svelte"; | |
| 3 | 3 | import { resolve } from "path"; | |
| 4 | 4 | import { pageGenerator } from "./vite-plugin-pages"; | |
| 5 | + | import { copyFileSync, existsSync, mkdirSync } from "fs"; | |
| 5 | 6 | ||
| 6 | 7 | // Page configurations for dumb:// protocol pages | |
| 7 | 8 | const pages = { | |
| @@ -46,6 +47,20 @@ const pages = { | |||
| 46 | 47 | // Get page from VITE_PAGE env var, default to building all | |
| 47 | 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 | 64 | // Build single page config | |
| 50 | 65 | function buildPageConfig(pageName: keyof typeof pages) { | |
| 51 | 66 | const page = pages[pageName]; | |
| @@ -55,6 +70,12 @@ function buildPageConfig(pageName: keyof typeof pages) { | |||
| 55 | 70 | emitCss: false, // Inline component CSS for shadow DOM compatibility | |
| 56 | 71 | }), | |
| 57 | 72 | pageGenerator([page.html]), | |
| 73 | + | { | |
| 74 | + | name: "copy-public-assets", | |
| 75 | + | closeBundle() { | |
| 76 | + | copyPublicAssets(); | |
| 77 | + | }, | |
| 78 | + | }, | |
| 58 | 79 | ], | |
| 59 | 80 | build: { | |
| 60 | 81 | rollupOptions: { | |