detached 5f672c9

Merge pull request #19 from bnema/dev

Fix navigation perf, Wayland dialogs, idle inhibition, and stacked pane updates
commit
5f672c9e8225b04b385fbb180bf9bf497ad4742b
author
bnema · · 2025-12-24 16:18 UTC
committer
brice · · 2025-12-30 13:48 UTC
merge of
c741a39 + 436a28a · diff against the first parent
signature
unsigned
227 files · +35406 -1500
M .github/workflows/ci.yml +8 -3
@@ -2,7 +2,7 @@ name: CI
2 2
3 on:3 on:
4 push:4 push:
5- branches: [dev, main]5+ branches: [main]
6 paths:6 paths:
7 - '**.go'7 - '**.go'
8 - 'go.mod'8 - 'go.mod'
@@ -65,7 +65,9 @@ jobs:
65 run: |65 run: |
66 curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin v2.6.266 curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin v2.6.2
67 - name: Run golangci-lint67 - name: Run golangci-lint
68- run: golangci-lint run --timeout=20m68+ run: |
69+ git config --global --add safe.directory "$GITHUB_WORKSPACE"
70+ golangci-lint run --timeout=20m
69 71
70 test:72 test:
71 runs-on: ubuntu-latest73 runs-on: ubuntu-latest
@@ -75,7 +77,7 @@ jobs:
75 - name: Install dependencies77 - name: Install dependencies
76 run: |78 run: |
77 pacman -Syu --noconfirm79 pacman -Syu --noconfirm
78- pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 gobject-introspection base-devel80+ pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel
79 - uses: actions/checkout@v681 - uses: actions/checkout@v6
80 - name: Cache Go modules82 - name: Cache Go modules
81 uses: actions/cache@v483 uses: actions/cache@v4
@@ -101,4 +103,7 @@ jobs:
101 - name: Build frontend assets103 - name: Build frontend assets
102 run: make build-frontend104 run: make build-frontend
103 - name: Run tests105 - name: Run tests
106+ env:
107+ GDK_BACKEND: wayland
108+ WAYLAND_DISPLAY: wayland-0
104 run: go test -v ./...109 run: go test -v ./...
M .goreleaser.yaml +12 -5
@@ -33,14 +33,14 @@ archives:
33 - id: default33 - id: default
34 name_template: >-34 name_template: >-
35 {{ .ProjectName }}_35 {{ .ProjectName }}_
36- {{- .Version }}_
37 {{- .Os }}_36 {{- .Os }}_
38 {{- if eq .Arch "amd64" }}x86_6437 {{- if eq .Arch "amd64" }}x86_64
39 {{- else if eq .Arch "386" }}i38638 {{- else if eq .Arch "386" }}i386
40 {{- else }}{{ .Arch }}{{ end }}39 {{- else }}{{ .Arch }}{{ end }}
40+ wrap_in_directory: "{{ .ProjectName }}_{{ .Version }}"
41 format_overrides:41 format_overrides:
42 - goos: linux42 - goos: linux
43- format: tar.gz43+ formats: ['tar.gz']
44 files:44 files:
45 - LICENSE45 - LICENSE
46 - README.md46 - README.md
@@ -109,9 +109,16 @@ release:
109 109
110 ### Linux (x86_64/amd64)110 ### Linux (x86_64/amd64)
111 ```bash111 ```bash
112- wget https://github.com/bnema/dumber/releases/download/{{.Tag}}/dumber_{{.Version}}_linux_x86_64.tar.gz112+ # Download latest release
113- tar -xzf dumber_{{.Version}}_linux_x86_64.tar.gz113+ wget https://github.com/bnema/dumber/releases/latest/download/dumber_linux_x86_64.tar.gz
114- sudo install -m 755 dumber /usr/local/bin/114+
115+ # Extract and install to ~/.local/bin (enables auto-updates)
116+ tar -xzf dumber_linux_x86_64.tar.gz
117+ mkdir -p ~/.local/bin
118+ install -m 755 dumber_*/dumber ~/.local/bin/
119+
120+ # Ensure ~/.local/bin is in your PATH (add to ~/.bashrc or ~/.zshrc if needed)
121+ # export PATH="$HOME/.local/bin:$PATH"
115 ```122 ```
116 123
117 ### Dependencies124 ### Dependencies
M .mockery.yaml +13 -0
@@ -8,10 +8,21 @@ packages:
8 filename: mocks.go8 filename: mocks.go
9 pkgname: mocks9 pkgname: mocks
10 interfaces:10 interfaces:
11+ ConfigMigrator: {}
12+ ConfigSchemaProvider: {}
13+ DatabaseProvider: {}
14+ DiffFormatter: {}
11 SessionLogger: {}15 SessionLogger: {}
16+ SessionSpawner: {}
17+ TabListProvider: {}
12 WebView: {}18 WebView: {}
13 WebViewFactory: {}19 WebViewFactory: {}
14 WebViewPool: {}20 WebViewPool: {}
21+ MemoryPressureApplier: {}
22+ WebKitContextProvider: {}
23+ HardwareSurveyor: {}
24+ ColorSchemeDetector: {}
25+ ColorSchemeResolver: {}
15 github.com/bnema/dumber/internal/domain/repository:26 github.com/bnema/dumber/internal/domain/repository:
16 config:27 config:
17 dir: '{{.InterfaceDir}}/mocks'28 dir: '{{.InterfaceDir}}/mocks'
@@ -23,6 +34,7 @@ packages:
23 FolderRepository: {}34 FolderRepository: {}
24 HistoryRepository: {}35 HistoryRepository: {}
25 SessionRepository: {}36 SessionRepository: {}
37+ SessionStateRepository: {}
26 TagRepository: {}38 TagRepository: {}
27 ZoomRepository: {}39 ZoomRepository: {}
28 github.com/bnema/dumber/internal/infrastructure/filtering:40 github.com/bnema/dumber/internal/infrastructure/filtering:
@@ -48,5 +60,6 @@ packages:
48 PaneViewFactory: {}60 PaneViewFactory: {}
49 PanedWidget: {}61 PanedWidget: {}
50 ProgressBarWidget: {}62 ProgressBarWidget: {}
63+ SpinnerWidget: {}
51 Widget: {}64 Widget: {}
52 WidgetFactory: {}65 WidgetFactory: {}
M CHANGELOG.md +107 -2
@@ -4,13 +4,118 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
7+### Fixed
8+- **Dark mode detection on internal pages**: Fixed `dumb://home` and other internal pages showing light theme despite system being in dark mode. Refactored color scheme detection to use a priority-based resolver with proper initialization ordering. The libadwaita detector (highest priority) is now correctly enabled only after `adw.Init()` completes, ensuring WebViews created during bootstrap get the correct theme preference.
9+
10+## [0.22.0] - 2025-12-28
11+
12+### Added
13+- **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.
14+- **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.
15+- **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.
16+- **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.
17+- **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).
18+- **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them.
19+ - `dumber config status`: Shows config file path and number of new settings available.
20+ - `dumber config migrate`: Lists missing keys with types/defaults and adds them to config file.
21+ - GUI toast notification on startup when new settings are available (configurable via `update.notify_on_new_settings`).
22+- **CLI self-update command**: New `dumber update` command to check for and install updates from the command line.
23+ - Interactive spinner animation during check and download phases.
24+ - `--force` / `-f` flag to skip version check and reinstall the current version.
25+ - Reuses existing auto-update infrastructure (check, download, stage, apply on exit).
26+ - Theme-aware styling consistent with other CLI commands.
27+- **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.
28+- **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.
29+- **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.
30+- **Move pane to tab**: New pane-mode actions to move the active pane to another tab.
31+ - `Ctrl+P → m`: Opens a tab picker modal.
32+ - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed).
33+ - New config: `workspace.switch_to_tab_on_move` (default: true).
34+
35+### Changed
36+- **Cold start optimization**: Reduced startup time by ~150ms (29% faster) through parallel initialization.
37+ - New `internal/bootstrap` package encapsulates all startup orchestration.
38+ - `RunParallelInit()` runs 5 goroutines concurrently: directory resolution, pkg-config check, GStreamer check, theme manager creation, and SQLite WASM pre-compilation.
39+ - WebView pool prewarming creates one WebView synchronously so first `Acquire()` is instant.
40+ - Async session cleanup: stale session marking runs in background, doesn't block startup.
41+ - `StartupTimer` logs timing for each bootstrap phase (parallel init, DB/WebKit, app creation).
42+ - Database initialization runs in background goroutine while WebKit stack initializes on main thread.
43+ - Database path now resolved via `config.GetDatabaseFile()` following clean architecture.
44+ - Added connection pool settings optimized for SQLite (`SetMaxOpenConns(1)`, keep-alive).
45+ - Reduced `mmap_size` from 30GB to 256MB (reasonable for browser history DB).
46+ - Added `PRAGMA foreign_keys = ON` for referential integrity.
47+- **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.
48+- **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging.
49+
50+### Fixed
51+- **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.
52+- **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.
53+- **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.
54+- **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.
55+- **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode.
56+- **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes.
57+- **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.
58+- Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.
59+- README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag).
60+
61+## [0.21.0] - 2025-12-26
62+
63+### Added
64+- **Configurable new-pane defaults**: New `workspace.new_pane_url` (default: `about:blank`, supports `dumb://`) and `omnibox.auto_open_on_new_pane` (default: false).
65+- **Consume-or-expel panes (very alpha)**: Niri-style pane consume/expel operation to merge a leaf into a sibling stack or expel a stacked pane into a split. Default global shortcuts: `Alt+[` / `Alt+]` (and `Alt+Shift+[` / `Alt+Shift+]`).
66+- **Zellij-style pane resize mode**: New modal resize mode (`Ctrl+N`) to adjust split sizes with keyboard (`h/j/k/l` or arrow keys, `+/-` smart resize). Shows a per-pane border indicator and keeps the timeout alive while you resize.
67+- **Mouse-driven pane resizing**: Drag split dividers with the mouse; split ratios are persisted in session snapshots (rounded to 2 decimals) and restored on resurrection.
68+- **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters.
69+- **Link URL status overlay**: Shows destination URL in bottom-left corner when hovering over links, images, or media elements. Standard browser UX pattern with 100ms show delay to avoid flicker and 150ms CSS fade transitions. Configurable via CSS.
70+- **Local file path support**: `dumber browse test.html` now correctly opens local HTML files by converting existing file paths to `file://` URLs. Supports absolute paths, relative paths, and `~/` home directory expansion.
71+- **Auto-update system**: Automatic update checking and self-updating binary replacement following clean architecture.
72+ - **Update check on startup**: Async check against GitHub releases API (configurable via `update.enable_on_startup`, default: true).
73+ - **Toast notifications**: Non-intrusive auto-dismiss notifications for update availability.
74+ - **Auto-download**: Optional background download with binary staging (configurable via `update.auto_download`, default: false).
75+ - **Binary self-update**: Atomic binary replacement on clean exit with `.old` backup. Only works if binary is user-writable (e.g., `~/.local/bin/`).
76+ - **CLI update check**: `dumber about` now displays update availability.
77+ - **Version-less release archives**: Goreleaser now produces `dumber_linux_x86_64.tar.gz` (version in directory inside) enabling stable GitHub `/releases/latest/download/` URLs.
78+- **Session management & resurrection**: Zellij-inspired session management system allowing users to save, list, and restore browser sessions.
79+ - **Automatic session snapshots**: Debounced state saving (configurable interval, default 5s) captures tabs, panes, splits, stacks, URLs, titles, and zoom levels.
80+ - **Session Manager modal**: Access via `Ctrl+O → s/w` or `Ctrl+Shift+S` to browse and restore sessions with inline preview of tabs and panes.
81+ - **Session mode**: New modal mode (`Ctrl+O`) with purple border indicator, similar to pane/tab modes.
82+ - **CLI session commands**: `dumber sessions` opens interactive TUI browser; `dumber sessions list` shows saved sessions; `dumber sessions restore <id>` resurrects a session in a new window; `dumber sessions delete <id>` removes a session.
83+ - **Session purging**: Integrated into `dumber purge` command for cleaning up old session data. Simplified UI with single "Sessions" toggle instead of individual selection.
84+ - **Automatic session cleanup**: Sessions are automatically cleaned up on startup based on `max_exited_sessions` (default: 50) and `max_exited_session_age_days` (default: 7 days).
85+ - **Configuration options**: `session.auto_restore`, `session.snapshot_interval_ms`, `session.max_exited_sessions`, `session.max_exited_session_age_days`, plus session mode shortcuts and styling.
86+
87+### Fixed
88+- **Alt/Ctrl+number shortcuts on non-QWERTY keyboards**: Fixed Alt+1-9/0 tab switching and Ctrl+1-9/0 omnibox shortcuts not working on AZERTY, QWERTZ, Dvorak, and other non-QWERTY keyboard layouts. Uses hardware keycodes as fallback when keyval lookup fails, enabling shortcuts to work based on physical key position regardless of layout.
89+- **Idle inhibition leak on pane close**: Fixed D-Bus idle inhibit request not being released when closing a pane/tab while in fullscreen or playing audio. The inhibition would remain active until the app exited. Now properly tracks fullscreen and audio state and releases inhibition before destroying WebView.
90+- **Audio-based idle inhibition**: Extended idle inhibition to also activate when a page is playing audio (e.g., video/music playback), not just in fullscreen mode. Uses WebKit's `notify::is-playing-audio` signal. The inhibitor uses refcounting, so both fullscreen and audio can be active simultaneously.
91+
92+## [0.20.1] - 2025-12-25
93+
94+### Fixed
95+- **Stacked pane favicon and title updates**: Fixed favicon and title not updating in stacked pane title bars by tracking pane IDs in StackedView and properly routing update events to the correct pane.
96+- **WebKit process leak on pane close**: Fixed zombie WebKit web processes not being terminated when panes were closed. `WebView.Destroy()` now properly calls `TerminateWebProcess()` and unparents GTK widgets, freeing GPU resources (VA-API, DMA-BUF, GL contexts). This should significantly improve video playback stability and reduce memory usage when opening/closing many panes.
97+- **White flash on navigation**: Eliminated white page flash when navigating in dark mode by setting WebView background color to match theme and hiding WebView until content commits.
98+- **Dark mode detection on websites**: Fixed `prefers-color-scheme: dark` detection by improving matchMedia query normalization and adding proper event listener stubs. Sites like Reddit now correctly detect dark mode preference.
99+- **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey.
100+- **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.).
101+- **Navigation performance**: Significantly faster page navigation through multiple optimizations:
102+ - Async history recording via background worker (SQLite writes no longer block GTK main thread)
103+ - LRU cache for per-domain zoom levels (repeat visits skip database queries)
104+ - Async favicon preload (cache lookup doesn't block navigation start)
105+ - Progress callback throttling at ~60fps (reduces UI callback overhead)
106+ - Increased WebView pool prewarm from 2 to 4 for faster initial tab creation
107+
108+### Changed
109+- **Custom color picker and confirm dialogs**: Replaced native GTK color picker and confirm dialogs with custom Svelte components using bits-ui AlertDialog. Fixes Wayland compatibility issues where native dialogs would not display properly (fixes #12).
110+- **GSK renderer default**: Changed default from `vulkan` to `auto` to let GTK choose the best renderer and avoid potential DMA-BUF synchronization conflicts with WebKit.
111+- **Removed invalid GStreamer config**: Removed `video_buffer_size_mb` and `queue_buffer_time_sec` config options as these were not valid GStreamer environment variables (they are element properties that cannot be set via env vars).
112+
7 ### Added113 ### Added
114+- **Performance config section**: New `performance` config section with `zoom_cache_size` (default: 256) and `webview_pool_prewarm_count` (default: 4) for tuning. Not exposed in UI but can be set in config file.
8 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.115 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.
9 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.116 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.
10 - **WebUI config improvements**: Search shortcut management (CRUD) and reset-to-defaults support.117 - **WebUI config improvements**: Search shortcut management (CRUD) and reset-to-defaults support.
11 - **Find-in-page**: New find UI and shortcut handling.118 - **Find-in-page**: New find UI and shortcut handling.
12-
13-### Changed
14 - Migrated GUI stack to `puregotk` + `puregotk-webkit` and disabled CGO for pure Go compilation.119 - Migrated GUI stack to `puregotk` + `puregotk-webkit` and disabled CGO for pure Go compilation.
15 - **Omnibox rewrite**: Replaced injected Svelte/JS omnibox with a native GTK4 widget overlay.120 - **Omnibox rewrite**: Replaced injected Svelte/JS omnibox with a native GTK4 widget overlay.
16 - Startup defers non-critical work, adds DNS prefetching and a warmed WebView pool for faster tab/pane creation.121 - Startup defers non-critical work, adds DNS prefetching and a warmed WebView pool for faster tab/pane creation.
M Makefile +6 -1
@@ -1,6 +1,6 @@
1 # Makefile for dumber (Clean Architecture - puregotk)1 # Makefile for dumber (Clean Architecture - puregotk)
2 2
3-.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs3+.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man
4 4
5 # Load local overrides from .env.local if present (Makefile syntax)5 # Load local overrides from .env.local if present (Makefile syntax)
6 ifneq (,$(wildcard .env.local))6 ifneq (,$(wildcard .env.local))
@@ -137,6 +137,11 @@ check: ## Check that all tools and dependencies are working
137 @$(MAKE) test137 @$(MAKE) test
138 @echo "\nAll checks passed!"138 @echo "\nAll checks passed!"
139 139
140+# Documentation
141+man: build-quick ## Install man pages to ~/.local/share/man/man1/
142+ @echo "Installing man pages..."
143+ $(DIST_DIR)/$(BINARY_NAME) gen-docs
144+
140 # Native release targets145 # Native release targets
141 .PHONY: release-snapshot release146 .PHONY: release-snapshot release
142 147
M README.md +98 -36
@@ -23,6 +23,7 @@ A dumb browser that works like your favorite terminal multiplexer.
23 - **Customizable themes**: Light and dark palettes with semantic color tokens.23 - **Customizable themes**: Light and dark palettes with semantic color tokens.
24 - **Persistent storage**: SQLite for history, zoom levels, and settings.24 - **Persistent storage**: SQLite for history, zoom levels, and settings.
25 - **Live configuration**: Single config file with hot reload when possible.25 - **Live configuration**: Single config file with hot reload when possible.
26+- **Session management**: Zellij-style session save/restore with automatic snapshots and resurrection.
26 27
27 ## Status28 ## Status
28 Dumber recently completed a full rewrite in pure Go using the `puregotk` and `puregotk-webkit` libraries.29 Dumber recently completed a full rewrite in pure Go using the `puregotk` and `puregotk-webkit` libraries.
@@ -31,6 +32,8 @@ The project is now structured around clean architecture, which makes it much eas
31 32
32 Early development with regular releases. Core features work well for daily use but expect some rough edges.33 Early development with regular releases. Core features work well for daily use but expect some rough edges.
33 34
35+Some features are very alpha/experimental (notably consume-or-expel panes) and may change behavior between releases.
36+
34 ## Controls & Shortcuts37 ## Controls & Shortcuts
35 38
36 ### Keyboard Shortcuts39 ### Keyboard Shortcuts
@@ -74,14 +77,37 @@ Early development with regular releases. Core features work well for daily use b
74 | **↓** / **D** (in pane mode) | Split Down | Create new pane below |77 | **↓** / **D** (in pane mode) | Split Down | Create new pane below |
75 | **S** (in pane mode) | Stack Pane | Create stacked pane (Zellij-style) |78 | **S** (in pane mode) | Stack Pane | Create stacked pane (Zellij-style) |
76 | **X** (in pane mode) | Close Pane | Close current pane |79 | **X** (in pane mode) | Close Pane | Close current pane |
80+| **m** (in pane mode) | Move Pane To Tab | Opens a tab picker modal |
81+| **M** (in pane mode) | Move Pane To Next Tab | Moves to the next tab, creates new tab if needed |
77 | **Enter** (in pane mode) | Confirm Action | Confirm pane operation |82 | **Enter** (in pane mode) | Confirm Action | Confirm pane operation |
78 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |83 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |
79 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |84 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
80 | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |85 | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |
86+| **Alt+[** / **Alt+]** | Consume / Expel Pane | Experimental (alpha): merge into sibling stack, or expel out of stack |
87+| **Alt+Shift+[** / **Alt+Shift+]** | Consume / Expel Pane (Vertical) | Experimental (alpha): up/down variants |
88+
89+#### Resize Mode
90+| Shortcut | Action | Notes |
91+|----------|--------|-------|
92+| **Ctrl+N** | Enter Resize Mode | Modal mode for pane resizing |
93+| **←/↓/↑/→** / **h/j/k/l** (in resize mode) | Move Divider | Resizes the nearest split by moving the divider |
94+| **H/J/K/L** (in resize mode) | Move Divider (Inverse) | Inverts the direction |
95+| **+ / -** (in resize mode) | Smart Resize | Grow/shrink the active pane (best-effort) |
96+| **Enter** (in resize mode) | Confirm | Exit resize mode |
97+| **Escape** (in resize mode) | Cancel | Exit resize mode |
98+
99+#### Session Management
100+| Shortcut | Action | Notes |
101+|----------|--------|-------|
102+| **Ctrl+O** | Enter Session Mode | Modal mode for session operations |
103+| **Ctrl+Shift+S** | Open Session Manager | Direct access to session browser |
104+| **S** / **W** (in session mode) | Open Session Manager | Browse and restore sessions |
105+| **Escape** (in session mode) | Exit Session Mode | Return to normal navigation |
81 106
82 ### Mouse Controls107 ### Mouse Controls
83 | Action | Result | Notes |108 | Action | Result | Notes |
84 |--------|---------|-------|109 |--------|---------|-------|
110+| **Drag pane divider** | Resize panes | Updates split ratio and persists in session snapshots |
85 | **Ctrl+Scroll Up** | Zoom In | Smooth zoom control |111 | **Ctrl+Scroll Up** | Zoom In | Smooth zoom control |
86 | **Ctrl+Scroll Down** | Zoom Out | Smooth zoom control |112 | **Ctrl+Scroll Down** | Zoom Out | Smooth zoom control |
87 | **Mouse Button 8** | Navigate Back | Side button (back) |113 | **Mouse Button 8** | Navigate Back | Side button (back) |
@@ -108,12 +134,19 @@ All zoom changes are automatically persisted per-domain and restored on next vis
108 134
109 Download the latest release from the [releases page](https://github.com/bnema/dumber/releases):135 Download the latest release from the [releases page](https://github.com/bnema/dumber/releases):
110 ```bash136 ```bash
111-wget https://github.com/bnema/dumber/releases/latest/download/dumber_<version>_linux_x86_64.tar.gz137+# Download latest release
112-tar -xzf dumber_<version>_linux_x86_64.tar.gz138+wget https://github.com/bnema/dumber/releases/latest/download/dumber_linux_x86_64.tar.gz
113-sudo install -m 755 dumber /usr/local/bin/139+
140+# Extract and install to ~/.local/bin (enables auto-updates)
141+tar -xzf dumber_linux_x86_64.tar.gz
142+mkdir -p ~/.local/bin
143+install -m 755 dumber_*/dumber ~/.local/bin/
144+
145+# Ensure ~/.local/bin is in your PATH (add to ~/.bashrc or ~/.zshrc if needed)
146+# export PATH="$HOME/.local/bin:$PATH"
114 ```147 ```
115 148
116-Then install runtime dependencies (see [Install](#install) section for details).149+Then install runtime dependencies (see [Dependencies](#dependencies) section below).
117 150
118 **Option 2: Build From Source**151 **Option 2: Build From Source**
119 152
@@ -142,18 +175,11 @@ Development:
142 175
143 ### Pre-built Binaries (Recommended)176 ### Pre-built Binaries (Recommended)
144 177
145-Pre-built binaries are available for Linux (x86_64/amd64) in the [releases page](https://github.com/bnema/dumber/releases). These include all frontend assets and are ready to use:178+Pre-built binaries are available for Linux (x86_64/amd64) in the [releases page](https://github.com/bnema/dumber/releases). See [Quick Start](#quick-start) for installation instructions.
146-
147-```bash
148-# Download and install latest release
149-wget https://github.com/bnema/dumber/releases/latest/download/dumber_<version>_linux_x86_64.tar.gz
150-tar -xzf dumber_<version>_linux_x86_64.tar.gz
151-sudo install -m 755 dumber /usr/local/bin/
152-```
153 179
154-Replace `<version>` with the desired version (e.g., `0.14.1`), or use the direct link from the releases page.180+### Dependencies
155 181
156-**Dependencies** (required for both pre-built binaries and source builds):182+Required for both pre-built binaries and source builds:
157 183
158 **Arch Linux:**184 **Arch Linux:**
159 ```bash185 ```bash
@@ -210,38 +236,61 @@ sudo apt install va-driver-all
210 - The resulting `./dist/dumber-no-gui` binary runs CLI flows with GUI code stubbed236 - The resulting `./dist/dumber-no-gui` binary runs CLI flows with GUI code stubbed
211 237
212 ## Usage238 ## Usage
213-- Open a URL or search:239+- Open a URL, local file, or search:
214 - `dumber browse https://example.com`240 - `dumber browse https://example.com`
215- - `dumber browse example.com` # scheme auto‑added241+ - `dumber browse example.com` # scheme auto‑added
216- - `dumber browse dumb://home` # built-in home page (stats, shortcuts, etc.)242+ - `dumber browse ./test.html` # local file path (converted to file://)
217- - `dumber browse "!g golang"` # Google search via bang shortcut243+ - `dumber browse dumb://home` # built-in home page (stats, shortcuts, etc.)
244+ - `dumber browse "!g golang"` # search via bang shortcut
218 - Show version information:245 - Show version information:
219- - `dumber about` # display version, commit, and build date246+ - `dumber about` # version, commit, and build date
247+- Check for and install updates:
248+ - `dumber update` # check and install if available
249+ - `dumber update --force` # force reinstall (skips version check)
220 - Launcher integration (dmenu‑style examples with favicon support):250 - Launcher integration (dmenu‑style examples with favicon support):
221 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`251 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`
222 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`252 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
253+ - `dumber dmenu --interactive` # built-in TUI fuzzy finder
254+ - `dumber dmenu --days 7` # show history from last 7 days (default: 30)
255+ - `dumber dmenu --most-visited` # sort by visit count instead of recency
223 - Manage browsing history:256 - Manage browsing history:
224- - `dumber history` # interactive history browser (timeline tabs + fuzzy search)257+ - `dumber history` # interactive history browser (timeline tabs + fuzzy search)
225- - `dumber history --json` # output recent entries as JSON258+ - `dumber history --json` # output recent entries as JSON
226- - `dumber history --json --max 50` # limit JSON output to N entries259+ - `dumber history --json --max 50` # limit JSON output to N entries
227- - `dumber history stats` # show history statistics260+ - `dumber history stats` # show history statistics
228- - `dumber history clear` # interactive cleanup (select time range)261+ - `dumber history clear` # interactive time-range cleanup
229-- Clean up data and cache:262+- Clean up data:
230- - `dumber purge` # purge all data (with confirmation)263+ - `dumber purge` # interactive selection (TUI)
231- - `dumber purge --force` # purge all data (no confirmation)264+ - `dumber purge --force` # remove everything (no prompts)
232- - `dumber purge -d -H -c` # purge database and both caches265+- Manage configuration:
233- - `dumber purge --browser-data` # purge WebKit data (cookies, etc.)266+ - `dumber config status` # show config path and migration availability
267+ - `dumber config migrate` # add missing default settings to config
268+ - `dumber config migrate --yes` # skip confirmation prompt
269+- Manage sessions:
270+ - `dumber sessions` # interactive session browser (TUI)
271+ - `dumber sessions list` # list saved sessions
272+ - `dumber sessions list --json` # output sessions as JSON
273+ - `dumber sessions list --limit 50` # limit number of sessions shown
274+ - `dumber sessions restore <id>` # restore a session in a new window
275+ - `dumber sessions delete <id>` # delete a saved session
234 - Manage logs:276 - Manage logs:
235- - `dumber logs list` # list available log files277+ - `dumber logs` # list sessions with log files
236- - `dumber logs tail` # tail current log file278+ - `dumber logs <session>` # show logs for a session (full ID or unique suffix)
237- - `dumber logs clean` # clean up old log files279+ - `dumber logs -f <session>` # follow logs in real time
280+ - `dumber logs -n 200 <session>` # show last N lines
281+ - `dumber logs clear` # clean up old log files
282+ - `dumber logs clear --all` # remove all log files
283+- Generate documentation:
284+ - `dumber gen-docs` # install man pages to ~/.local/share/man/man1/
285+ - `dumber gen-docs --format markdown` # generate markdown docs to ./docs/
286+ - `dumber gen-docs --output ./man` # generate to custom directory
238 287
239 ### Dmenu mode invocation288 ### Dmenu mode invocation
240 You can invoke dmenu mode in two ways:289 You can invoke dmenu mode in two ways:
241-- Subcommand (recommended): `dumber dmenu` … and `dumber dmenu --select`290+- Piped launcher (generate options): `dumber dmenu`
242-- Root flag (generate options only): `dumber --dmenu`291+- Receive selection from stdin: `dumber dmenu --select`
243 292
244-Note: The root flag path only generates options; for processing a selection (`--select`), use the `dmenu` subcommand as the receiving command.293+For a built-in fuzzy finder UI, use: `dumber dmenu --interactive`
245 294
246 In GUI mode the app serves an embedded home page via `dumb://home`, and frontend assets under `dumb://app/...`.295 In GUI mode the app serves an embedded home page via `dumb://home`, and frontend assets under `dumb://app/...`.
247 296
@@ -259,7 +308,7 @@ Each tab holds its own workspace. Each workspace can contain multiple panes (spl
259 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode308 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode
260 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout309 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout
261 310
262-Tab mode provides visual feedback with an orange border indicator.311+
263 312
264 ## Pane Management313 ## Pane Management
265 314
@@ -276,6 +325,19 @@ Split your browser window into multiple panes, each running independent web sess
276 3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes325 3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes
277 4. **Close**: Press `X` in pane mode to close the current pane326 4. **Close**: Press `X` in pane mode to close the current pane
278 327
328+## Session Management
329+
330+Dumber automatically saves your browser session (tabs, panes, split ratios, URLs) and allows you to restore previous sessions.
331+
332+### How it Works
333+1. **Automatic Snapshots**: Session state is saved automatically (debounced, every 5s by default) and on graceful shutdown
334+2. **Session Manager**: Press `Ctrl+O → s` or `Ctrl+Shift+S` to open the session manager modal
335+3. **Browse Sessions**: View active and exited sessions with tab/pane previews
336+4. **Restore**: Select an exited session to restore it in a new browser window
337+5. **CLI Access**: Use `dumber sessions` for an interactive TUI, or `dumber sessions list/restore/delete` for scripting
338+
339+
340+
279 ## Configuration341 ## Configuration
280 342
281 Configuration file: `~/.config/dumber/config.toml` (supports TOML, JSON, YAML)343 Configuration file: `~/.config/dumber/config.toml` (supports TOML, JSON, YAML)
A assets/logo-32.png +0 -0 binary file: no text diff

binary file: no text diff

M assets/logo.svg +1 -1
@@ -1,5 +1,5 @@
1 <?xml version="1.0" encoding="UTF-8"?>1 <?xml version="1.0" encoding="UTF-8"?>
2-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">2+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512">
3 <defs>3 <defs>
4 <!-- Background gradient - deeper dark -->4 <!-- Background gradient - deeper dark -->
5 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">5 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
M assets/webui_embed.go +5 -0
@@ -14,3 +14,8 @@ var WebUIAssets embed.FS
14 //14 //
15 //go:embed logo.svg15 //go:embed logo.svg
16 var LogoSVG []byte16 var LogoSVG []byte
17+
18+// LogoPNG32 contains the dumber logo as 32x32 PNG for CLI tools like dmenu/rofi.
19+//
20+//go:embed logo-32.png
21+var LogoPNG32 []byte
M cmd/dumber/main.go +307 -156
@@ -6,7 +6,6 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "os/signal"8 "os/signal"
9- "path/filepath"
10 "runtime"9 "runtime"
11 "syscall"10 "syscall"
12 11
@@ -15,20 +14,23 @@ import (
15 "github.com/bnema/dumber/internal/bootstrap"14 "github.com/bnema/dumber/internal/bootstrap"
16 "github.com/bnema/dumber/internal/cli/cmd"15 "github.com/bnema/dumber/internal/cli/cmd"
17 "github.com/bnema/dumber/internal/domain/build"16 "github.com/bnema/dumber/internal/domain/build"
17+ "github.com/bnema/dumber/internal/domain/entity"
18 "github.com/bnema/dumber/internal/domain/repository"18 "github.com/bnema/dumber/internal/domain/repository"
19+ "github.com/bnema/dumber/internal/infrastructure/cache"
19 "github.com/bnema/dumber/internal/infrastructure/clipboard"20 "github.com/bnema/dumber/internal/infrastructure/clipboard"
21+ "github.com/bnema/dumber/internal/infrastructure/colorscheme"
20 "github.com/bnema/dumber/internal/infrastructure/config"22 "github.com/bnema/dumber/internal/infrastructure/config"
21 "github.com/bnema/dumber/internal/infrastructure/deps"23 "github.com/bnema/dumber/internal/infrastructure/deps"
22 "github.com/bnema/dumber/internal/infrastructure/favicon"24 "github.com/bnema/dumber/internal/infrastructure/favicon"
23- "github.com/bnema/dumber/internal/infrastructure/media"25+ "github.com/bnema/dumber/internal/infrastructure/idle"
24 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"26 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
27+ "github.com/bnema/dumber/internal/infrastructure/updater"
25 "github.com/bnema/dumber/internal/logging"28 "github.com/bnema/dumber/internal/logging"
26 "github.com/bnema/dumber/internal/ui"29 "github.com/bnema/dumber/internal/ui"
27 "github.com/bnema/dumber/internal/ui/theme"30 "github.com/bnema/dumber/internal/ui/theme"
28- "github.com/rs/zerolog"
29 )31 )
30 32
31-// Build-time variables (set via ldflags)33+// Build-time variables (set via ldflags).
32 var (34 var (
33 version = "dev"35 version = "dev"
34 commit = "unknown"36 commit = "unknown"
@@ -38,14 +40,16 @@ var (
38 // initialURL holds the URL to open on startup (from browse command).40 // initialURL holds the URL to open on startup (from browse command).
39 var initialURL string41 var initialURL string
40 42
43+// restoreSessionID holds the session ID to restore on startup.
44+var restoreSessionID string
45+
41 func main() {46 func main() {
42 // Run GUI mode for browse command47 // Run GUI mode for browse command
43 if len(os.Args) > 1 && os.Args[1] == "browse" {48 if len(os.Args) > 1 && os.Args[1] == "browse" {
44- // Extract URL if provided
45 if len(os.Args) > 2 {49 if len(os.Args) > 2 {
46 initialURL = os.Args[2]50 initialURL = os.Args[2]
47 }51 }
48- // Strip "browse" and URL from args so GTK doesn't see them52+ restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
49 os.Args = os.Args[:1]53 os.Args = os.Args[:1]
50 os.Exit(runGUI())54 os.Exit(runGUI())
51 return55 return
@@ -64,190 +68,305 @@ func main() {
64 }68 }
65 69
66 func runGUI() int {70 func runGUI() int {
67- // GTK requires all GTK calls to be made from the main thread
68 runtime.LockOSThread()71 runtime.LockOSThread()
72+ timer := bootstrap.NewStartupTimer()
69 73
70 cfg := initConfig()74 cfg := initConfig()
75+ timer.Mark("config")
71 76
72- // Apply optional /opt-style runtime prefix overrides (if configured)77+ ctx := initStartupContext(cfg)
73- deps.ApplyPrefixEnv(cfg.Runtime.Prefix)78+ timer.Mark("logger")
79+ bootstrapLog := logging.FromContext(ctx)
74 80
75- bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)81+ // Parallel phase: directories and theme setup
76- bootstrapLogger.Info().82+ initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
77- Str("version", version).83+ Ctx: ctx,
78- Str("commit", commit).84+ Config: cfg,
79- Str("build_date", buildDate).85+ })
80- Msg("starting dumber")86+ if err != nil {
81-87+ handleParallelInitError(ctx, err)
82- ctx := logging.WithContext(context.Background(), bootstrapLogger)88+ return 1
89+ }
90+ timer.MarkDuration("parallel_phase", initResult.Duration)
83 91
84- dataDir, cacheDir := resolveWebKitDirs(bootstrapLogger)92+ needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore
85 93
86- db, dbCleanup := openDatabase(ctx, bootstrapLogger, dataDir)94+ stack, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB)
95+ if err != nil {
96+ bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit")
97+ }
87 if dbCleanup != nil {98 if dbCleanup != nil {
88 defer dbCleanup()99 defer dbCleanup()
89 }100 }
101+ timer.Mark("db_webkit_parallel")
90 102
91- browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)103+ browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB)
92 if err != nil {104 if err != nil {
93- bootstrapLogger.Fatal().Err(err).Msg("failed to start session")105+ bootstrapLog.Fatal().Err(err).Msg("failed to start session")
94 }106 }
95 if browserSession.LogCleanup != nil {107 if browserSession.LogCleanup != nil {
96 defer browserSession.LogCleanup()108 defer browserSession.LogCleanup()
97 }109 }
98- defer func() {110+ defer func() { _ = browserSession.End(sessionCtx) }()
99- _ = browserSession.End(sessionCtx)111+ timer.Mark("session")
100- }()
101 112
102- logger := browserSession.Logger
103 ctx = sessionCtx113 ctx = sessionCtx
114+ log := logging.FromContext(ctx)
115+ if stack.MessageRouter != nil {
116+ stack.MessageRouter.SetBaseContext(ctx)
117+ }
104 118
105- checkRuntimeRequirements(ctx, cfg, logger)119+ // Repositories and use cases
106- checkMediaRequirements(ctx, cfg, logger)120+ useCases := createUseCases(repos, cfg)
107-121+ if needsEagerDB {
108- themeManager := theme.NewManager(ctx, cfg)122+ handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
109- stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger)123+ }
110- repos := createRepositories(db)
111- useCases := createUseCases(repos, cfg.DefaultWebpageZoom)
112- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases)
113 124
125+ idleInhibitor := idle.NewPortalInhibitor(ctx)
126+ defer func() {
127+ if idleInhibitor != nil {
128+ _ = idleInhibitor.Close()
129+ }
130+ }()
131+ timer.Mark("use_cases")
132+
133+ // Build UI
134+ uiDeps := buildUIDependencies(
135+ ctx, cfg, initResult.ThemeManager,
136+ initResult.ColorResolver, initResult.AdwaitaDetector,
137+ &stack, repos, useCases, idleInhibitor, browserSession.Session.ID,
138+ )
139+ configureDeferredInit(uiDeps, cfg, browserSession)
114 app, err := ui.New(uiDeps)140 app, err := ui.New(uiDeps)
115 if err != nil {141 if err != nil {
116- logger.Error().Err(err).Msg("failed to create application")142+ log.Error().Err(err).Msg("failed to create application")
117 return 1143 return 1
118 }144 }
145+ timer.Mark("ui_deps")
146+ timer.Log(ctx)
119 147
120- // Ensure sessions are closed on Ctrl+C / SIGTERM.148+ // Signal handling
121- // Without this, the process may exit immediately and skip defers,149+ setupSignalHandler(ctx, app)
122- // leaving the DB session stuck in "active" state.
123- sigCh := make(chan os.Signal, 1)
124- signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
125- defer signal.Stop(sigCh)
126- go func() {
127- <-sigCh
128- logger.Info().Msg("received interrupt, quitting")
129- app.Quit()
130- }()
131 150
132- // Run the application151+ return app.Run(ctx, os.Args)
133- exitCode := app.Run(ctx, os.Args)
134- return exitCode
135 }152 }
136 153
137-func initConfig() *config.Config {154+func initStartupContext(cfg *config.Config) context.Context {
138- if err := config.Init(); err != nil {155+ deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
139- fmt.Fprintf(os.Stderr, "Failed to initialize configuration: %v\n", err)156+ bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat(
140- os.Exit(1)157+ cfg.Logging.Level,
141- }158+ cfg.Logging.Format,
142- return config.Get()159+ logging.ConsoleTimeFormat,
160+ )
161+ bootstrapLogger.Info().
162+ Str("version", version).
163+ Str("commit", commit).
164+ Str("build_date", buildDate).
165+ Msg("starting dumber")
166+ ctx := logging.WithContext(context.Background(), bootstrapLogger)
167+ return ctx
143 }168 }
144 169
145-func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {170+func initStackAndRepos(
146- probe := deps.NewPkgConfigProbe()171+ ctx context.Context,
147- checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)172+ cfg *config.Config,
148- runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{173+ initResult *bootstrap.ParallelInitResult,
149- Prefix: cfg.Runtime.Prefix,174+ needsEagerDB bool,
175+) (bootstrap.WebKitStack, *repositories, func(), error) {
176+ if needsEagerDB {
177+ // Parallel phase 2: Database + WebKit stack initialize concurrently
178+ dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{
179+ Ctx: ctx,
180+ Config: cfg,
181+ DataDir: initResult.DataDir,
182+ CacheDir: initResult.CacheDir,
183+ ThemeManager: initResult.ThemeManager,
184+ ColorResolver: initResult.ColorResolver,
185+ })
186+ if err != nil {
187+ return bootstrap.WebKitStack{}, nil, nil, err
188+ }
189+ return dbWebKit.Stack, createRepositories(dbWebKit.DB), dbWebKit.DBCleanup, nil
190+ }
191+
192+ log := logging.FromContext(ctx)
193+ stack := bootstrap.BuildWebKitStack(bootstrap.WebKitStackInput{
194+ Ctx: ctx,
195+ Config: cfg,
196+ DataDir: initResult.DataDir,
197+ CacheDir: initResult.CacheDir,
198+ ThemeManager: initResult.ThemeManager,
199+ ColorResolver: initResult.ColorResolver,
200+ Logger: *log,
150 })201 })
202+
203+ lazyDB, err := bootstrap.CreateLazyDatabase()
151 if err != nil {204 if err != nil {
152- logger.Fatal().Err(err).Msg("runtime requirements check failed")205+ return stack, nil, nil, err
153 }206 }
154- if runtimeOut.OK {207+ dbCleanup := func() { _ = lazyDB.Close() }
208+ return stack, createLazyRepositories(lazyDB), dbCleanup, nil
209+}
210+
211+func configureDeferredInit(
212+ uiDeps *ui.Dependencies,
213+ cfg *config.Config,
214+ session *bootstrap.BrowserSession,
215+) {
216+ if uiDeps == nil {
155 return217 return
156 }218 }
157- for _, c := range runtimeOut.Checks {219+ uiDeps.OnFirstWebViewShown = func(cbCtx context.Context) {
158- if c.Installed {220+ logger := logging.FromContext(cbCtx)
159- logger.Error().221+ bgCtx := logging.WithContext(context.Background(), *logger)
160- Str("dependency", c.PkgConfigName).222+ go func() {
161- Str("have", c.Version).223+ result := bootstrap.RunDeferredInit(bootstrap.DeferredInitInput{
162- Str("need", c.RequiredVersion).224+ Ctx: bgCtx,
163- Bool("ok", c.MeetsRequirement).225+ Config: cfg,
164- Msg("runtime dependency")226+ })
165- } else {227+ logDeferredInitResults(bgCtx, result)
166- logger.Error().228+ }()
167- Str("dependency", c.PkgConfigName).229+ if session != nil && session.Persist != nil {
168- Str("need", c.RequiredVersion).230+ go func() {
169- Msg("runtime dependency missing")231+ if persistErr := session.Persist(bgCtx); persistErr != nil {
232+ logger.Error().Err(persistErr).Msg("deferred session persistence failed")
233+ }
234+ }()
170 }235 }
171 }236 }
172- logger.Fatal().
173- Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
174- Msg("runtime requirements not met")
175 }237 }
176 238
177-func checkMediaRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {239+func initConfig() *config.Config {
178- mediaDiagAdapter := media.New()240+ if err := config.Init(); err != nil {
179- checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)241+ fmt.Fprintf(os.Stderr, "Failed to initialize configuration: %v\n", err)
180- if _, mediaErr := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{242+ os.Exit(1)
181- ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,243+ }
182- }); mediaErr != nil {244+ return config.Get()
183- logger.Fatal().Err(mediaErr).Msg("media requirements check failed")245+}
246+
247+func handleParallelInitError(ctx context.Context, err error) {
248+ log := logging.FromContext(ctx)
249+ if runtimeErr, ok := err.(*bootstrap.RuntimeRequirementsError); ok {
250+ runtimeErr.LogDetails(ctx)
251+ log.Fatal().Err(runtimeErr).
252+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
253+ Msg("runtime requirements not met")
184 }254 }
255+ log.Fatal().Err(err).Msg("initialization failed")
185 }256 }
186 257
187-func resolveWebKitDirs(logger zerolog.Logger) (string, string) {258+func logDeferredInitResults(ctx context.Context, result bootstrap.DeferredInitResult) {
188- const cacheDirPerm = 0o755259+ log := logging.FromContext(ctx)
189- dataDir, err := config.GetDataDir()260+ if result.SQLiteErr != nil {
190- if err != nil {261+ log.Warn().Err(result.SQLiteErr).Msg("deferred sqlite wasm init failed")
191- logger.Fatal().Err(err).Msg("failed to resolve data directory")
192 }262 }
193- stateDir, err := config.GetStateDir()263+ if result.RuntimeErr != nil {
194- if err != nil {264+ if runtimeErr, ok := result.RuntimeErr.(*bootstrap.RuntimeRequirementsError); ok {
195- logger.Fatal().Err(err).Msg("failed to resolve state directory")265+ runtimeErr.LogDetails(ctx)
266+ log.Warn().Err(runtimeErr).
267+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
268+ Msg("runtime requirements not met")
269+ } else {
270+ log.Warn().Err(result.RuntimeErr).Msg("runtime requirements check failed")
271+ }
196 }272 }
197- cacheDir := filepath.Join(stateDir, "webkit-cache")273+ if result.MediaErr != nil {
198- if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {274+ log.Warn().Err(result.MediaErr).Msg("media check failed")
199- logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory")
200 }275 }
201- return dataDir, cacheDir276+ log.Debug().Dur("duration", result.Duration).Msg("deferred init complete")
202 }277 }
203 278
204-func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (*sql.DB, func()) {279+func handleAutoRestore(
205- dbPath := filepath.Join(dataDir, "dumber.db")280+ ctx context.Context,
206- db, err := sqlite.NewConnection(ctx, dbPath)281+ cfg *config.Config,
282+ uc *useCases,
283+ currentSessionID entity.SessionID,
284+) {
285+ if !cfg.Session.AutoRestore || restoreSessionID != "" {
286+ return
287+ }
288+ log := logging.FromContext(ctx)
289+ out, err := uc.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{
290+ ExcludeSessionID: currentSessionID,
291+ })
207 if err != nil {292 if err != nil {
208- logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database")293+ log.Warn().Err(err).Msg("auto-restore: failed to find restorable session")
294+ return
209 }295 }
210- cleanup := func() {296+ if out.SessionID != "" {
211- if err := sqlite.Close(db); err != nil {297+ restoreSessionID = string(out.SessionID)
212- logger.Error().Err(err).Msg("failed to close database")298+ log.Info().
213- }299+ Str("session_id", restoreSessionID).
300+ Int("tabs", len(out.State.Tabs)).
301+ Msg("auto-restore: found last session")
214 }302 }
215- return db, cleanup303+}
304+
305+func setupSignalHandler(ctx context.Context, app *ui.App) {
306+ log := logging.FromContext(ctx)
307+ sigCh := make(chan os.Signal, 1)
308+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
309+ go func() {
310+ sig := <-sigCh
311+ signal.Stop(sigCh)
312+ log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting")
313+ app.Quit()
314+ }()
216 }315 }
217 316
218 // repositories groups infrastructure layer repository implementations.317 // repositories groups infrastructure layer repository implementations.
219 type repositories struct {318 type repositories struct {
220- history repository.HistoryRepository319+ history repository.HistoryRepository
221- favorite repository.FavoriteRepository320+ favorite repository.FavoriteRepository
222- folder repository.FolderRepository321+ folder repository.FolderRepository
223- tag repository.TagRepository322+ tag repository.TagRepository
224- zoom repository.ZoomRepository323+ zoom repository.ZoomRepository
324+ session repository.SessionRepository
325+ sessionState repository.SessionStateRepository
225 }326 }
226 327
227 func createRepositories(db *sql.DB) *repositories {328 func createRepositories(db *sql.DB) *repositories {
228 return &repositories{329 return &repositories{
229- history: sqlite.NewHistoryRepository(db),330+ history: sqlite.NewHistoryRepository(db),
230- favorite: sqlite.NewFavoriteRepository(db),331+ favorite: sqlite.NewFavoriteRepository(db),
231- folder: sqlite.NewFolderRepository(db),332+ folder: sqlite.NewFolderRepository(db),
232- tag: sqlite.NewTagRepository(db),333+ tag: sqlite.NewTagRepository(db),
233- zoom: sqlite.NewZoomRepository(db),334+ zoom: sqlite.NewZoomRepository(db),
335+ session: sqlite.NewSessionRepository(db),
336+ sessionState: sqlite.NewSessionStateRepository(db),
337+ }
338+}
339+
340+func createLazyRepositories(provider port.DatabaseProvider) *repositories {
341+ return &repositories{
342+ history: sqlite.NewLazyHistoryRepository(provider),
343+ favorite: sqlite.NewLazyFavoriteRepository(provider),
344+ folder: sqlite.NewLazyFolderRepository(provider),
345+ tag: sqlite.NewLazyTagRepository(provider),
346+ zoom: sqlite.NewLazyZoomRepository(provider),
347+ session: sqlite.NewLazySessionRepository(provider),
348+ sessionState: sqlite.NewLazySessionStateRepository(provider),
234 }349 }
235 }350 }
236 351
237 // useCases groups application layer use case implementations.352 // useCases groups application layer use case implementations.
238 type useCases struct {353 type useCases struct {
239- tabs *usecase.ManageTabsUseCase354+ tabs *usecase.ManageTabsUseCase
240- panes *usecase.ManagePanesUseCase355+ panes *usecase.ManagePanesUseCase
241- history *usecase.SearchHistoryUseCase356+ history *usecase.SearchHistoryUseCase
242- favorites *usecase.ManageFavoritesUseCase357+ favorites *usecase.ManageFavoritesUseCase
243- zoom *usecase.ManageZoomUseCase358+ zoom *usecase.ManageZoomUseCase
244- navigate *usecase.NavigateUseCase359+ navigate *usecase.NavigateUseCase
245- copyURL *usecase.CopyURLUseCase360+ copyURL *usecase.CopyURLUseCase
246- clipboard port.Clipboard361+ snapshot *usecase.SnapshotSessionUseCase
247- favicon *favicon.Service362+ lastRestorable *usecase.GetLastRestorableSessionUseCase
363+ checkUpdate *usecase.CheckUpdateUseCase
364+ applyUpdate *usecase.ApplyUpdateUseCase
365+ clipboard port.Clipboard
366+ favicon *favicon.Service
248 }367 }
249 368
250-func createUseCases(repos *repositories, defaultZoom float64) *useCases {369+func createUseCases(repos *repositories, cfg *config.Config) *useCases {
251 const idAlphabetSize = 26370 const idAlphabetSize = 26
252 idCounter := uint64(0)371 idCounter := uint64(0)
253 idGenerator := func() string {372 idGenerator := func() string {
@@ -257,17 +376,35 @@ func createUseCases(repos *repositories, defaultZoom float64) *useCases {
257 376
258 clipboardAdapter := clipboard.New()377 clipboardAdapter := clipboard.New()
259 faviconCacheDir, _ := config.GetFaviconCacheDir()378 faviconCacheDir, _ := config.GetFaviconCacheDir()
379+ xdgDirs, _ := config.GetXDGDirs()
380+ stateDir, _ := config.GetStateDir()
381+ defaultZoom := cfg.DefaultWebpageZoom
382+ zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
383+
384+ buildInfo := build.Info{
385+ Version: version,
386+ Commit: commit,
387+ BuildDate: buildDate,
388+ GoVersion: runtime.Version(),
389+ }
390+ updateChecker := updater.NewGitHubChecker()
391+ updateDownloader := updater.NewGitHubDownloader()
392+ updateApplier := updater.NewApplier(stateDir)
260 393
261 return &useCases{394 return &useCases{
262- tabs: usecase.NewManageTabsUseCase(idGenerator),395+ tabs: usecase.NewManageTabsUseCase(idGenerator),
263- panes: usecase.NewManagePanesUseCase(idGenerator),396+ panes: usecase.NewManagePanesUseCase(idGenerator),
264- history: usecase.NewSearchHistoryUseCase(repos.history),397+ history: usecase.NewSearchHistoryUseCase(repos.history),
265- favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),398+ favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
266- zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom),399+ zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
267- navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),400+ navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
268- copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),401+ copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
269- clipboard: clipboardAdapter,402+ snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
270- favicon: favicon.NewService(faviconCacheDir),403+ lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),
404+ checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
405+ applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),
406+ clipboard: clipboardAdapter,
407+ favicon: favicon.NewService(faviconCacheDir),
271 }408 }
272 }409 }
273 410
@@ -275,32 +412,46 @@ func buildUIDependencies(
275 ctx context.Context,412 ctx context.Context,
276 cfg *config.Config,413 cfg *config.Config,
277 themeManager *theme.Manager,414 themeManager *theme.Manager,
415+ colorResolver port.ColorSchemeResolver,
416+ adwaitaDetector *colorscheme.AdwaitaDetector,
278 stack *bootstrap.WebKitStack,417 stack *bootstrap.WebKitStack,
279 repos *repositories,418 repos *repositories,
280 uc *useCases,419 uc *useCases,
420+ idleInhibitor port.IdleInhibitor,
421+ currentSessionID entity.SessionID,
281 ) *ui.Dependencies {422 ) *ui.Dependencies {
282 return &ui.Dependencies{423 return &ui.Dependencies{
283- Ctx: ctx,424+ Ctx: ctx,
284- Config: cfg,425+ Config: cfg,
285- InitialURL: initialURL,426+ InitialURL: initialURL,
286- Theme: themeManager,427+ RestoreSessionID: restoreSessionID,
287- WebContext: stack.Context,428+ Theme: themeManager,
288- Pool: stack.Pool,429+ ColorResolver: colorResolver,
289- Settings: stack.Settings,430+ AdwaitaDetector: adwaitaDetector,
290- Injector: stack.Injector,431+ WebContext: stack.Context,
291- MessageRouter: stack.MessageRouter,432+ Pool: stack.Pool,
292- FilterManager: stack.FilterManager,433+ Settings: stack.Settings,
293- HistoryRepo: repos.history,434+ Injector: stack.Injector,
294- FavoriteRepo: repos.favorite,435+ MessageRouter: stack.MessageRouter,
295- ZoomRepo: repos.zoom,436+ FilterManager: stack.FilterManager,
296- TabsUC: uc.tabs,437+ HistoryRepo: repos.history,
297- PanesUC: uc.panes,438+ FavoriteRepo: repos.favorite,
298- HistoryUC: uc.history,439+ ZoomRepo: repos.zoom,
299- FavoritesUC: uc.favorites,440+ TabsUC: uc.tabs,
300- ZoomUC: uc.zoom,441+ PanesUC: uc.panes,
301- NavigateUC: uc.navigate,442+ HistoryUC: uc.history,
302- CopyURLUC: uc.copyURL,443+ FavoritesUC: uc.favorites,
303- Clipboard: uc.clipboard,444+ ZoomUC: uc.zoom,
304- FaviconService: uc.favicon,445+ NavigateUC: uc.navigate,
446+ CopyURLUC: uc.copyURL,
447+ Clipboard: uc.clipboard,
448+ FaviconService: uc.favicon,
449+ IdleInhibitor: idleInhibitor,
450+ SessionRepo: repos.session,
451+ SessionStateRepo: repos.sessionState,
452+ CurrentSessionID: currentSessionID,
453+ SnapshotUC: uc.snapshot,
454+ CheckUpdateUC: uc.checkUpdate,
455+ ApplyUpdateUC: uc.applyUpdate,
305 }456 }
306 }457 }
M docs/CONFIG.md +246 -22
@@ -83,14 +83,14 @@ These settings control the `dumber dmenu` CLI command for rofi/fuzzel integratio
83 83
84 | Key | Type | Default | Description |84 | Key | Type | Default | Description |
85 |-----|------|---------|-------------|85 |-----|------|---------|-------------|
86-| `dmenu.max_history_items` | int | `20` | Max items shown in launcher |86+| `dmenu.max_history_days` | int | `30` | Number of days of history to show (0 = all) |
87 | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output |87 | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output |
88 | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output |88 | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output |
89 | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items |89 | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items |
90 | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts |90 | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts |
91 | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs |91 | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs |
92 | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string |92 | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string |
93-| `dmenu.sort_by_visit_count` | bool | `true` | Sort by popularity |93+| `dmenu.sort_by_visit_count` | bool | `true` | Sort by visit count instead of recency |
94 94
95 **CLI Usage:**95 **CLI Usage:**
96 ```bash96 ```bash
@@ -101,8 +101,14 @@ dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select
101 # Interactive TUI mode101 # Interactive TUI mode
102 dumber dmenu --interactive102 dumber dmenu --interactive
103 103
104-# Override max items via CLI flag104+# Override history days via CLI flag (show last 7 days)
105-dumber dmenu --max 50105+dumber dmenu --days 7
106+
107+# Sort by most visited instead of recency
108+dumber dmenu --most-visited
109+
110+# Combined: most visited from last 14 days
111+dumber dmenu --days 14 --most-visited
106 ```112 ```
107 113
108 ## Omnibox114 ## Omnibox
@@ -110,11 +116,13 @@ dumber dmenu --max 50
110 | Key | Type | Default | Valid Values | Description |116 | Key | Type | Default | Valid Values | Description |
111 |-----|------|---------|--------------|-------------|117 |-----|------|---------|--------------|-------------|
112 | `omnibox.initial_behavior` | string | `"recent"` | `recent`, `most_visited`, `none` | Initial history display behavior |118 | `omnibox.initial_behavior` | string | `"recent"` | `recent`, `most_visited`, `none` | Initial history display behavior |
119+| `omnibox.auto_open_on_new_pane` | bool | `false` | - | Automatically open the omnibox after creating a new pane |
113 120
114 **Example:**121 **Example:**
115 ```toml122 ```toml
116 [omnibox]123 [omnibox]
117 initial_behavior = "recent" # Show recent history when omnibox opens124 initial_behavior = "recent" # Show recent history when omnibox opens
125+auto_open_on_new_pane = false
118 126
119 # Alternative options:127 # Alternative options:
120 # initial_behavior = "most_visited" # Show most visited sites128 # initial_behavior = "most_visited" # Show most visited sites
@@ -182,7 +190,7 @@ border = "#363636"
182 | `rendering.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit DMA-BUF renderer (may fix flicker on Wayland; slower) |190 | `rendering.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit DMA-BUF renderer (may fix flicker on Wayland; slower) |
183 | `rendering.force_compositing_mode` | bool | `false` | - | Force WebKit compositing mode (`WEBKIT_FORCE_COMPOSITING_MODE`) |191 | `rendering.force_compositing_mode` | bool | `false` | - | Force WebKit compositing mode (`WEBKIT_FORCE_COMPOSITING_MODE`) |
184 | `rendering.disable_compositing_mode` | bool | `false` | - | Disable WebKit compositing mode (`WEBKIT_DISABLE_COMPOSITING_MODE`) |192 | `rendering.disable_compositing_mode` | bool | `false` | - | Disable WebKit compositing mode (`WEBKIT_DISABLE_COMPOSITING_MODE`) |
185-| `rendering.gsk_renderer` | string | `"vulkan"` | `auto`, `opengl`, `vulkan`, `cairo` | GTK renderer selection (`GSK_RENDERER`) |193+| `rendering.gsk_renderer` | string | `"auto"` | `auto`, `opengl`, `vulkan`, `cairo` | GTK renderer selection (`GSK_RENDERER`) |
186 | `rendering.disable_mipmaps` | bool | `false` | - | Disable GTK mipmaps (`GSK_GPU_DISABLE=mipmap`) |194 | `rendering.disable_mipmaps` | bool | `false` | - | Disable GTK mipmaps (`GSK_GPU_DISABLE=mipmap`) |
187 | `rendering.prefer_gl` | bool | `false` | - | Prefer OpenGL over GLES (`GDK_DEBUG=gl-prefer-gl`) |195 | `rendering.prefer_gl` | bool | `false` | - | Prefer OpenGL over GLES (`GDK_DEBUG=gl-prefer-gl`) |
188 | `rendering.draw_compositing_indicators` | bool | `false` | - | Draw WebKit compositing indicators (debug) |196 | `rendering.draw_compositing_indicators` | bool | `false` | - | Draw WebKit compositing indicators (debug) |
@@ -194,6 +202,19 @@ border = "#363636"
194 202
195 ## Workspace Configuration203 ## Workspace Configuration
196 204
205+### General
206+
207+| Key | Type | Default | Description |
208+|-----|------|---------|-------------|
209+| `workspace.new_pane_url` | string | `"about:blank"` | URL loaded for new panes/tabs (supports `http(s)://`, `dumb://`, `file://`, `about:`) |
210+| `workspace.switch_to_tab_on_move` | bool | `true` | When moving a pane to another tab, automatically switch to the destination tab |
211+
212+**Example:**
213+```toml
214+[workspace]
215+new_pane_url = "dumb://home"
216+```
217+
197 ### Pane Mode218 ### Pane Mode
198 219
199 | Key | Type | Default | Description |220 | Key | Type | Default | Description |
@@ -211,6 +232,15 @@ split-up = ["arrowup", "u"]
211 split-down = ["arrowdown", "d"]232 split-down = ["arrowdown", "d"]
212 stack-pane = ["s"]233 stack-pane = ["s"]
213 close-pane = ["x"]234 close-pane = ["x"]
235+move-pane-to-tab = ["m"]
236+move-pane-to-next-tab = ["M", "shift+m"]
237+
238+# Consume-or-expel (niri-style) - very alpha
239+consume-or-expel-left = ["bracketleft", "["]
240+consume-or-expel-right = ["bracketright", "]"]
241+consume-or-expel-up = ["shift+bracketleft", "braceleft", "{"]
242+consume-or-expel-down = ["shift+bracketright", "braceright", "}"]
243+
214 focus-right = ["shift+arrowright", "shift+l"]244 focus-right = ["shift+arrowright", "shift+l"]
215 focus-left = ["shift+arrowleft", "shift+h"]245 focus-left = ["shift+arrowleft", "shift+h"]
216 focus-up = ["shift+arrowup", "shift+k"]246 focus-up = ["shift+arrowup", "shift+k"]
@@ -243,6 +273,38 @@ cancel = ["escape"]
243 273
244 > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.274 > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.
245 275
276+### Resize Mode
277+
278+| Key | Type | Default | Description |
279+|-----|------|---------|-------------|
280+| `workspace.resize_mode.activation_shortcut` | string | `"ctrl+n"` | Resize mode activation key |
281+| `workspace.resize_mode.timeout_ms` | int | `3000` | Resize mode timeout (ms) |
282+| `workspace.resize_mode.step_percent` | float | `5.0` | Split ratio step per keystroke (percent) |
283+| `workspace.resize_mode.min_pane_percent` | float | `10.0` | Minimum pane size (percent) |
284+| `workspace.resize_mode.actions` | map | See below | Action→keys mappings |
285+
286+**Default resize mode actions:**
287+```toml
288+[workspace.resize_mode.actions]
289+resize-increase-left = ["h", "arrowleft"]
290+resize-increase-down = ["j", "arrowdown"]
291+resize-increase-up = ["k", "arrowup"]
292+resize-increase-right = ["l", "arrowright"]
293+resize-decrease-left = ["H"]
294+resize-decrease-down = ["J"]
295+resize-decrease-up = ["K"]
296+resize-decrease-right = ["L"]
297+resize-increase = ["+", "="]
298+resize-decrease = ["-"]
299+confirm = ["enter"]
300+cancel = ["escape"]
301+```
302+
303+Notes:
304+- Directional actions (`resize-increase-*/resize-decrease-*`) move the split divider.
305+- Smart actions (`resize-increase` / `resize-decrease`) grow/shrink the active pane (best-effort) by picking a direction automatically.
306+- Timeout is refreshed on each resize keypress so you can keep adjusting without re-entering the mode.
307+
246 ### Global Shortcuts308 ### Global Shortcuts
247 309
248 | Key | Type | Default | Description |310 | Key | Type | Default | Description |
@@ -250,8 +312,14 @@ cancel = ["escape"]
250 | `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) |312 | `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) |
251 | `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut |313 | `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut |
252 | `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut |314 | `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut |
315+| `workspace.shortcuts.consume_or_expel_left` | string | `"alt+bracketleft"` | Consume into left sibling stack, or expel left if stacked (very alpha) |
316+| `workspace.shortcuts.consume_or_expel_right` | string | `"alt+bracketright"` | Consume into right sibling stack, or expel right if stacked (very alpha) |
317+| `workspace.shortcuts.consume_or_expel_up` | string | `"alt+shift+bracketleft"` | Consume into upper sibling stack, or expel up if stacked (very alpha) |
318+| `workspace.shortcuts.consume_or_expel_down` | string | `"alt+shift+bracketright"` | Consume into lower sibling stack, or expel down if stacked (very alpha) |
253 319
254 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.320 > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface.
321+>
322+> **Note:** Consume-or-expel is experimental and may change behavior between releases.
255 323
256 ### Popup Behavior324 ### Popup Behavior
257 325
@@ -271,24 +339,72 @@ cancel = ["escape"]
271 |-----|------|---------|-------------|339 |-----|------|---------|-------------|
272 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |340 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |
273 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |341 | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color |
274-| `workspace.styling.pane_mode_border_width` | int | `4` | Pane mode border width (px) - Ctrl+P N overlay |342+| `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes |
275-| `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) |343+| `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue) - used for border and toaster |
276-| `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay |344+| `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster |
277-| `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |345+| `workspace.styling.session_mode_color` | string | `"#9B59B6"` | Session mode color (purple) - used for border and toaster |
346+| `workspace.styling.resize_mode_color` | string | `"#00D4AA"` | Resize mode color (teal) - used for border and toaster |
347+| `workspace.styling.mode_indicator_toaster_enabled` | bool | `true` | Show toaster notification when modal modes are active |
278 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |348 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
279 349
350+**Example:**
351+```toml
352+[workspace.styling]
353+border_width = 1
354+border_color = "@theme_selected_bg_color"
355+mode_border_width = 4
356+pane_mode_color = "#4A90E2" # Blue for pane mode
357+tab_mode_color = "#FFA500" # Orange for tab mode
358+session_mode_color = "#9B59B6" # Purple for session mode
359+resize_mode_color = "#00D4AA" # Teal for resize mode
360+mode_indicator_toaster_enabled = true
361+transition_duration = 120
362+```
363+
364+## Session
365+
366+| Key | Type | Default | Description |
367+|-----|------|---------|-------------|
368+| `session.auto_restore` | bool | `false` | Automatically restore the last session on startup |
369+| `session.snapshot_interval_ms` | int | `5000` | Minimum interval between snapshots in milliseconds |
370+| `session.max_exited_sessions` | int | `50` | Maximum number of exited sessions to keep |
371+| `session.max_exited_session_age_days` | int | `7` | Maximum age in days for exited sessions (auto-deleted on startup) |
372+
373+### Session Mode
374+
375+| Key | Type | Default | Description |
376+|-----|------|---------|-------------|
377+| `session.session_mode.activation_shortcut` | string | `"ctrl+o"` | Session mode activation key |
378+| `session.session_mode.timeout_ms` | int | `3000` | Session mode timeout (ms) |
379+| `session.session_mode.actions` | map | See below | Action to keys mappings |
380+
381+**Default session mode actions:**
382+```toml
383+[session.session_mode.actions]
384+session-manager = ["s", "w"]
385+confirm = ["enter"]
386+cancel = ["escape"]
387+```
388+
389+**Example:**
390+```toml
391+[session]
392+auto_restore = false # Don't auto-restore on startup
393+snapshot_interval_ms = 5000 # Save state every 5 seconds (debounced)
394+max_exited_sessions = 50 # Keep last 50 exited sessions
395+max_exited_session_age_days = 7 # Delete sessions older than 7 days on startup
396+```
397+
280 ## Media398 ## Media
281 399
282 | Key | Type | Default | Valid Values | Description |400 | Key | Type | Default | Valid Values | Description |
283 |-----|------|---------|--------------|-------------|401 |-----|------|---------|--------------|-------------|
284 | `media.hardware_decoding` | string | `"auto"` | `auto`, `force`, `disable` | Hardware video decoding mode |402 | `media.hardware_decoding` | string | `"auto"` | `auto`, `force`, `disable` | Hardware video decoding mode |
285-| `media.prefer_av1` | bool | `true` | - | Prefer AV1 codec when available |403+| `media.prefer_av1` | bool | `false` | - | Prefer AV1 codec when available |
286-| `media.show_diagnostics` | bool | `true` | - | Show media diagnostics warnings at startup |404+| `media.show_diagnostics` | bool | `false` | - | Show media diagnostics warnings at startup |
287 | `media.force_vsync` | bool | `false` | - | Force VSync for video playback (may help with tearing) |405 | `media.force_vsync` | bool | `false` | - | Force VSync for video playback (may help with tearing) |
288 | `media.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | OpenGL API selection for video rendering |406 | `media.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | OpenGL API selection for video rendering |
289 | `media.gstreamer_debug_level` | int | `0` | `0-5` | GStreamer debug verbosity (0=off) |407 | `media.gstreamer_debug_level` | int | `0` | `0-5` | GStreamer debug verbosity (0=off) |
290-| `media.video_buffer_size_mb` | int | `64` | > 0 | Video buffer size in MB for smoother streaming |
291-| `media.queue_buffer_time_sec` | int | `20` | > 0 | Queue prebuffer time in seconds |
292 408
293 **Hardware decoding modes:**409 **Hardware decoding modes:**
294 - `auto` (recommended): Hardware preferred with software fallback - fixes Twitch Error #4000410 - `auto` (recommended): Hardware preferred with software fallback - fixes Twitch Error #4000
@@ -307,21 +423,15 @@ Dumber automatically detects your GPU vendor (AMD/Intel/NVIDIA) and sets optimal
307 - **Intel**: Uses `iHD` driver (modern, for Broadwell+)423 - **Intel**: Uses `iHD` driver (modern, for Broadwell+)
308 - **NVIDIA**: Uses `nvidia` driver with EGL platform424 - **NVIDIA**: Uses `nvidia` driver with EGL platform
309 425
310-**Buffering settings:**
311-- `video_buffer_size_mb`: Controls GStreamer buffer size. Larger buffers reduce rebuffering on bursty streams (Twitch, YouTube). Uses more memory.
312-- `queue_buffer_time_sec`: Controls prebuffer duration. Higher values allow more data to be buffered ahead of playback.
313-
314 **Example:**426 **Example:**
315 ```toml427 ```toml
316 [media]428 [media]
317 hardware_decoding = "auto" # HW preferred, SW fallback429 hardware_decoding = "auto" # HW preferred, SW fallback
318-prefer_av1 = true # AV1 is most efficient codec430+prefer_av1 = false # Let site choose codec
319-show_diagnostics = true # Log warnings if HW accel unavailable431+show_diagnostics = false # Enable for debugging
320 force_vsync = false # Let compositor handle VSync432 force_vsync = false # Let compositor handle VSync
321 gl_rendering_mode = "auto" # GStreamer picks best GL API433 gl_rendering_mode = "auto" # GStreamer picks best GL API
322 gstreamer_debug_level = 0 # Increase to 3-5 for debugging434 gstreamer_debug_level = 0 # Increase to 3-5 for debugging
323-video_buffer_size_mb = 64 # 64 MB buffer for smooth streaming
324-queue_buffer_time_sec = 20 # 20 seconds prebuffer
325 ```435 ```
326 436
327 **Diagnostics CLI:**437 **Diagnostics CLI:**
@@ -359,6 +469,120 @@ Notes:
359 - Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases.469 - Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases.
360 - Domain whitelist is managed via the database (`content_whitelist` table), not the config file.470 - Domain whitelist is managed via the database (`content_whitelist` table), not the config file.
361 471
472+## Update
473+
474+| Key | Type | Default | Description |
475+|-----|------|---------|-------------|
476+| `update.enable_on_startup` | bool | `true` | Check for updates when browser starts |
477+| `update.auto_download` | bool | `false` | Automatically download updates in background |
478+| `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available |
479+
480+**Example:**
481+```toml
482+[update]
483+enable_on_startup = true # Check for updates on startup
484+auto_download = false # Don't auto-download (prompt instead)
485+notify_on_new_settings = true # Show toast when config migration available
486+```
487+
488+**CLI Commands:**
489+```bash
490+# Check config status and available migrations
491+dumber config status
492+
493+# Add missing settings with default values
494+dumber config migrate
495+
496+# Skip confirmation prompt
497+dumber config migrate --yes
498+```
499+
500+## Performance Profiles
501+
502+Performance profiles provide preset configurations for WebKitGTK tuning. These settings affect Skia rendering threads, memory pressure handling, and WebView pool behavior.
503+
504+> **Note:** Performance settings are applied at browser startup. Changes require a restart to take effect.
505+
506+| Key | Type | Default | Valid Values | Description |
507+|-----|------|---------|--------------|-------------|
508+| `performance.profile` | string | `"default"` | `default`, `lite`, `max`, `custom` | Performance profile selection |
509+
510+### Profiles
511+
512+| Profile | Description | Use Case |
513+|---------|-------------|----------|
514+| `default` | No tuning, uses WebKit defaults | Normal browsing, most users |
515+| `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving |
516+| `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems |
517+| `custom` | Manual control over all settings | Advanced users who want fine-grained control |
518+
519+### Profile Settings Matrix
520+
521+| Setting | default | lite | max |
522+|---------|---------|------|-----|
523+| Skia CPU threads | unset | 2 | `NumCPU()/2` (min 4) |
524+| Skia GPU threads | unset | unset | scales with VRAM |
525+| Web process memory (MB) | unset | 768 | unset |
526+| Network process memory (MB) | unset | 384 | unset |
527+| Conservative threshold | unset | 0.25 | unset |
528+| Strict threshold | unset | 0.4 | unset |
529+| WebView pool prewarm | 4 | 2 | scales with RAM |
530+
531+**Example:**
532+```toml
533+[performance]
534+profile = "default" # Most users - no tuning needed
535+
536+# For low-RAM systems:
537+# profile = "lite"
538+
539+# For heavy pages (GitHub PRs, complex web apps):
540+# profile = "max"
541+
542+# For manual control:
543+# profile = "custom"
544+```
545+
546+### Custom Profile Settings
547+
548+When `profile = "custom"`, you can configure individual tuning options:
549+
550+| Key | Type | Default | Description |
551+|-----|------|---------|-------------|
552+| `performance.skia_cpu_painting_threads` | int | `0` | Skia CPU rendering threads (0=unset) |
553+| `performance.skia_gpu_painting_threads` | int | `-1` | Skia GPU rendering threads (-1=unset, 0=disable) |
554+| `performance.skia_enable_cpu_rendering` | bool | `false` | Force CPU rendering |
555+| `performance.web_process_memory_limit_mb` | int | `0` | Web process memory limit in MB (0=unset) |
556+| `performance.web_process_memory_poll_interval_sec` | float | `0` | Memory check interval (0=WebKit default: 30s) |
557+| `performance.web_process_memory_conservative_threshold` | float | `0` | Conservative cleanup threshold (0=unset) |
558+| `performance.web_process_memory_strict_threshold` | float | `0` | Strict cleanup threshold (0=unset) |
559+| `performance.network_process_memory_limit_mb` | int | `0` | Network process memory limit in MB |
560+| `performance.network_process_memory_poll_interval_sec` | float | `0` | Network memory check interval |
561+| `performance.network_process_memory_conservative_threshold` | float | `0` | Network conservative threshold |
562+| `performance.network_process_memory_strict_threshold` | float | `0` | Network strict threshold |
563+| `performance.webview_pool_prewarm_count` | int | `4` | WebViews to pre-create at startup |
564+| `performance.zoom_cache_size` | int | `256` | Domain zoom levels to cache |
565+
566+**Custom profile example:**
567+```toml
568+[performance]
569+profile = "custom"
570+
571+# Skia threading - tune for your CPU
572+skia_cpu_painting_threads = 4
573+skia_gpu_painting_threads = 2
574+
575+# Web process memory pressure
576+web_process_memory_limit_mb = 1024
577+web_process_memory_conservative_threshold = 0.4
578+web_process_memory_strict_threshold = 0.6
579+
580+# WebView pool
581+webview_pool_prewarm_count = 6
582+```
583+
584+> **Important:** Individual tuning fields are ignored unless `profile = "custom"`. Setting individual fields with any other profile will produce a validation warning.
585+
362 ## Environment Variables586 ## Environment Variables
363 587
364 All config values can be overridden via environment variables with the prefix `DUMBER_`:588 All config values can be overridden via environment variables with the prefix `DUMBER_`:
@@ -368,7 +592,7 @@ All config values can be overridden via environment variables with the prefix `D
368 DUMBER_DATABASE_PATH=/custom/path/db.sqlite592 DUMBER_DATABASE_PATH=/custom/path/db.sqlite
369 593
370 # Rendering (examples)594 # Rendering (examples)
371-# Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="vulkan"595+# Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="auto"
372 DUMBER_RENDERING_MODE=cpu596 DUMBER_RENDERING_MODE=cpu
373 # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true597 # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true
374 # DUMBER_RENDERING_GSK_RENDERER=opengl598 # DUMBER_RENDERING_GSK_RENDERER=opengl
M go.mod +7 -4
@@ -3,19 +3,23 @@ module github.com/bnema/dumber
3 go 1.25.33 go 1.25.3
4 4
5 require (5 require (
6- github.com/bnema/puregotk-webkit v0.0.76+ github.com/bnema/puregotk-webkit v0.0.9
7 github.com/charmbracelet/bubbles v0.21.07 github.com/charmbracelet/bubbles v0.21.0
8 github.com/charmbracelet/bubbletea v1.3.108 github.com/charmbracelet/bubbletea v1.3.10
9 github.com/charmbracelet/lipgloss v1.1.09 github.com/charmbracelet/lipgloss v1.1.0
10 github.com/fsnotify/fsnotify v1.9.010 github.com/fsnotify/fsnotify v1.9.0
11+ github.com/godbus/dbus/v5 v5.2.1
11 github.com/jwijenbergh/puregotk v0.0.0-20251222174431-73930527faa312 github.com/jwijenbergh/puregotk v0.0.0-20251222174431-73930527faa3
12 github.com/ncruces/go-sqlite3 v0.30.313 github.com/ncruces/go-sqlite3 v0.30.3
14+ github.com/pelletier/go-toml/v2 v2.2.4
13 github.com/pressly/goose/v3 v3.26.015 github.com/pressly/goose/v3 v3.26.0
14 github.com/rs/zerolog v1.33.016 github.com/rs/zerolog v1.33.0
15 github.com/spf13/cobra v1.10.217 github.com/spf13/cobra v1.10.2
16 github.com/spf13/viper v1.21.018 github.com/spf13/viper v1.21.0
17 github.com/stretchr/testify v1.11.119 github.com/stretchr/testify v1.11.1
18 golang.org/x/image v0.34.020 golang.org/x/image v0.34.0
21+ golang.org/x/sys v0.38.0
22+ gopkg.in/yaml.v3 v3.0.1
19 )23 )
20 24
21 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa325 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3
@@ -27,6 +31,7 @@ require (
27 github.com/charmbracelet/x/ansi v0.10.1 // indirect31 github.com/charmbracelet/x/ansi v0.10.1 // indirect
28 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect32 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
29 github.com/charmbracelet/x/term v0.2.1 // indirect33 github.com/charmbracelet/x/term v0.2.1 // indirect
34+ github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
30 github.com/davecgh/go-spew v1.1.1 // indirect35 github.com/davecgh/go-spew v1.1.1 // indirect
31 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect36 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
32 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect37 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
@@ -42,9 +47,9 @@ require (
42 github.com/muesli/cancelreader v0.2.2 // indirect47 github.com/muesli/cancelreader v0.2.2 // indirect
43 github.com/muesli/termenv v0.16.0 // indirect48 github.com/muesli/termenv v0.16.0 // indirect
44 github.com/ncruces/julianday v1.0.0 // indirect49 github.com/ncruces/julianday v1.0.0 // indirect
45- github.com/pelletier/go-toml/v2 v2.2.4 // indirect
46 github.com/pmezard/go-difflib v1.0.0 // indirect50 github.com/pmezard/go-difflib v1.0.0 // indirect
47 github.com/rivo/uniseg v0.4.7 // indirect51 github.com/rivo/uniseg v0.4.7 // indirect
52+ github.com/russross/blackfriday/v2 v2.1.0 // indirect
48 github.com/sagikazarmark/locafero v0.11.0 // indirect53 github.com/sagikazarmark/locafero v0.11.0 // indirect
49 github.com/sahilm/fuzzy v0.1.1 // indirect54 github.com/sahilm/fuzzy v0.1.1 // indirect
50 github.com/sethvargo/go-retry v0.3.0 // indirect55 github.com/sethvargo/go-retry v0.3.0 // indirect
@@ -59,7 +64,5 @@ require (
59 go.uber.org/multierr v1.11.0 // indirect64 go.uber.org/multierr v1.11.0 // indirect
60 go.yaml.in/yaml/v3 v3.0.4 // indirect65 go.yaml.in/yaml/v3 v3.0.4 // indirect
61 golang.org/x/sync v0.19.0 // indirect66 golang.org/x/sync v0.19.0 // indirect
62- golang.org/x/sys v0.38.0 // indirect
63 golang.org/x/text v0.32.0 // indirect67 golang.org/x/text v0.32.0 // indirect
64- gopkg.in/yaml.v3 v3.0.1 // indirect
65 )68 )
M go.sum +6 -2 generated file: collapsed
A internal/application/port/cache.go +19 -0
@@ -0,0 +1,19 @@
1+package port
2+
3+// Cache is a generic cache interface for storing key-value pairs.
4+// Implementations should be thread-safe.
5+type Cache[K comparable, V any] interface {
6+ // Get retrieves a value by key. Returns the value and true if found,
7+ // or the zero value and false if not found.
8+ Get(key K) (V, bool)
9+
10+ // Set stores a value for the given key. If the cache is at capacity,
11+ // the least recently used entry may be evicted.
12+ Set(key K, value V)
13+
14+ // Remove deletes a key from the cache.
15+ Remove(key K)
16+
17+ // Len returns the number of items currently in the cache.
18+ Len() int
19+}
A internal/application/port/color_scheme.go +57 -0
@@ -0,0 +1,57 @@
1+package port
2+
3+// ColorSchemePreference represents the resolved color scheme preference.
4+type ColorSchemePreference struct {
5+ // PrefersDark indicates whether dark mode is preferred.
6+ PrefersDark bool
7+
8+ // Source identifies which detector provided this preference.
9+ // Empty string means fallback was used.
10+ Source string
11+}
12+
13+// ColorSchemeDetector detects the system's color scheme preference.
14+// Multiple detectors can be registered with different priorities.
15+type ColorSchemeDetector interface {
16+ // Name returns a human-readable name for this detector.
17+ Name() string
18+
19+ // Priority returns the detector's priority.
20+ // Higher values = higher priority (checked first).
21+ // Recommended ranges:
22+ // - 100+: Runtime detectors (libadwaita, GTK Settings)
23+ // - 50+: Config file detectors
24+ // - 10+: Fallback detectors (gsettings, env vars)
25+ Priority() int
26+
27+ // Available returns true if this detector can be used.
28+ // For example, libadwaita detector returns false before adw.Init().
29+ Available() bool
30+
31+ // Detect returns the detected preference and whether detection succeeded.
32+ // Returns (preference, true) on success, (_, false) if unavailable or detection failed.
33+ Detect() (prefersDark bool, ok bool)
34+}
35+
36+// ColorSchemeResolver resolves the effective color scheme preference.
37+// It manages multiple detectors and respects config overrides.
38+type ColorSchemeResolver interface {
39+ // Resolve returns the current color scheme preference.
40+ // It checks config for explicit overrides, then queries detectors by priority.
41+ // If all detectors fail, defaults to dark mode.
42+ Resolve() ColorSchemePreference
43+
44+ // RegisterDetector adds a detector to the resolver.
45+ // Safe to call at any time; the resolver re-evaluates on next Resolve().
46+ RegisterDetector(detector ColorSchemeDetector)
47+
48+ // Refresh forces re-evaluation of the color scheme.
49+ // Call this after registering new detectors or when system preferences change.
50+ // Returns the new preference.
51+ Refresh() ColorSchemePreference
52+
53+ // OnChange registers a callback for color scheme changes.
54+ // The callback is invoked when Refresh() results in a different preference.
55+ // Returns a function to unregister the callback.
56+ OnChange(callback func(ColorSchemePreference)) func()
57+}
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+}
A internal/application/port/hardware.go +69 -0
@@ -0,0 +1,69 @@
1+// Package port defines interfaces for external dependencies.
2+package port
3+
4+import "context"
5+
6+// HardwareInfo contains detected system hardware specifications.
7+// Used for performance profile auto-tuning.
8+type HardwareInfo struct {
9+ // CPU information
10+ CPUCores int // Number of physical CPU cores
11+ CPUThreads int // Number of logical CPU threads (with hyperthreading)
12+
13+ // Memory information (in bytes)
14+ TotalRAM uint64 // Total system RAM
15+ AvailableRAM uint64 // Currently available RAM
16+
17+ // GPU information
18+ GPUVendor GPUVendor // AMD, Intel, NVIDIA, or Unknown
19+ GPUName string // Human-readable GPU name (e.g., "AMD Radeon RX 9070 XT")
20+ VRAM uint64 // Video memory in bytes (0 if unknown)
21+}
22+
23+// CPUCoresOrDefault returns CPUCores if > 0, otherwise returns the fallback.
24+func (h HardwareInfo) CPUCoresOrDefault(fallback int) int {
25+ if h.CPUCores > 0 {
26+ return h.CPUCores
27+ }
28+ return fallback
29+}
30+
31+// CPUThreadsOrDefault returns CPUThreads if > 0, otherwise returns the fallback.
32+func (h HardwareInfo) CPUThreadsOrDefault(fallback int) int {
33+ if h.CPUThreads > 0 {
34+ return h.CPUThreads
35+ }
36+ return fallback
37+}
38+
39+// TotalRAMMB returns total RAM in megabytes.
40+// Returns 0 if RAM exceeds int range (unlikely on any real system).
41+func (h HardwareInfo) TotalRAMMB() int {
42+ mb := h.TotalRAM / (1024 * 1024)
43+ if mb > uint64(^uint(0)>>1) { // Check against max int
44+ return 0
45+ }
46+ return int(mb)
47+}
48+
49+// VRAMMB returns VRAM in megabytes.
50+// Returns 0 if VRAM exceeds int range (unlikely on any real system).
51+func (h HardwareInfo) VRAMMB() int {
52+ mb := h.VRAM / (1024 * 1024)
53+ if mb > uint64(^uint(0)>>1) { // Check against max int
54+ return 0
55+ }
56+ return int(mb)
57+}
58+
59+// HasDedicatedGPU returns true if a discrete GPU with VRAM was detected.
60+func (h HardwareInfo) HasDedicatedGPU() bool {
61+ return h.VRAM > 0 && h.GPUVendor != GPUVendorUnknown
62+}
63+
64+// HardwareSurveyor detects system hardware specifications.
65+type HardwareSurveyor interface {
66+ // Survey detects and returns hardware information.
67+ // This should be called once at startup and cached.
68+ Survey(ctx context.Context) HardwareInfo
69+}
A internal/application/port/idle_inhibit.go +22 -0
@@ -0,0 +1,22 @@
1+package port
2+
3+import "context"
4+
5+// IdleInhibitor prevents system idle/screensaver during fullscreen video playback.
6+// Implementations use refcounting internally - multiple Inhibit calls require
7+// matching Uninhibit calls before inhibition is released.
8+type IdleInhibitor interface {
9+ // Inhibit increments the inhibit refcount. First call activates inhibition.
10+ // Safe to call multiple times (refcounted).
11+ Inhibit(ctx context.Context, reason string) error
12+
13+ // Uninhibit decrements the refcount. When zero, releases inhibition.
14+ // Safe to call even if not currently inhibited (no-op).
15+ Uninhibit(ctx context.Context) error
16+
17+ // IsInhibited returns true if currently inhibiting idle.
18+ IsInhibited() bool
19+
20+ // Close releases any held resources. Should be called on application shutdown.
21+ Close() error
22+}
M internal/application/port/mocks/mocks.go +1861 -47
@@ -6,6 +6,7 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9+ "database/sql"
9 10
10 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
@@ -13,13 +14,1589 @@ import (
13 mock "github.com/stretchr/testify/mock"14 mock "github.com/stretchr/testify/mock"
14 )15 )
15 16
17+// NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. 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 NewMockColorSchemeDetector(t interface {
20+ mock.TestingT
21+ Cleanup(func())
22+}) *MockColorSchemeDetector {
23+ mock := &MockColorSchemeDetector{}
24+ mock.Mock.Test(t)
25+
26+ t.Cleanup(func() { mock.AssertExpectations(t) })
27+
28+ return mock
29+}
30+
31+// MockColorSchemeDetector is an autogenerated mock type for the ColorSchemeDetector type
32+type MockColorSchemeDetector struct {
33+ mock.Mock
34+}
35+
36+type MockColorSchemeDetector_Expecter struct {
37+ mock *mock.Mock
38+}
39+
40+func (_m *MockColorSchemeDetector) EXPECT() *MockColorSchemeDetector_Expecter {
41+ return &MockColorSchemeDetector_Expecter{mock: &_m.Mock}
42+}
43+
44+// Available provides a mock function for the type MockColorSchemeDetector
45+func (_mock *MockColorSchemeDetector) Available() bool {
46+ ret := _mock.Called()
47+
48+ if len(ret) == 0 {
49+ panic("no return value specified for Available")
50+ }
51+
52+ var r0 bool
53+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
54+ r0 = returnFunc()
55+ } else {
56+ r0 = ret.Get(0).(bool)
57+ }
58+ return r0
59+}
60+
61+// MockColorSchemeDetector_Available_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Available'
62+type MockColorSchemeDetector_Available_Call struct {
63+ *mock.Call
64+}
65+
66+// Available is a helper method to define mock.On call
67+func (_e *MockColorSchemeDetector_Expecter) Available() *MockColorSchemeDetector_Available_Call {
68+ return &MockColorSchemeDetector_Available_Call{Call: _e.mock.On("Available")}
69+}
70+
71+func (_c *MockColorSchemeDetector_Available_Call) Run(run func()) *MockColorSchemeDetector_Available_Call {
72+ _c.Call.Run(func(args mock.Arguments) {
73+ run()
74+ })
75+ return _c
76+}
77+
78+func (_c *MockColorSchemeDetector_Available_Call) Return(b bool) *MockColorSchemeDetector_Available_Call {
79+ _c.Call.Return(b)
80+ return _c
81+}
82+
83+func (_c *MockColorSchemeDetector_Available_Call) RunAndReturn(run func() bool) *MockColorSchemeDetector_Available_Call {
84+ _c.Call.Return(run)
85+ return _c
86+}
87+
88+// Detect provides a mock function for the type MockColorSchemeDetector
89+func (_mock *MockColorSchemeDetector) Detect() (bool, bool) {
90+ ret := _mock.Called()
91+
92+ if len(ret) == 0 {
93+ panic("no return value specified for Detect")
94+ }
95+
96+ var r0 bool
97+ var r1 bool
98+ if returnFunc, ok := ret.Get(0).(func() (bool, bool)); ok {
99+ return returnFunc()
100+ }
101+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
102+ r0 = returnFunc()
103+ } else {
104+ r0 = ret.Get(0).(bool)
105+ }
106+ if returnFunc, ok := ret.Get(1).(func() bool); ok {
107+ r1 = returnFunc()
108+ } else {
109+ r1 = ret.Get(1).(bool)
110+ }
111+ return r0, r1
112+}
113+
114+// MockColorSchemeDetector_Detect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Detect'
115+type MockColorSchemeDetector_Detect_Call struct {
116+ *mock.Call
117+}
118+
119+// Detect is a helper method to define mock.On call
120+func (_e *MockColorSchemeDetector_Expecter) Detect() *MockColorSchemeDetector_Detect_Call {
121+ return &MockColorSchemeDetector_Detect_Call{Call: _e.mock.On("Detect")}
122+}
123+
124+func (_c *MockColorSchemeDetector_Detect_Call) Run(run func()) *MockColorSchemeDetector_Detect_Call {
125+ _c.Call.Run(func(args mock.Arguments) {
126+ run()
127+ })
128+ return _c
129+}
130+
131+func (_c *MockColorSchemeDetector_Detect_Call) Return(prefersDark bool, ok bool) *MockColorSchemeDetector_Detect_Call {
132+ _c.Call.Return(prefersDark, ok)
133+ return _c
134+}
135+
136+func (_c *MockColorSchemeDetector_Detect_Call) RunAndReturn(run func() (bool, bool)) *MockColorSchemeDetector_Detect_Call {
137+ _c.Call.Return(run)
138+ return _c
139+}
140+
141+// Name provides a mock function for the type MockColorSchemeDetector
142+func (_mock *MockColorSchemeDetector) Name() string {
143+ ret := _mock.Called()
144+
145+ if len(ret) == 0 {
146+ panic("no return value specified for Name")
147+ }
148+
149+ var r0 string
150+ if returnFunc, ok := ret.Get(0).(func() string); ok {
151+ r0 = returnFunc()
152+ } else {
153+ r0 = ret.Get(0).(string)
154+ }
155+ return r0
156+}
157+
158+// MockColorSchemeDetector_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name'
159+type MockColorSchemeDetector_Name_Call struct {
160+ *mock.Call
161+}
162+
163+// Name is a helper method to define mock.On call
164+func (_e *MockColorSchemeDetector_Expecter) Name() *MockColorSchemeDetector_Name_Call {
165+ return &MockColorSchemeDetector_Name_Call{Call: _e.mock.On("Name")}
166+}
167+
168+func (_c *MockColorSchemeDetector_Name_Call) Run(run func()) *MockColorSchemeDetector_Name_Call {
169+ _c.Call.Run(func(args mock.Arguments) {
170+ run()
171+ })
172+ return _c
173+}
174+
175+func (_c *MockColorSchemeDetector_Name_Call) Return(s string) *MockColorSchemeDetector_Name_Call {
176+ _c.Call.Return(s)
177+ return _c
178+}
179+
180+func (_c *MockColorSchemeDetector_Name_Call) RunAndReturn(run func() string) *MockColorSchemeDetector_Name_Call {
181+ _c.Call.Return(run)
182+ return _c
183+}
184+
185+// Priority provides a mock function for the type MockColorSchemeDetector
186+func (_mock *MockColorSchemeDetector) Priority() int {
187+ ret := _mock.Called()
188+
189+ if len(ret) == 0 {
190+ panic("no return value specified for Priority")
191+ }
192+
193+ var r0 int
194+ if returnFunc, ok := ret.Get(0).(func() int); ok {
195+ r0 = returnFunc()
196+ } else {
197+ r0 = ret.Get(0).(int)
198+ }
199+ return r0
200+}
201+
202+// MockColorSchemeDetector_Priority_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Priority'
203+type MockColorSchemeDetector_Priority_Call struct {
204+ *mock.Call
205+}
206+
207+// Priority is a helper method to define mock.On call
208+func (_e *MockColorSchemeDetector_Expecter) Priority() *MockColorSchemeDetector_Priority_Call {
209+ return &MockColorSchemeDetector_Priority_Call{Call: _e.mock.On("Priority")}
210+}
211+
212+func (_c *MockColorSchemeDetector_Priority_Call) Run(run func()) *MockColorSchemeDetector_Priority_Call {
213+ _c.Call.Run(func(args mock.Arguments) {
214+ run()
215+ })
216+ return _c
217+}
218+
219+func (_c *MockColorSchemeDetector_Priority_Call) Return(n int) *MockColorSchemeDetector_Priority_Call {
220+ _c.Call.Return(n)
221+ return _c
222+}
223+
224+func (_c *MockColorSchemeDetector_Priority_Call) RunAndReturn(run func() int) *MockColorSchemeDetector_Priority_Call {
225+ _c.Call.Return(run)
226+ return _c
227+}
228+
229+// NewMockColorSchemeResolver creates a new instance of MockColorSchemeResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
230+// The first argument is typically a *testing.T value.
231+func NewMockColorSchemeResolver(t interface {
232+ mock.TestingT
233+ Cleanup(func())
234+}) *MockColorSchemeResolver {
235+ mock := &MockColorSchemeResolver{}
236+ mock.Mock.Test(t)
237+
238+ t.Cleanup(func() { mock.AssertExpectations(t) })
239+
240+ return mock
241+}
242+
243+// MockColorSchemeResolver is an autogenerated mock type for the ColorSchemeResolver type
244+type MockColorSchemeResolver struct {
245+ mock.Mock
246+}
247+
248+type MockColorSchemeResolver_Expecter struct {
249+ mock *mock.Mock
250+}
251+
252+func (_m *MockColorSchemeResolver) EXPECT() *MockColorSchemeResolver_Expecter {
253+ return &MockColorSchemeResolver_Expecter{mock: &_m.Mock}
254+}
255+
256+// OnChange provides a mock function for the type MockColorSchemeResolver
257+func (_mock *MockColorSchemeResolver) OnChange(callback func(port.ColorSchemePreference)) func() {
258+ ret := _mock.Called(callback)
259+
260+ if len(ret) == 0 {
261+ panic("no return value specified for OnChange")
262+ }
263+
264+ var r0 func()
265+ if returnFunc, ok := ret.Get(0).(func(func(port.ColorSchemePreference)) func()); ok {
266+ r0 = returnFunc(callback)
267+ } else {
268+ if ret.Get(0) != nil {
269+ r0 = ret.Get(0).(func())
270+ }
271+ }
272+ return r0
273+}
274+
275+// MockColorSchemeResolver_OnChange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnChange'
276+type MockColorSchemeResolver_OnChange_Call struct {
277+ *mock.Call
278+}
279+
280+// OnChange is a helper method to define mock.On call
281+// - callback func(port.ColorSchemePreference)
282+func (_e *MockColorSchemeResolver_Expecter) OnChange(callback interface{}) *MockColorSchemeResolver_OnChange_Call {
283+ return &MockColorSchemeResolver_OnChange_Call{Call: _e.mock.On("OnChange", callback)}
284+}
285+
286+func (_c *MockColorSchemeResolver_OnChange_Call) Run(run func(callback func(port.ColorSchemePreference))) *MockColorSchemeResolver_OnChange_Call {
287+ _c.Call.Run(func(args mock.Arguments) {
288+ var arg0 func(port.ColorSchemePreference)
289+ if args[0] != nil {
290+ arg0 = args[0].(func(port.ColorSchemePreference))
291+ }
292+ run(
293+ arg0,
294+ )
295+ })
296+ return _c
297+}
298+
299+func (_c *MockColorSchemeResolver_OnChange_Call) Return(fn func()) *MockColorSchemeResolver_OnChange_Call {
300+ _c.Call.Return(fn)
301+ return _c
302+}
303+
304+func (_c *MockColorSchemeResolver_OnChange_Call) RunAndReturn(run func(callback func(port.ColorSchemePreference)) func()) *MockColorSchemeResolver_OnChange_Call {
305+ _c.Call.Return(run)
306+ return _c
307+}
308+
309+// Refresh provides a mock function for the type MockColorSchemeResolver
310+func (_mock *MockColorSchemeResolver) Refresh() port.ColorSchemePreference {
311+ ret := _mock.Called()
312+
313+ if len(ret) == 0 {
314+ panic("no return value specified for Refresh")
315+ }
316+
317+ var r0 port.ColorSchemePreference
318+ if returnFunc, ok := ret.Get(0).(func() port.ColorSchemePreference); ok {
319+ r0 = returnFunc()
320+ } else {
321+ r0 = ret.Get(0).(port.ColorSchemePreference)
322+ }
323+ return r0
324+}
325+
326+// MockColorSchemeResolver_Refresh_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Refresh'
327+type MockColorSchemeResolver_Refresh_Call struct {
328+ *mock.Call
329+}
330+
331+// Refresh is a helper method to define mock.On call
332+func (_e *MockColorSchemeResolver_Expecter) Refresh() *MockColorSchemeResolver_Refresh_Call {
333+ return &MockColorSchemeResolver_Refresh_Call{Call: _e.mock.On("Refresh")}
334+}
335+
336+func (_c *MockColorSchemeResolver_Refresh_Call) Run(run func()) *MockColorSchemeResolver_Refresh_Call {
337+ _c.Call.Run(func(args mock.Arguments) {
338+ run()
339+ })
340+ return _c
341+}
342+
343+func (_c *MockColorSchemeResolver_Refresh_Call) Return(colorSchemePreference port.ColorSchemePreference) *MockColorSchemeResolver_Refresh_Call {
344+ _c.Call.Return(colorSchemePreference)
345+ return _c
346+}
347+
348+func (_c *MockColorSchemeResolver_Refresh_Call) RunAndReturn(run func() port.ColorSchemePreference) *MockColorSchemeResolver_Refresh_Call {
349+ _c.Call.Return(run)
350+ return _c
351+}
352+
353+// RegisterDetector provides a mock function for the type MockColorSchemeResolver
354+func (_mock *MockColorSchemeResolver) RegisterDetector(detector port.ColorSchemeDetector) {
355+ _mock.Called(detector)
356+ return
357+}
358+
359+// MockColorSchemeResolver_RegisterDetector_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterDetector'
360+type MockColorSchemeResolver_RegisterDetector_Call struct {
361+ *mock.Call
362+}
363+
364+// RegisterDetector is a helper method to define mock.On call
365+// - detector port.ColorSchemeDetector
366+func (_e *MockColorSchemeResolver_Expecter) RegisterDetector(detector interface{}) *MockColorSchemeResolver_RegisterDetector_Call {
367+ return &MockColorSchemeResolver_RegisterDetector_Call{Call: _e.mock.On("RegisterDetector", detector)}
368+}
369+
370+func (_c *MockColorSchemeResolver_RegisterDetector_Call) Run(run func(detector port.ColorSchemeDetector)) *MockColorSchemeResolver_RegisterDetector_Call {
371+ _c.Call.Run(func(args mock.Arguments) {
372+ var arg0 port.ColorSchemeDetector
373+ if args[0] != nil {
374+ arg0 = args[0].(port.ColorSchemeDetector)
375+ }
376+ run(
377+ arg0,
378+ )
379+ })
380+ return _c
381+}
382+
383+func (_c *MockColorSchemeResolver_RegisterDetector_Call) Return() *MockColorSchemeResolver_RegisterDetector_Call {
384+ _c.Call.Return()
385+ return _c
386+}
387+
388+func (_c *MockColorSchemeResolver_RegisterDetector_Call) RunAndReturn(run func(detector port.ColorSchemeDetector)) *MockColorSchemeResolver_RegisterDetector_Call {
389+ _c.Run(run)
390+ return _c
391+}
392+
393+// Resolve provides a mock function for the type MockColorSchemeResolver
394+func (_mock *MockColorSchemeResolver) Resolve() port.ColorSchemePreference {
395+ ret := _mock.Called()
396+
397+ if len(ret) == 0 {
398+ panic("no return value specified for Resolve")
399+ }
400+
401+ var r0 port.ColorSchemePreference
402+ if returnFunc, ok := ret.Get(0).(func() port.ColorSchemePreference); ok {
403+ r0 = returnFunc()
404+ } else {
405+ r0 = ret.Get(0).(port.ColorSchemePreference)
406+ }
407+ return r0
408+}
409+
410+// MockColorSchemeResolver_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve'
411+type MockColorSchemeResolver_Resolve_Call struct {
412+ *mock.Call
413+}
414+
415+// Resolve is a helper method to define mock.On call
416+func (_e *MockColorSchemeResolver_Expecter) Resolve() *MockColorSchemeResolver_Resolve_Call {
417+ return &MockColorSchemeResolver_Resolve_Call{Call: _e.mock.On("Resolve")}
418+}
419+
420+func (_c *MockColorSchemeResolver_Resolve_Call) Run(run func()) *MockColorSchemeResolver_Resolve_Call {
421+ _c.Call.Run(func(args mock.Arguments) {
422+ run()
423+ })
424+ return _c
425+}
426+
427+func (_c *MockColorSchemeResolver_Resolve_Call) Return(colorSchemePreference port.ColorSchemePreference) *MockColorSchemeResolver_Resolve_Call {
428+ _c.Call.Return(colorSchemePreference)
429+ return _c
430+}
431+
432+func (_c *MockColorSchemeResolver_Resolve_Call) RunAndReturn(run func() port.ColorSchemePreference) *MockColorSchemeResolver_Resolve_Call {
433+ _c.Call.Return(run)
434+ return _c
435+}
436+
437+// 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.
438+// The first argument is typically a *testing.T value.
439+func NewMockConfigMigrator(t interface {
440+ mock.TestingT
441+ Cleanup(func())
442+}) *MockConfigMigrator {
443+ mock := &MockConfigMigrator{}
444+ mock.Mock.Test(t)
445+
446+ t.Cleanup(func() { mock.AssertExpectations(t) })
447+
448+ return mock
449+}
450+
451+// MockConfigMigrator is an autogenerated mock type for the ConfigMigrator type
452+type MockConfigMigrator struct {
453+ mock.Mock
454+}
455+
456+type MockConfigMigrator_Expecter struct {
457+ mock *mock.Mock
458+}
459+
460+func (_m *MockConfigMigrator) EXPECT() *MockConfigMigrator_Expecter {
461+ return &MockConfigMigrator_Expecter{mock: &_m.Mock}
462+}
463+
464+// CheckMigration provides a mock function for the type MockConfigMigrator
465+func (_mock *MockConfigMigrator) CheckMigration() (*port.MigrationResult, error) {
466+ ret := _mock.Called()
467+
468+ if len(ret) == 0 {
469+ panic("no return value specified for CheckMigration")
470+ }
471+
472+ var r0 *port.MigrationResult
473+ var r1 error
474+ if returnFunc, ok := ret.Get(0).(func() (*port.MigrationResult, error)); ok {
475+ return returnFunc()
476+ }
477+ if returnFunc, ok := ret.Get(0).(func() *port.MigrationResult); ok {
478+ r0 = returnFunc()
479+ } else {
480+ if ret.Get(0) != nil {
481+ r0 = ret.Get(0).(*port.MigrationResult)
482+ }
483+ }
484+ if returnFunc, ok := ret.Get(1).(func() error); ok {
485+ r1 = returnFunc()
486+ } else {
487+ r1 = ret.Error(1)
488+ }
489+ return r0, r1
490+}
491+
492+// MockConfigMigrator_CheckMigration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckMigration'
493+type MockConfigMigrator_CheckMigration_Call struct {
494+ *mock.Call
495+}
496+
497+// CheckMigration is a helper method to define mock.On call
498+func (_e *MockConfigMigrator_Expecter) CheckMigration() *MockConfigMigrator_CheckMigration_Call {
499+ return &MockConfigMigrator_CheckMigration_Call{Call: _e.mock.On("CheckMigration")}
500+}
501+
502+func (_c *MockConfigMigrator_CheckMigration_Call) Run(run func()) *MockConfigMigrator_CheckMigration_Call {
503+ _c.Call.Run(func(args mock.Arguments) {
504+ run()
505+ })
506+ return _c
507+}
508+
509+func (_c *MockConfigMigrator_CheckMigration_Call) Return(migrationResult *port.MigrationResult, err error) *MockConfigMigrator_CheckMigration_Call {
510+ _c.Call.Return(migrationResult, err)
511+ return _c
512+}
513+
514+func (_c *MockConfigMigrator_CheckMigration_Call) RunAndReturn(run func() (*port.MigrationResult, error)) *MockConfigMigrator_CheckMigration_Call {
515+ _c.Call.Return(run)
516+ return _c
517+}
518+
519+// DetectChanges provides a mock function for the type MockConfigMigrator
520+func (_mock *MockConfigMigrator) DetectChanges() ([]port.KeyChange, error) {
521+ ret := _mock.Called()
522+
523+ if len(ret) == 0 {
524+ panic("no return value specified for DetectChanges")
525+ }
526+
527+ var r0 []port.KeyChange
528+ var r1 error
529+ if returnFunc, ok := ret.Get(0).(func() ([]port.KeyChange, error)); ok {
530+ return returnFunc()
531+ }
532+ if returnFunc, ok := ret.Get(0).(func() []port.KeyChange); ok {
533+ r0 = returnFunc()
534+ } else {
535+ if ret.Get(0) != nil {
536+ r0 = ret.Get(0).([]port.KeyChange)
537+ }
538+ }
539+ if returnFunc, ok := ret.Get(1).(func() error); ok {
540+ r1 = returnFunc()
541+ } else {
542+ r1 = ret.Error(1)
543+ }
544+ return r0, r1
545+}
546+
547+// MockConfigMigrator_DetectChanges_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetectChanges'
548+type MockConfigMigrator_DetectChanges_Call struct {
549+ *mock.Call
550+}
551+
552+// DetectChanges is a helper method to define mock.On call
553+func (_e *MockConfigMigrator_Expecter) DetectChanges() *MockConfigMigrator_DetectChanges_Call {
554+ return &MockConfigMigrator_DetectChanges_Call{Call: _e.mock.On("DetectChanges")}
555+}
556+
557+func (_c *MockConfigMigrator_DetectChanges_Call) Run(run func()) *MockConfigMigrator_DetectChanges_Call {
558+ _c.Call.Run(func(args mock.Arguments) {
559+ run()
560+ })
561+ return _c
562+}
563+
564+func (_c *MockConfigMigrator_DetectChanges_Call) Return(keyChanges []port.KeyChange, err error) *MockConfigMigrator_DetectChanges_Call {
565+ _c.Call.Return(keyChanges, err)
566+ return _c
567+}
568+
569+func (_c *MockConfigMigrator_DetectChanges_Call) RunAndReturn(run func() ([]port.KeyChange, error)) *MockConfigMigrator_DetectChanges_Call {
570+ _c.Call.Return(run)
571+ return _c
572+}
573+
574+// GetConfigFile provides a mock function for the type MockConfigMigrator
575+func (_mock *MockConfigMigrator) GetConfigFile() (string, error) {
576+ ret := _mock.Called()
577+
578+ if len(ret) == 0 {
579+ panic("no return value specified for GetConfigFile")
580+ }
581+
582+ var r0 string
583+ var r1 error
584+ if returnFunc, ok := ret.Get(0).(func() (string, error)); ok {
585+ return returnFunc()
586+ }
587+ if returnFunc, ok := ret.Get(0).(func() string); ok {
588+ r0 = returnFunc()
589+ } else {
590+ r0 = ret.Get(0).(string)
591+ }
592+ if returnFunc, ok := ret.Get(1).(func() error); ok {
593+ r1 = returnFunc()
594+ } else {
595+ r1 = ret.Error(1)
596+ }
597+ return r0, r1
598+}
599+
600+// MockConfigMigrator_GetConfigFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfigFile'
601+type MockConfigMigrator_GetConfigFile_Call struct {
602+ *mock.Call
603+}
604+
605+// GetConfigFile is a helper method to define mock.On call
606+func (_e *MockConfigMigrator_Expecter) GetConfigFile() *MockConfigMigrator_GetConfigFile_Call {
607+ return &MockConfigMigrator_GetConfigFile_Call{Call: _e.mock.On("GetConfigFile")}
608+}
609+
610+func (_c *MockConfigMigrator_GetConfigFile_Call) Run(run func()) *MockConfigMigrator_GetConfigFile_Call {
611+ _c.Call.Run(func(args mock.Arguments) {
612+ run()
613+ })
614+ return _c
615+}
616+
617+func (_c *MockConfigMigrator_GetConfigFile_Call) Return(s string, err error) *MockConfigMigrator_GetConfigFile_Call {
618+ _c.Call.Return(s, err)
619+ return _c
620+}
621+
622+func (_c *MockConfigMigrator_GetConfigFile_Call) RunAndReturn(run func() (string, error)) *MockConfigMigrator_GetConfigFile_Call {
623+ _c.Call.Return(run)
624+ return _c
625+}
626+
627+// GetKeyInfo provides a mock function for the type MockConfigMigrator
628+func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo {
629+ ret := _mock.Called(key)
630+
631+ if len(ret) == 0 {
632+ panic("no return value specified for GetKeyInfo")
633+ }
634+
635+ var r0 port.KeyInfo
636+ if returnFunc, ok := ret.Get(0).(func(string) port.KeyInfo); ok {
637+ r0 = returnFunc(key)
638+ } else {
639+ r0 = ret.Get(0).(port.KeyInfo)
640+ }
641+ return r0
642+}
643+
644+// MockConfigMigrator_GetKeyInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetKeyInfo'
645+type MockConfigMigrator_GetKeyInfo_Call struct {
646+ *mock.Call
647+}
648+
649+// GetKeyInfo is a helper method to define mock.On call
650+// - key string
651+func (_e *MockConfigMigrator_Expecter) GetKeyInfo(key interface{}) *MockConfigMigrator_GetKeyInfo_Call {
652+ return &MockConfigMigrator_GetKeyInfo_Call{Call: _e.mock.On("GetKeyInfo", key)}
653+}
654+
655+func (_c *MockConfigMigrator_GetKeyInfo_Call) Run(run func(key string)) *MockConfigMigrator_GetKeyInfo_Call {
656+ _c.Call.Run(func(args mock.Arguments) {
657+ var arg0 string
658+ if args[0] != nil {
659+ arg0 = args[0].(string)
660+ }
661+ run(
662+ arg0,
663+ )
664+ })
665+ return _c
666+}
667+
668+func (_c *MockConfigMigrator_GetKeyInfo_Call) Return(keyInfo port.KeyInfo) *MockConfigMigrator_GetKeyInfo_Call {
669+ _c.Call.Return(keyInfo)
670+ return _c
671+}
672+
673+func (_c *MockConfigMigrator_GetKeyInfo_Call) RunAndReturn(run func(key string) port.KeyInfo) *MockConfigMigrator_GetKeyInfo_Call {
674+ _c.Call.Return(run)
675+ return _c
676+}
677+
678+// Migrate provides a mock function for the type MockConfigMigrator
679+func (_mock *MockConfigMigrator) Migrate() ([]string, error) {
680+ ret := _mock.Called()
681+
682+ if len(ret) == 0 {
683+ panic("no return value specified for Migrate")
684+ }
685+
686+ var r0 []string
687+ var r1 error
688+ if returnFunc, ok := ret.Get(0).(func() ([]string, error)); ok {
689+ return returnFunc()
690+ }
691+ if returnFunc, ok := ret.Get(0).(func() []string); ok {
692+ r0 = returnFunc()
693+ } else {
694+ if ret.Get(0) != nil {
695+ r0 = ret.Get(0).([]string)
696+ }
697+ }
698+ if returnFunc, ok := ret.Get(1).(func() error); ok {
699+ r1 = returnFunc()
700+ } else {
701+ r1 = ret.Error(1)
702+ }
703+ return r0, r1
704+}
705+
706+// MockConfigMigrator_Migrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Migrate'
707+type MockConfigMigrator_Migrate_Call struct {
708+ *mock.Call
709+}
710+
711+// Migrate is a helper method to define mock.On call
712+func (_e *MockConfigMigrator_Expecter) Migrate() *MockConfigMigrator_Migrate_Call {
713+ return &MockConfigMigrator_Migrate_Call{Call: _e.mock.On("Migrate")}
714+}
715+
716+func (_c *MockConfigMigrator_Migrate_Call) Run(run func()) *MockConfigMigrator_Migrate_Call {
717+ _c.Call.Run(func(args mock.Arguments) {
718+ run()
719+ })
720+ return _c
721+}
722+
723+func (_c *MockConfigMigrator_Migrate_Call) Return(strings []string, err error) *MockConfigMigrator_Migrate_Call {
724+ _c.Call.Return(strings, err)
725+ return _c
726+}
727+
728+func (_c *MockConfigMigrator_Migrate_Call) RunAndReturn(run func() ([]string, error)) *MockConfigMigrator_Migrate_Call {
729+ _c.Call.Return(run)
730+ return _c
731+}
732+
733+// 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.
734+// The first argument is typically a *testing.T value.
735+func NewMockDiffFormatter(t interface {
736+ mock.TestingT
737+ Cleanup(func())
738+}) *MockDiffFormatter {
739+ mock := &MockDiffFormatter{}
740+ mock.Mock.Test(t)
741+
742+ t.Cleanup(func() { mock.AssertExpectations(t) })
743+
744+ return mock
745+}
746+
747+// MockDiffFormatter is an autogenerated mock type for the DiffFormatter type
748+type MockDiffFormatter struct {
749+ mock.Mock
750+}
751+
752+type MockDiffFormatter_Expecter struct {
753+ mock *mock.Mock
754+}
755+
756+func (_m *MockDiffFormatter) EXPECT() *MockDiffFormatter_Expecter {
757+ return &MockDiffFormatter_Expecter{mock: &_m.Mock}
758+}
759+
760+// FormatChangesAsDiff provides a mock function for the type MockDiffFormatter
761+func (_mock *MockDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string {
762+ ret := _mock.Called(changes)
763+
764+ if len(ret) == 0 {
765+ panic("no return value specified for FormatChangesAsDiff")
766+ }
767+
768+ var r0 string
769+ if returnFunc, ok := ret.Get(0).(func([]port.KeyChange) string); ok {
770+ r0 = returnFunc(changes)
771+ } else {
772+ r0 = ret.Get(0).(string)
773+ }
774+ return r0
775+}
776+
777+// MockDiffFormatter_FormatChangesAsDiff_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FormatChangesAsDiff'
778+type MockDiffFormatter_FormatChangesAsDiff_Call struct {
779+ *mock.Call
780+}
781+
782+// FormatChangesAsDiff is a helper method to define mock.On call
783+// - changes []port.KeyChange
784+func (_e *MockDiffFormatter_Expecter) FormatChangesAsDiff(changes interface{}) *MockDiffFormatter_FormatChangesAsDiff_Call {
785+ return &MockDiffFormatter_FormatChangesAsDiff_Call{Call: _e.mock.On("FormatChangesAsDiff", changes)}
786+}
787+
788+func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Run(run func(changes []port.KeyChange)) *MockDiffFormatter_FormatChangesAsDiff_Call {
789+ _c.Call.Run(func(args mock.Arguments) {
790+ var arg0 []port.KeyChange
791+ if args[0] != nil {
792+ arg0 = args[0].([]port.KeyChange)
793+ }
794+ run(
795+ arg0,
796+ )
797+ })
798+ return _c
799+}
800+
801+func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Return(s string) *MockDiffFormatter_FormatChangesAsDiff_Call {
802+ _c.Call.Return(s)
803+ return _c
804+}
805+
806+func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) RunAndReturn(run func(changes []port.KeyChange) string) *MockDiffFormatter_FormatChangesAsDiff_Call {
807+ _c.Call.Return(run)
808+ return _c
809+}
810+
811+// 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.
812+// The first argument is typically a *testing.T value.
813+func NewMockConfigSchemaProvider(t interface {
814+ mock.TestingT
815+ Cleanup(func())
816+}) *MockConfigSchemaProvider {
817+ mock := &MockConfigSchemaProvider{}
818+ mock.Mock.Test(t)
819+
820+ t.Cleanup(func() { mock.AssertExpectations(t) })
821+
822+ return mock
823+}
824+
825+// MockConfigSchemaProvider is an autogenerated mock type for the ConfigSchemaProvider type
826+type MockConfigSchemaProvider struct {
827+ mock.Mock
828+}
829+
830+type MockConfigSchemaProvider_Expecter struct {
831+ mock *mock.Mock
832+}
833+
834+func (_m *MockConfigSchemaProvider) EXPECT() *MockConfigSchemaProvider_Expecter {
835+ return &MockConfigSchemaProvider_Expecter{mock: &_m.Mock}
836+}
837+
838+// GetSchema provides a mock function for the type MockConfigSchemaProvider
839+func (_mock *MockConfigSchemaProvider) GetSchema() []entity.ConfigKeyInfo {
840+ ret := _mock.Called()
841+
842+ if len(ret) == 0 {
843+ panic("no return value specified for GetSchema")
844+ }
845+
846+ var r0 []entity.ConfigKeyInfo
847+ if returnFunc, ok := ret.Get(0).(func() []entity.ConfigKeyInfo); ok {
848+ r0 = returnFunc()
849+ } else {
850+ if ret.Get(0) != nil {
851+ r0 = ret.Get(0).([]entity.ConfigKeyInfo)
852+ }
853+ }
854+ return r0
855+}
856+
857+// MockConfigSchemaProvider_GetSchema_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSchema'
858+type MockConfigSchemaProvider_GetSchema_Call struct {
859+ *mock.Call
860+}
861+
862+// GetSchema is a helper method to define mock.On call
863+func (_e *MockConfigSchemaProvider_Expecter) GetSchema() *MockConfigSchemaProvider_GetSchema_Call {
864+ return &MockConfigSchemaProvider_GetSchema_Call{Call: _e.mock.On("GetSchema")}
865+}
866+
867+func (_c *MockConfigSchemaProvider_GetSchema_Call) Run(run func()) *MockConfigSchemaProvider_GetSchema_Call {
868+ _c.Call.Run(func(args mock.Arguments) {
869+ run()
870+ })
871+ return _c
872+}
873+
874+func (_c *MockConfigSchemaProvider_GetSchema_Call) Return(configKeyInfos []entity.ConfigKeyInfo) *MockConfigSchemaProvider_GetSchema_Call {
875+ _c.Call.Return(configKeyInfos)
876+ return _c
877+}
878+
879+func (_c *MockConfigSchemaProvider_GetSchema_Call) RunAndReturn(run func() []entity.ConfigKeyInfo) *MockConfigSchemaProvider_GetSchema_Call {
880+ _c.Call.Return(run)
881+ return _c
882+}
883+
884+// 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.
885+// The first argument is typically a *testing.T value.
886+func NewMockDatabaseProvider(t interface {
887+ mock.TestingT
888+ Cleanup(func())
889+}) *MockDatabaseProvider {
890+ mock := &MockDatabaseProvider{}
891+ mock.Mock.Test(t)
892+
893+ t.Cleanup(func() { mock.AssertExpectations(t) })
894+
895+ return mock
896+}
897+
898+// MockDatabaseProvider is an autogenerated mock type for the DatabaseProvider type
899+type MockDatabaseProvider struct {
900+ mock.Mock
901+}
902+
903+type MockDatabaseProvider_Expecter struct {
904+ mock *mock.Mock
905+}
906+
907+func (_m *MockDatabaseProvider) EXPECT() *MockDatabaseProvider_Expecter {
908+ return &MockDatabaseProvider_Expecter{mock: &_m.Mock}
909+}
910+
911+// Close provides a mock function for the type MockDatabaseProvider
912+func (_mock *MockDatabaseProvider) Close() error {
913+ ret := _mock.Called()
914+
915+ if len(ret) == 0 {
916+ panic("no return value specified for Close")
917+ }
918+
919+ var r0 error
920+ if returnFunc, ok := ret.Get(0).(func() error); ok {
921+ r0 = returnFunc()
922+ } else {
923+ r0 = ret.Error(0)
924+ }
925+ return r0
926+}
927+
928+// MockDatabaseProvider_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
929+type MockDatabaseProvider_Close_Call struct {
930+ *mock.Call
931+}
932+
933+// Close is a helper method to define mock.On call
934+func (_e *MockDatabaseProvider_Expecter) Close() *MockDatabaseProvider_Close_Call {
935+ return &MockDatabaseProvider_Close_Call{Call: _e.mock.On("Close")}
936+}
937+
938+func (_c *MockDatabaseProvider_Close_Call) Run(run func()) *MockDatabaseProvider_Close_Call {
939+ _c.Call.Run(func(args mock.Arguments) {
940+ run()
941+ })
942+ return _c
943+}
944+
945+func (_c *MockDatabaseProvider_Close_Call) Return(err error) *MockDatabaseProvider_Close_Call {
946+ _c.Call.Return(err)
947+ return _c
948+}
949+
950+func (_c *MockDatabaseProvider_Close_Call) RunAndReturn(run func() error) *MockDatabaseProvider_Close_Call {
951+ _c.Call.Return(run)
952+ return _c
953+}
954+
955+// DB provides a mock function for the type MockDatabaseProvider
956+func (_mock *MockDatabaseProvider) DB(ctx context.Context) (*sql.DB, error) {
957+ ret := _mock.Called(ctx)
958+
959+ if len(ret) == 0 {
960+ panic("no return value specified for DB")
961+ }
962+
963+ var r0 *sql.DB
964+ var r1 error
965+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*sql.DB, error)); ok {
966+ return returnFunc(ctx)
967+ }
968+ if returnFunc, ok := ret.Get(0).(func(context.Context) *sql.DB); ok {
969+ r0 = returnFunc(ctx)
970+ } else {
971+ if ret.Get(0) != nil {
972+ r0 = ret.Get(0).(*sql.DB)
973+ }
974+ }
975+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
976+ r1 = returnFunc(ctx)
977+ } else {
978+ r1 = ret.Error(1)
979+ }
980+ return r0, r1
981+}
982+
983+// MockDatabaseProvider_DB_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DB'
984+type MockDatabaseProvider_DB_Call struct {
985+ *mock.Call
986+}
987+
988+// DB is a helper method to define mock.On call
989+// - ctx context.Context
990+func (_e *MockDatabaseProvider_Expecter) DB(ctx interface{}) *MockDatabaseProvider_DB_Call {
991+ return &MockDatabaseProvider_DB_Call{Call: _e.mock.On("DB", ctx)}
992+}
993+
994+func (_c *MockDatabaseProvider_DB_Call) Run(run func(ctx context.Context)) *MockDatabaseProvider_DB_Call {
995+ _c.Call.Run(func(args mock.Arguments) {
996+ var arg0 context.Context
997+ if args[0] != nil {
998+ arg0 = args[0].(context.Context)
999+ }
1000+ run(
1001+ arg0,
1002+ )
1003+ })
1004+ return _c
1005+}
1006+
1007+func (_c *MockDatabaseProvider_DB_Call) Return(dB *sql.DB, err error) *MockDatabaseProvider_DB_Call {
1008+ _c.Call.Return(dB, err)
1009+ return _c
1010+}
1011+
1012+func (_c *MockDatabaseProvider_DB_Call) RunAndReturn(run func(ctx context.Context) (*sql.DB, error)) *MockDatabaseProvider_DB_Call {
1013+ _c.Call.Return(run)
1014+ return _c
1015+}
1016+
1017+// IsInitialized provides a mock function for the type MockDatabaseProvider
1018+func (_mock *MockDatabaseProvider) IsInitialized() bool {
1019+ ret := _mock.Called()
1020+
1021+ if len(ret) == 0 {
1022+ panic("no return value specified for IsInitialized")
1023+ }
1024+
1025+ var r0 bool
1026+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
1027+ r0 = returnFunc()
1028+ } else {
1029+ r0 = ret.Get(0).(bool)
1030+ }
1031+ return r0
1032+}
1033+
1034+// MockDatabaseProvider_IsInitialized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsInitialized'
1035+type MockDatabaseProvider_IsInitialized_Call struct {
1036+ *mock.Call
1037+}
1038+
1039+// IsInitialized is a helper method to define mock.On call
1040+func (_e *MockDatabaseProvider_Expecter) IsInitialized() *MockDatabaseProvider_IsInitialized_Call {
1041+ return &MockDatabaseProvider_IsInitialized_Call{Call: _e.mock.On("IsInitialized")}
1042+}
1043+
1044+func (_c *MockDatabaseProvider_IsInitialized_Call) Run(run func()) *MockDatabaseProvider_IsInitialized_Call {
1045+ _c.Call.Run(func(args mock.Arguments) {
1046+ run()
1047+ })
1048+ return _c
1049+}
1050+
1051+func (_c *MockDatabaseProvider_IsInitialized_Call) Return(b bool) *MockDatabaseProvider_IsInitialized_Call {
1052+ _c.Call.Return(b)
1053+ return _c
1054+}
1055+
1056+func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool) *MockDatabaseProvider_IsInitialized_Call {
1057+ _c.Call.Return(run)
1058+ return _c
1059+}
1060+
1061+// NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1062+// The first argument is typically a *testing.T value.
1063+func NewMockHardwareSurveyor(t interface {
1064+ mock.TestingT
1065+ Cleanup(func())
1066+}) *MockHardwareSurveyor {
1067+ mock := &MockHardwareSurveyor{}
1068+ mock.Mock.Test(t)
1069+
1070+ t.Cleanup(func() { mock.AssertExpectations(t) })
1071+
1072+ return mock
1073+}
1074+
1075+// MockHardwareSurveyor is an autogenerated mock type for the HardwareSurveyor type
1076+type MockHardwareSurveyor struct {
1077+ mock.Mock
1078+}
1079+
1080+type MockHardwareSurveyor_Expecter struct {
1081+ mock *mock.Mock
1082+}
1083+
1084+func (_m *MockHardwareSurveyor) EXPECT() *MockHardwareSurveyor_Expecter {
1085+ return &MockHardwareSurveyor_Expecter{mock: &_m.Mock}
1086+}
1087+
1088+// Survey provides a mock function for the type MockHardwareSurveyor
1089+func (_mock *MockHardwareSurveyor) Survey(ctx context.Context) port.HardwareInfo {
1090+ ret := _mock.Called(ctx)
1091+
1092+ if len(ret) == 0 {
1093+ panic("no return value specified for Survey")
1094+ }
1095+
1096+ var r0 port.HardwareInfo
1097+ if returnFunc, ok := ret.Get(0).(func(context.Context) port.HardwareInfo); ok {
1098+ r0 = returnFunc(ctx)
1099+ } else {
1100+ r0 = ret.Get(0).(port.HardwareInfo)
1101+ }
1102+ return r0
1103+}
1104+
1105+// MockHardwareSurveyor_Survey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Survey'
1106+type MockHardwareSurveyor_Survey_Call struct {
1107+ *mock.Call
1108+}
1109+
1110+// Survey is a helper method to define mock.On call
1111+// - ctx context.Context
1112+func (_e *MockHardwareSurveyor_Expecter) Survey(ctx interface{}) *MockHardwareSurveyor_Survey_Call {
1113+ return &MockHardwareSurveyor_Survey_Call{Call: _e.mock.On("Survey", ctx)}
1114+}
1115+
1116+func (_c *MockHardwareSurveyor_Survey_Call) Run(run func(ctx context.Context)) *MockHardwareSurveyor_Survey_Call {
1117+ _c.Call.Run(func(args mock.Arguments) {
1118+ var arg0 context.Context
1119+ if args[0] != nil {
1120+ arg0 = args[0].(context.Context)
1121+ }
1122+ run(
1123+ arg0,
1124+ )
1125+ })
1126+ return _c
1127+}
1128+
1129+func (_c *MockHardwareSurveyor_Survey_Call) Return(hardwareInfo port.HardwareInfo) *MockHardwareSurveyor_Survey_Call {
1130+ _c.Call.Return(hardwareInfo)
1131+ return _c
1132+}
1133+
1134+func (_c *MockHardwareSurveyor_Survey_Call) RunAndReturn(run func(ctx context.Context) port.HardwareInfo) *MockHardwareSurveyor_Survey_Call {
1135+ _c.Call.Return(run)
1136+ return _c
1137+}
1138+
16 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1139 // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17 // The first argument is typically a *testing.T value.1140 // The first argument is typically a *testing.T value.
18-func NewMockSessionLogger(t interface {1141+func NewMockSessionLogger(t interface {
1142+ mock.TestingT
1143+ Cleanup(func())
1144+}) *MockSessionLogger {
1145+ mock := &MockSessionLogger{}
1146+ mock.Mock.Test(t)
1147+
1148+ t.Cleanup(func() { mock.AssertExpectations(t) })
1149+
1150+ return mock
1151+}
1152+
1153+// MockSessionLogger is an autogenerated mock type for the SessionLogger type
1154+type MockSessionLogger struct {
1155+ mock.Mock
1156+}
1157+
1158+type MockSessionLogger_Expecter struct {
1159+ mock *mock.Mock
1160+}
1161+
1162+func (_m *MockSessionLogger) EXPECT() *MockSessionLogger_Expecter {
1163+ return &MockSessionLogger_Expecter{mock: &_m.Mock}
1164+}
1165+
1166+// CreateLogger provides a mock function for the type MockSessionLogger
1167+func (_mock *MockSessionLogger) CreateLogger(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig) (zerolog.Logger, func(), error) {
1168+ ret := _mock.Called(ctx, sessionID, cfg)
1169+
1170+ if len(ret) == 0 {
1171+ panic("no return value specified for CreateLogger")
1172+ }
1173+
1174+ var r0 zerolog.Logger
1175+ var r1 func()
1176+ var r2 error
1177+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, port.SessionLogConfig) (zerolog.Logger, func(), error)); ok {
1178+ return returnFunc(ctx, sessionID, cfg)
1179+ }
1180+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, port.SessionLogConfig) zerolog.Logger); ok {
1181+ r0 = returnFunc(ctx, sessionID, cfg)
1182+ } else {
1183+ r0 = ret.Get(0).(zerolog.Logger)
1184+ }
1185+ if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID, port.SessionLogConfig) func()); ok {
1186+ r1 = returnFunc(ctx, sessionID, cfg)
1187+ } else {
1188+ if ret.Get(1) != nil {
1189+ r1 = ret.Get(1).(func())
1190+ }
1191+ }
1192+ if returnFunc, ok := ret.Get(2).(func(context.Context, entity.SessionID, port.SessionLogConfig) error); ok {
1193+ r2 = returnFunc(ctx, sessionID, cfg)
1194+ } else {
1195+ r2 = ret.Error(2)
1196+ }
1197+ return r0, r1, r2
1198+}
1199+
1200+// MockSessionLogger_CreateLogger_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateLogger'
1201+type MockSessionLogger_CreateLogger_Call struct {
1202+ *mock.Call
1203+}
1204+
1205+// CreateLogger is a helper method to define mock.On call
1206+// - ctx context.Context
1207+// - sessionID entity.SessionID
1208+// - cfg port.SessionLogConfig
1209+func (_e *MockSessionLogger_Expecter) CreateLogger(ctx interface{}, sessionID interface{}, cfg interface{}) *MockSessionLogger_CreateLogger_Call {
1210+ return &MockSessionLogger_CreateLogger_Call{Call: _e.mock.On("CreateLogger", ctx, sessionID, cfg)}
1211+}
1212+
1213+func (_c *MockSessionLogger_CreateLogger_Call) Run(run func(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig)) *MockSessionLogger_CreateLogger_Call {
1214+ _c.Call.Run(func(args mock.Arguments) {
1215+ var arg0 context.Context
1216+ if args[0] != nil {
1217+ arg0 = args[0].(context.Context)
1218+ }
1219+ var arg1 entity.SessionID
1220+ if args[1] != nil {
1221+ arg1 = args[1].(entity.SessionID)
1222+ }
1223+ var arg2 port.SessionLogConfig
1224+ if args[2] != nil {
1225+ arg2 = args[2].(port.SessionLogConfig)
1226+ }
1227+ run(
1228+ arg0,
1229+ arg1,
1230+ arg2,
1231+ )
1232+ })
1233+ return _c
1234+}
1235+
1236+func (_c *MockSessionLogger_CreateLogger_Call) Return(logger zerolog.Logger, fn func(), err error) *MockSessionLogger_CreateLogger_Call {
1237+ _c.Call.Return(logger, fn, err)
1238+ return _c
1239+}
1240+
1241+func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig) (zerolog.Logger, func(), error)) *MockSessionLogger_CreateLogger_Call {
1242+ _c.Call.Return(run)
1243+ return _c
1244+}
1245+
1246+// NewMockTabListProvider creates a new instance of MockTabListProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1247+// The first argument is typically a *testing.T value.
1248+func NewMockTabListProvider(t interface {
1249+ mock.TestingT
1250+ Cleanup(func())
1251+}) *MockTabListProvider {
1252+ mock := &MockTabListProvider{}
1253+ mock.Mock.Test(t)
1254+
1255+ t.Cleanup(func() { mock.AssertExpectations(t) })
1256+
1257+ return mock
1258+}
1259+
1260+// MockTabListProvider is an autogenerated mock type for the TabListProvider type
1261+type MockTabListProvider struct {
1262+ mock.Mock
1263+}
1264+
1265+type MockTabListProvider_Expecter struct {
1266+ mock *mock.Mock
1267+}
1268+
1269+func (_m *MockTabListProvider) EXPECT() *MockTabListProvider_Expecter {
1270+ return &MockTabListProvider_Expecter{mock: &_m.Mock}
1271+}
1272+
1273+// GetSessionID provides a mock function for the type MockTabListProvider
1274+func (_mock *MockTabListProvider) GetSessionID() entity.SessionID {
1275+ ret := _mock.Called()
1276+
1277+ if len(ret) == 0 {
1278+ panic("no return value specified for GetSessionID")
1279+ }
1280+
1281+ var r0 entity.SessionID
1282+ if returnFunc, ok := ret.Get(0).(func() entity.SessionID); ok {
1283+ r0 = returnFunc()
1284+ } else {
1285+ r0 = ret.Get(0).(entity.SessionID)
1286+ }
1287+ return r0
1288+}
1289+
1290+// MockTabListProvider_GetSessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSessionID'
1291+type MockTabListProvider_GetSessionID_Call struct {
1292+ *mock.Call
1293+}
1294+
1295+// GetSessionID is a helper method to define mock.On call
1296+func (_e *MockTabListProvider_Expecter) GetSessionID() *MockTabListProvider_GetSessionID_Call {
1297+ return &MockTabListProvider_GetSessionID_Call{Call: _e.mock.On("GetSessionID")}
1298+}
1299+
1300+func (_c *MockTabListProvider_GetSessionID_Call) Run(run func()) *MockTabListProvider_GetSessionID_Call {
1301+ _c.Call.Run(func(args mock.Arguments) {
1302+ run()
1303+ })
1304+ return _c
1305+}
1306+
1307+func (_c *MockTabListProvider_GetSessionID_Call) Return(sessionID entity.SessionID) *MockTabListProvider_GetSessionID_Call {
1308+ _c.Call.Return(sessionID)
1309+ return _c
1310+}
1311+
1312+func (_c *MockTabListProvider_GetSessionID_Call) RunAndReturn(run func() entity.SessionID) *MockTabListProvider_GetSessionID_Call {
1313+ _c.Call.Return(run)
1314+ return _c
1315+}
1316+
1317+// GetTabList provides a mock function for the type MockTabListProvider
1318+func (_mock *MockTabListProvider) GetTabList() *entity.TabList {
1319+ ret := _mock.Called()
1320+
1321+ if len(ret) == 0 {
1322+ panic("no return value specified for GetTabList")
1323+ }
1324+
1325+ var r0 *entity.TabList
1326+ if returnFunc, ok := ret.Get(0).(func() *entity.TabList); ok {
1327+ r0 = returnFunc()
1328+ } else {
1329+ if ret.Get(0) != nil {
1330+ r0 = ret.Get(0).(*entity.TabList)
1331+ }
1332+ }
1333+ return r0
1334+}
1335+
1336+// MockTabListProvider_GetTabList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTabList'
1337+type MockTabListProvider_GetTabList_Call struct {
1338+ *mock.Call
1339+}
1340+
1341+// GetTabList is a helper method to define mock.On call
1342+func (_e *MockTabListProvider_Expecter) GetTabList() *MockTabListProvider_GetTabList_Call {
1343+ return &MockTabListProvider_GetTabList_Call{Call: _e.mock.On("GetTabList")}
1344+}
1345+
1346+func (_c *MockTabListProvider_GetTabList_Call) Run(run func()) *MockTabListProvider_GetTabList_Call {
1347+ _c.Call.Run(func(args mock.Arguments) {
1348+ run()
1349+ })
1350+ return _c
1351+}
1352+
1353+func (_c *MockTabListProvider_GetTabList_Call) Return(tabList *entity.TabList) *MockTabListProvider_GetTabList_Call {
1354+ _c.Call.Return(tabList)
1355+ return _c
1356+}
1357+
1358+func (_c *MockTabListProvider_GetTabList_Call) RunAndReturn(run func() *entity.TabList) *MockTabListProvider_GetTabList_Call {
1359+ _c.Call.Return(run)
1360+ return _c
1361+}
1362+
1363+// NewMockSessionSpawner creates a new instance of MockSessionSpawner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1364+// The first argument is typically a *testing.T value.
1365+func NewMockSessionSpawner(t interface {
1366+ mock.TestingT
1367+ Cleanup(func())
1368+}) *MockSessionSpawner {
1369+ mock := &MockSessionSpawner{}
1370+ mock.Mock.Test(t)
1371+
1372+ t.Cleanup(func() { mock.AssertExpectations(t) })
1373+
1374+ return mock
1375+}
1376+
1377+// MockSessionSpawner is an autogenerated mock type for the SessionSpawner type
1378+type MockSessionSpawner struct {
1379+ mock.Mock
1380+}
1381+
1382+type MockSessionSpawner_Expecter struct {
1383+ mock *mock.Mock
1384+}
1385+
1386+func (_m *MockSessionSpawner) EXPECT() *MockSessionSpawner_Expecter {
1387+ return &MockSessionSpawner_Expecter{mock: &_m.Mock}
1388+}
1389+
1390+// SpawnWithSession provides a mock function for the type MockSessionSpawner
1391+func (_mock *MockSessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
1392+ ret := _mock.Called(sessionID)
1393+
1394+ if len(ret) == 0 {
1395+ panic("no return value specified for SpawnWithSession")
1396+ }
1397+
1398+ var r0 error
1399+ if returnFunc, ok := ret.Get(0).(func(entity.SessionID) error); ok {
1400+ r0 = returnFunc(sessionID)
1401+ } else {
1402+ r0 = ret.Error(0)
1403+ }
1404+ return r0
1405+}
1406+
1407+// MockSessionSpawner_SpawnWithSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SpawnWithSession'
1408+type MockSessionSpawner_SpawnWithSession_Call struct {
1409+ *mock.Call
1410+}
1411+
1412+// SpawnWithSession is a helper method to define mock.On call
1413+// - sessionID entity.SessionID
1414+func (_e *MockSessionSpawner_Expecter) SpawnWithSession(sessionID interface{}) *MockSessionSpawner_SpawnWithSession_Call {
1415+ return &MockSessionSpawner_SpawnWithSession_Call{Call: _e.mock.On("SpawnWithSession", sessionID)}
1416+}
1417+
1418+func (_c *MockSessionSpawner_SpawnWithSession_Call) Run(run func(sessionID entity.SessionID)) *MockSessionSpawner_SpawnWithSession_Call {
1419+ _c.Call.Run(func(args mock.Arguments) {
1420+ var arg0 entity.SessionID
1421+ if args[0] != nil {
1422+ arg0 = args[0].(entity.SessionID)
1423+ }
1424+ run(
1425+ arg0,
1426+ )
1427+ })
1428+ return _c
1429+}
1430+
1431+func (_c *MockSessionSpawner_SpawnWithSession_Call) Return(err error) *MockSessionSpawner_SpawnWithSession_Call {
1432+ _c.Call.Return(err)
1433+ return _c
1434+}
1435+
1436+func (_c *MockSessionSpawner_SpawnWithSession_Call) RunAndReturn(run func(sessionID entity.SessionID) error) *MockSessionSpawner_SpawnWithSession_Call {
1437+ _c.Call.Return(run)
1438+ return _c
1439+}
1440+
1441+// NewMockMemoryPressureApplier creates a new instance of MockMemoryPressureApplier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1442+// The first argument is typically a *testing.T value.
1443+func NewMockMemoryPressureApplier(t interface {
1444+ mock.TestingT
1445+ Cleanup(func())
1446+}) *MockMemoryPressureApplier {
1447+ mock := &MockMemoryPressureApplier{}
1448+ mock.Mock.Test(t)
1449+
1450+ t.Cleanup(func() { mock.AssertExpectations(t) })
1451+
1452+ return mock
1453+}
1454+
1455+// MockMemoryPressureApplier is an autogenerated mock type for the MemoryPressureApplier type
1456+type MockMemoryPressureApplier struct {
1457+ mock.Mock
1458+}
1459+
1460+type MockMemoryPressureApplier_Expecter struct {
1461+ mock *mock.Mock
1462+}
1463+
1464+func (_m *MockMemoryPressureApplier) EXPECT() *MockMemoryPressureApplier_Expecter {
1465+ return &MockMemoryPressureApplier_Expecter{mock: &_m.Mock}
1466+}
1467+
1468+// ApplyNetworkProcessSettings provides a mock function for the type MockMemoryPressureApplier
1469+func (_mock *MockMemoryPressureApplier) ApplyNetworkProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) error {
1470+ ret := _mock.Called(ctx, cfg)
1471+
1472+ if len(ret) == 0 {
1473+ panic("no return value specified for ApplyNetworkProcessSettings")
1474+ }
1475+
1476+ var r0 error
1477+ if returnFunc, ok := ret.Get(0).(func(context.Context, *port.MemoryPressureConfig) error); ok {
1478+ r0 = returnFunc(ctx, cfg)
1479+ } else {
1480+ r0 = ret.Error(0)
1481+ }
1482+ return r0
1483+}
1484+
1485+// MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyNetworkProcessSettings'
1486+type MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call struct {
1487+ *mock.Call
1488+}
1489+
1490+// ApplyNetworkProcessSettings is a helper method to define mock.On call
1491+// - ctx context.Context
1492+// - cfg *port.MemoryPressureConfig
1493+func (_e *MockMemoryPressureApplier_Expecter) ApplyNetworkProcessSettings(ctx interface{}, cfg interface{}) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call {
1494+ return &MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call{Call: _e.mock.On("ApplyNetworkProcessSettings", ctx, cfg)}
1495+}
1496+
1497+func (_c *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call) Run(run func(ctx context.Context, cfg *port.MemoryPressureConfig)) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call {
1498+ _c.Call.Run(func(args mock.Arguments) {
1499+ var arg0 context.Context
1500+ if args[0] != nil {
1501+ arg0 = args[0].(context.Context)
1502+ }
1503+ var arg1 *port.MemoryPressureConfig
1504+ if args[1] != nil {
1505+ arg1 = args[1].(*port.MemoryPressureConfig)
1506+ }
1507+ run(
1508+ arg0,
1509+ arg1,
1510+ )
1511+ })
1512+ return _c
1513+}
1514+
1515+func (_c *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call) Return(err error) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call {
1516+ _c.Call.Return(err)
1517+ return _c
1518+}
1519+
1520+func (_c *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call) RunAndReturn(run func(ctx context.Context, cfg *port.MemoryPressureConfig) error) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call {
1521+ _c.Call.Return(run)
1522+ return _c
1523+}
1524+
1525+// ApplyWebProcessSettings provides a mock function for the type MockMemoryPressureApplier
1526+func (_mock *MockMemoryPressureApplier) ApplyWebProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) (any, error) {
1527+ ret := _mock.Called(ctx, cfg)
1528+
1529+ if len(ret) == 0 {
1530+ panic("no return value specified for ApplyWebProcessSettings")
1531+ }
1532+
1533+ var r0 any
1534+ var r1 error
1535+ if returnFunc, ok := ret.Get(0).(func(context.Context, *port.MemoryPressureConfig) (any, error)); ok {
1536+ return returnFunc(ctx, cfg)
1537+ }
1538+ if returnFunc, ok := ret.Get(0).(func(context.Context, *port.MemoryPressureConfig) any); ok {
1539+ r0 = returnFunc(ctx, cfg)
1540+ } else {
1541+ if ret.Get(0) != nil {
1542+ r0 = ret.Get(0).(any)
1543+ }
1544+ }
1545+ if returnFunc, ok := ret.Get(1).(func(context.Context, *port.MemoryPressureConfig) error); ok {
1546+ r1 = returnFunc(ctx, cfg)
1547+ } else {
1548+ r1 = ret.Error(1)
1549+ }
1550+ return r0, r1
1551+}
1552+
1553+// MockMemoryPressureApplier_ApplyWebProcessSettings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyWebProcessSettings'
1554+type MockMemoryPressureApplier_ApplyWebProcessSettings_Call struct {
1555+ *mock.Call
1556+}
1557+
1558+// ApplyWebProcessSettings is a helper method to define mock.On call
1559+// - ctx context.Context
1560+// - cfg *port.MemoryPressureConfig
1561+func (_e *MockMemoryPressureApplier_Expecter) ApplyWebProcessSettings(ctx interface{}, cfg interface{}) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call {
1562+ return &MockMemoryPressureApplier_ApplyWebProcessSettings_Call{Call: _e.mock.On("ApplyWebProcessSettings", ctx, cfg)}
1563+}
1564+
1565+func (_c *MockMemoryPressureApplier_ApplyWebProcessSettings_Call) Run(run func(ctx context.Context, cfg *port.MemoryPressureConfig)) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call {
1566+ _c.Call.Run(func(args mock.Arguments) {
1567+ var arg0 context.Context
1568+ if args[0] != nil {
1569+ arg0 = args[0].(context.Context)
1570+ }
1571+ var arg1 *port.MemoryPressureConfig
1572+ if args[1] != nil {
1573+ arg1 = args[1].(*port.MemoryPressureConfig)
1574+ }
1575+ run(
1576+ arg0,
1577+ arg1,
1578+ )
1579+ })
1580+ return _c
1581+}
1582+
1583+func (_c *MockMemoryPressureApplier_ApplyWebProcessSettings_Call) Return(v any, err error) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call {
1584+ _c.Call.Return(v, err)
1585+ return _c
1586+}
1587+
1588+func (_c *MockMemoryPressureApplier_ApplyWebProcessSettings_Call) RunAndReturn(run func(ctx context.Context, cfg *port.MemoryPressureConfig) (any, error)) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call {
1589+ _c.Call.Return(run)
1590+ return _c
1591+}
1592+
1593+// NewMockWebKitContextProvider creates a new instance of MockWebKitContextProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1594+// The first argument is typically a *testing.T value.
1595+func NewMockWebKitContextProvider(t interface {
19 mock.TestingT1596 mock.TestingT
20 Cleanup(func())1597 Cleanup(func())
21-}) *MockSessionLogger {1598+}) *MockWebKitContextProvider {
22- mock := &MockSessionLogger{}1599+ mock := &MockWebKitContextProvider{}
23 mock.Mock.Test(t)1600 mock.Mock.Test(t)
24 1601
25 t.Cleanup(func() { mock.AssertExpectations(t) })1602 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -27,99 +1604,248 @@ func NewMockSessionLogger(t interface {
27 return mock1604 return mock
28 }1605 }
29 1606
30-// MockSessionLogger is an autogenerated mock type for the SessionLogger type1607+// MockWebKitContextProvider is an autogenerated mock type for the WebKitContextProvider type
31-type MockSessionLogger struct {1608+type MockWebKitContextProvider struct {
32 mock.Mock1609 mock.Mock
33 }1610 }
34 1611
35-type MockSessionLogger_Expecter struct {1612+type MockWebKitContextProvider_Expecter struct {
36 mock *mock.Mock1613 mock *mock.Mock
37 }1614 }
38 1615
39-func (_m *MockSessionLogger) EXPECT() *MockSessionLogger_Expecter {1616+func (_m *MockWebKitContextProvider) EXPECT() *MockWebKitContextProvider_Expecter {
40- return &MockSessionLogger_Expecter{mock: &_m.Mock}1617+ return &MockWebKitContextProvider_Expecter{mock: &_m.Mock}
41 }1618 }
42 1619
43-// CreateLogger provides a mock function for the type MockSessionLogger1620+// CacheDir provides a mock function for the type MockWebKitContextProvider
44-func (_mock *MockSessionLogger) CreateLogger(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig) (zerolog.Logger, func(), error) {1621+func (_mock *MockWebKitContextProvider) CacheDir() string {
45- ret := _mock.Called(ctx, sessionID, cfg)1622+ ret := _mock.Called()
46 1623
47 if len(ret) == 0 {1624 if len(ret) == 0 {
48- panic("no return value specified for CreateLogger")1625+ panic("no return value specified for CacheDir")
49 }1626 }
50 1627
51- var r0 zerolog.Logger1628+ var r0 string
52- var r1 func()1629+ if returnFunc, ok := ret.Get(0).(func() string); ok {
53- var r2 error1630+ r0 = returnFunc()
54- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, port.SessionLogConfig) (zerolog.Logger, func(), error)); ok {1631+ } else {
55- return returnFunc(ctx, sessionID, cfg)1632+ r0 = ret.Get(0).(string)
56 }1633 }
57- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, port.SessionLogConfig) zerolog.Logger); ok {1634+ return r0
58- r0 = returnFunc(ctx, sessionID, cfg)1635+}
1636+
1637+// MockWebKitContextProvider_CacheDir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CacheDir'
1638+type MockWebKitContextProvider_CacheDir_Call struct {
1639+ *mock.Call
1640+}
1641+
1642+// CacheDir is a helper method to define mock.On call
1643+func (_e *MockWebKitContextProvider_Expecter) CacheDir() *MockWebKitContextProvider_CacheDir_Call {
1644+ return &MockWebKitContextProvider_CacheDir_Call{Call: _e.mock.On("CacheDir")}
1645+}
1646+
1647+func (_c *MockWebKitContextProvider_CacheDir_Call) Run(run func()) *MockWebKitContextProvider_CacheDir_Call {
1648+ _c.Call.Run(func(args mock.Arguments) {
1649+ run()
1650+ })
1651+ return _c
1652+}
1653+
1654+func (_c *MockWebKitContextProvider_CacheDir_Call) Return(s string) *MockWebKitContextProvider_CacheDir_Call {
1655+ _c.Call.Return(s)
1656+ return _c
1657+}
1658+
1659+func (_c *MockWebKitContextProvider_CacheDir_Call) RunAndReturn(run func() string) *MockWebKitContextProvider_CacheDir_Call {
1660+ _c.Call.Return(run)
1661+ return _c
1662+}
1663+
1664+// Close provides a mock function for the type MockWebKitContextProvider
1665+func (_mock *MockWebKitContextProvider) Close() error {
1666+ ret := _mock.Called()
1667+
1668+ if len(ret) == 0 {
1669+ panic("no return value specified for Close")
1670+ }
1671+
1672+ var r0 error
1673+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1674+ r0 = returnFunc()
59 } else {1675 } else {
60- r0 = ret.Get(0).(zerolog.Logger)1676+ r0 = ret.Error(0)
61 }1677 }
62- if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID, port.SessionLogConfig) func()); ok {1678+ return r0
63- r1 = returnFunc(ctx, sessionID, cfg)1679+}
1680+
1681+// MockWebKitContextProvider_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
1682+type MockWebKitContextProvider_Close_Call struct {
1683+ *mock.Call
1684+}
1685+
1686+// Close is a helper method to define mock.On call
1687+func (_e *MockWebKitContextProvider_Expecter) Close() *MockWebKitContextProvider_Close_Call {
1688+ return &MockWebKitContextProvider_Close_Call{Call: _e.mock.On("Close")}
1689+}
1690+
1691+func (_c *MockWebKitContextProvider_Close_Call) Run(run func()) *MockWebKitContextProvider_Close_Call {
1692+ _c.Call.Run(func(args mock.Arguments) {
1693+ run()
1694+ })
1695+ return _c
1696+}
1697+
1698+func (_c *MockWebKitContextProvider_Close_Call) Return(err error) *MockWebKitContextProvider_Close_Call {
1699+ _c.Call.Return(err)
1700+ return _c
1701+}
1702+
1703+func (_c *MockWebKitContextProvider_Close_Call) RunAndReturn(run func() error) *MockWebKitContextProvider_Close_Call {
1704+ _c.Call.Return(run)
1705+ return _c
1706+}
1707+
1708+// DataDir provides a mock function for the type MockWebKitContextProvider
1709+func (_mock *MockWebKitContextProvider) DataDir() string {
1710+ ret := _mock.Called()
1711+
1712+ if len(ret) == 0 {
1713+ panic("no return value specified for DataDir")
1714+ }
1715+
1716+ var r0 string
1717+ if returnFunc, ok := ret.Get(0).(func() string); ok {
1718+ r0 = returnFunc()
64 } else {1719 } else {
65- if ret.Get(1) != nil {1720+ r0 = ret.Get(0).(string)
66- r1 = ret.Get(1).(func())
67- }
68 }1721 }
69- if returnFunc, ok := ret.Get(2).(func(context.Context, entity.SessionID, port.SessionLogConfig) error); ok {1722+ return r0
70- r2 = returnFunc(ctx, sessionID, cfg)1723+}
1724+
1725+// MockWebKitContextProvider_DataDir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DataDir'
1726+type MockWebKitContextProvider_DataDir_Call struct {
1727+ *mock.Call
1728+}
1729+
1730+// DataDir is a helper method to define mock.On call
1731+func (_e *MockWebKitContextProvider_Expecter) DataDir() *MockWebKitContextProvider_DataDir_Call {
1732+ return &MockWebKitContextProvider_DataDir_Call{Call: _e.mock.On("DataDir")}
1733+}
1734+
1735+func (_c *MockWebKitContextProvider_DataDir_Call) Run(run func()) *MockWebKitContextProvider_DataDir_Call {
1736+ _c.Call.Run(func(args mock.Arguments) {
1737+ run()
1738+ })
1739+ return _c
1740+}
1741+
1742+func (_c *MockWebKitContextProvider_DataDir_Call) Return(s string) *MockWebKitContextProvider_DataDir_Call {
1743+ _c.Call.Return(s)
1744+ return _c
1745+}
1746+
1747+func (_c *MockWebKitContextProvider_DataDir_Call) RunAndReturn(run func() string) *MockWebKitContextProvider_DataDir_Call {
1748+ _c.Call.Return(run)
1749+ return _c
1750+}
1751+
1752+// Initialize provides a mock function for the type MockWebKitContextProvider
1753+func (_mock *MockWebKitContextProvider) Initialize(ctx context.Context, opts port.WebKitContextOptions) error {
1754+ ret := _mock.Called(ctx, opts)
1755+
1756+ if len(ret) == 0 {
1757+ panic("no return value specified for Initialize")
1758+ }
1759+
1760+ var r0 error
1761+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.WebKitContextOptions) error); ok {
1762+ r0 = returnFunc(ctx, opts)
71 } else {1763 } else {
72- r2 = ret.Error(2)1764+ r0 = ret.Error(0)
73 }1765 }
74- return r0, r1, r21766+ return r0
75 }1767 }
76 1768
77-// MockSessionLogger_CreateLogger_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateLogger'1769+// MockWebKitContextProvider_Initialize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Initialize'
78-type MockSessionLogger_CreateLogger_Call struct {1770+type MockWebKitContextProvider_Initialize_Call struct {
79 *mock.Call1771 *mock.Call
80 }1772 }
81 1773
82-// CreateLogger is a helper method to define mock.On call1774+// Initialize is a helper method to define mock.On call
83 // - ctx context.Context1775 // - ctx context.Context
84-// - sessionID entity.SessionID1776+// - opts port.WebKitContextOptions
85-// - cfg port.SessionLogConfig1777+func (_e *MockWebKitContextProvider_Expecter) Initialize(ctx interface{}, opts interface{}) *MockWebKitContextProvider_Initialize_Call {
86-func (_e *MockSessionLogger_Expecter) CreateLogger(ctx interface{}, sessionID interface{}, cfg interface{}) *MockSessionLogger_CreateLogger_Call {1778+ return &MockWebKitContextProvider_Initialize_Call{Call: _e.mock.On("Initialize", ctx, opts)}
87- return &MockSessionLogger_CreateLogger_Call{Call: _e.mock.On("CreateLogger", ctx, sessionID, cfg)}
88 }1779 }
89 1780
90-func (_c *MockSessionLogger_CreateLogger_Call) Run(run func(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig)) *MockSessionLogger_CreateLogger_Call {1781+func (_c *MockWebKitContextProvider_Initialize_Call) Run(run func(ctx context.Context, opts port.WebKitContextOptions)) *MockWebKitContextProvider_Initialize_Call {
91 _c.Call.Run(func(args mock.Arguments) {1782 _c.Call.Run(func(args mock.Arguments) {
92 var arg0 context.Context1783 var arg0 context.Context
93 if args[0] != nil {1784 if args[0] != nil {
94 arg0 = args[0].(context.Context)1785 arg0 = args[0].(context.Context)
95 }1786 }
96- var arg1 entity.SessionID1787+ var arg1 port.WebKitContextOptions
97 if args[1] != nil {1788 if args[1] != nil {
98- arg1 = args[1].(entity.SessionID)1789+ arg1 = args[1].(port.WebKitContextOptions)
99- }
100- var arg2 port.SessionLogConfig
101- if args[2] != nil {
102- arg2 = args[2].(port.SessionLogConfig)
103 }1790 }
104 run(1791 run(
105 arg0,1792 arg0,
106 arg1,1793 arg1,
107- arg2,
108 )1794 )
109 })1795 })
110 return _c1796 return _c
111 }1797 }
112 1798
113-func (_c *MockSessionLogger_CreateLogger_Call) Return(logger zerolog.Logger, fn func(), err error) *MockSessionLogger_CreateLogger_Call {1799+func (_c *MockWebKitContextProvider_Initialize_Call) Return(err error) *MockWebKitContextProvider_Initialize_Call {
114- _c.Call.Return(logger, fn, err)1800+ _c.Call.Return(err)
115 return _c1801 return _c
116 }1802 }
117 1803
118-func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig) (zerolog.Logger, func(), error)) *MockSessionLogger_CreateLogger_Call {1804+func (_c *MockWebKitContextProvider_Initialize_Call) RunAndReturn(run func(ctx context.Context, opts port.WebKitContextOptions) error) *MockWebKitContextProvider_Initialize_Call {
119 _c.Call.Return(run)1805 _c.Call.Return(run)
120 return _c1806 return _c
121 }1807 }
122 1808
1809+// PrefetchDNS provides a mock function for the type MockWebKitContextProvider
1810+func (_mock *MockWebKitContextProvider) PrefetchDNS(hostname string) {
1811+ _mock.Called(hostname)
1812+ return
1813+}
1814+
1815+// MockWebKitContextProvider_PrefetchDNS_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrefetchDNS'
1816+type MockWebKitContextProvider_PrefetchDNS_Call struct {
1817+ *mock.Call
1818+}
1819+
1820+// PrefetchDNS is a helper method to define mock.On call
1821+// - hostname string
1822+func (_e *MockWebKitContextProvider_Expecter) PrefetchDNS(hostname interface{}) *MockWebKitContextProvider_PrefetchDNS_Call {
1823+ return &MockWebKitContextProvider_PrefetchDNS_Call{Call: _e.mock.On("PrefetchDNS", hostname)}
1824+}
1825+
1826+func (_c *MockWebKitContextProvider_PrefetchDNS_Call) Run(run func(hostname string)) *MockWebKitContextProvider_PrefetchDNS_Call {
1827+ _c.Call.Run(func(args mock.Arguments) {
1828+ var arg0 string
1829+ if args[0] != nil {
1830+ arg0 = args[0].(string)
1831+ }
1832+ run(
1833+ arg0,
1834+ )
1835+ })
1836+ return _c
1837+}
1838+
1839+func (_c *MockWebKitContextProvider_PrefetchDNS_Call) Return() *MockWebKitContextProvider_PrefetchDNS_Call {
1840+ _c.Call.Return()
1841+ return _c
1842+}
1843+
1844+func (_c *MockWebKitContextProvider_PrefetchDNS_Call) RunAndReturn(run func(hostname string)) *MockWebKitContextProvider_PrefetchDNS_Call {
1845+ _c.Run(run)
1846+ return _c
1847+}
1848+
123 // NewMockWebView creates a new instance of MockWebView. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1849 // NewMockWebView creates a new instance of MockWebView. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
124 // The first argument is typically a *testing.T value.1850 // The first argument is typically a *testing.T value.
125 func NewMockWebView(t interface {1851 func NewMockWebView(t interface {
@@ -592,6 +2318,50 @@ func (_c *MockWebView_IsDestroyed_Call) RunAndReturn(run func() bool) *MockWebVi
592 return _c2318 return _c
593 }2319 }
594 2320
2321+// IsFullscreen provides a mock function for the type MockWebView
2322+func (_mock *MockWebView) IsFullscreen() bool {
2323+ ret := _mock.Called()
2324+
2325+ if len(ret) == 0 {
2326+ panic("no return value specified for IsFullscreen")
2327+ }
2328+
2329+ var r0 bool
2330+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
2331+ r0 = returnFunc()
2332+ } else {
2333+ r0 = ret.Get(0).(bool)
2334+ }
2335+ return r0
2336+}
2337+
2338+// MockWebView_IsFullscreen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsFullscreen'
2339+type MockWebView_IsFullscreen_Call struct {
2340+ *mock.Call
2341+}
2342+
2343+// IsFullscreen is a helper method to define mock.On call
2344+func (_e *MockWebView_Expecter) IsFullscreen() *MockWebView_IsFullscreen_Call {
2345+ return &MockWebView_IsFullscreen_Call{Call: _e.mock.On("IsFullscreen")}
2346+}
2347+
2348+func (_c *MockWebView_IsFullscreen_Call) Run(run func()) *MockWebView_IsFullscreen_Call {
2349+ _c.Call.Run(func(args mock.Arguments) {
2350+ run()
2351+ })
2352+ return _c
2353+}
2354+
2355+func (_c *MockWebView_IsFullscreen_Call) Return(b bool) *MockWebView_IsFullscreen_Call {
2356+ _c.Call.Return(b)
2357+ return _c
2358+}
2359+
2360+func (_c *MockWebView_IsFullscreen_Call) RunAndReturn(run func() bool) *MockWebView_IsFullscreen_Call {
2361+ _c.Call.Return(run)
2362+ return _c
2363+}
2364+
595 // IsLoading provides a mock function for the type MockWebView2365 // IsLoading provides a mock function for the type MockWebView
596 func (_mock *MockWebView) IsLoading() bool {2366 func (_mock *MockWebView) IsLoading() bool {
597 ret := _mock.Called()2367 ret := _mock.Called()
@@ -636,6 +2406,50 @@ func (_c *MockWebView_IsLoading_Call) RunAndReturn(run func() bool) *MockWebView
636 return _c2406 return _c
637 }2407 }
638 2408
2409+// IsPlayingAudio provides a mock function for the type MockWebView
2410+func (_mock *MockWebView) IsPlayingAudio() bool {
2411+ ret := _mock.Called()
2412+
2413+ if len(ret) == 0 {
2414+ panic("no return value specified for IsPlayingAudio")
2415+ }
2416+
2417+ var r0 bool
2418+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
2419+ r0 = returnFunc()
2420+ } else {
2421+ r0 = ret.Get(0).(bool)
2422+ }
2423+ return r0
2424+}
2425+
2426+// MockWebView_IsPlayingAudio_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsPlayingAudio'
2427+type MockWebView_IsPlayingAudio_Call struct {
2428+ *mock.Call
2429+}
2430+
2431+// IsPlayingAudio is a helper method to define mock.On call
2432+func (_e *MockWebView_Expecter) IsPlayingAudio() *MockWebView_IsPlayingAudio_Call {
2433+ return &MockWebView_IsPlayingAudio_Call{Call: _e.mock.On("IsPlayingAudio")}
2434+}
2435+
2436+func (_c *MockWebView_IsPlayingAudio_Call) Run(run func()) *MockWebView_IsPlayingAudio_Call {
2437+ _c.Call.Run(func(args mock.Arguments) {
2438+ run()
2439+ })
2440+ return _c
2441+}
2442+
2443+func (_c *MockWebView_IsPlayingAudio_Call) Return(b bool) *MockWebView_IsPlayingAudio_Call {
2444+ _c.Call.Return(b)
2445+ return _c
2446+}
2447+
2448+func (_c *MockWebView_IsPlayingAudio_Call) RunAndReturn(run func() bool) *MockWebView_IsPlayingAudio_Call {
2449+ _c.Call.Return(run)
2450+ return _c
2451+}
2452+
639 // LoadHTML provides a mock function for the type MockWebView2453 // LoadHTML provides a mock function for the type MockWebView
640 func (_mock *MockWebView) LoadHTML(ctx context.Context, content string, baseURI string) error {2454 func (_mock *MockWebView) LoadHTML(ctx context.Context, content string, baseURI string) error {
641 ret := _mock.Called(ctx, content, baseURI)2455 ret := _mock.Called(ctx, content, baseURI)
M internal/application/port/rendering_env.go +10 -0
@@ -46,6 +46,16 @@ type RenderingEnvSettings struct {
46 ShowFPS bool46 ShowFPS bool
47 SampleMemory bool47 SampleMemory bool
48 DebugFrames bool48 DebugFrames bool
49+
50+ // --- Skia rendering thread settings (from PerformanceConfig) ---
51+ // SkiaCPUPaintingThreads sets WEBKIT_SKIA_CPU_PAINTING_THREADS.
52+ // 0 means unset (use WebKit default).
53+ SkiaCPUPaintingThreads int
54+ // SkiaGPUPaintingThreads sets WEBKIT_SKIA_GPU_PAINTING_THREADS.
55+ // -1 means unset; 0 disables GPU tile painting.
56+ SkiaGPUPaintingThreads int
57+ // SkiaEnableCPURendering forces CPU rendering via WEBKIT_SKIA_ENABLE_CPU_RENDERING=1.
58+ SkiaEnableCPURendering bool
49 }59 }
50 60
51 // RenderingEnvManager configures rendering environment variables61 // RenderingEnvManager configures rendering environment variables
A internal/application/port/session_state.go +18 -0
@@ -0,0 +1,18 @@
1+package port
2+
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// TabListProvider provides access to the current tab list state.
6+// Implemented by the UI layer to allow the snapshot service to read state.
7+type TabListProvider interface {
8+ // GetTabList returns the current tab list state.
9+ GetTabList() *entity.TabList
10+ // GetSessionID returns the current session ID.
11+ GetSessionID() entity.SessionID
12+}
13+
14+// SessionSpawner spawns a new dumber instance for resurrection.
15+type SessionSpawner interface {
16+ // SpawnWithSession starts a new dumber instance to restore a session.
17+ SpawnWithSession(sessionID entity.SessionID) error
18+}
A internal/application/port/updater.go +51 -0
@@ -0,0 +1,51 @@
1+// Package port defines interfaces for external dependencies.
2+package port
3+
4+import (
5+ "context"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// UpdateChecker checks for available updates from a remote source.
11+type UpdateChecker interface {
12+ // CheckForUpdate compares the current version with the latest available release.
13+ // Returns update info including whether a newer version is available.
14+ CheckForUpdate(ctx context.Context, currentVersion string) (*entity.UpdateInfo, error)
15+}
16+
17+// UpdateDownloader downloads and extracts update archives.
18+type UpdateDownloader interface {
19+ // Download fetches the update archive to the specified destination directory.
20+ // Returns the path to the downloaded archive file.
21+ Download(ctx context.Context, downloadURL string, destDir string) (archivePath string, err error)
22+
23+ // Extract extracts the binary from the downloaded archive.
24+ // Returns the path to the extracted binary.
25+ Extract(ctx context.Context, archivePath string, destDir string) (binaryPath string, err error)
26+}
27+
28+// UpdateApplier stages and applies updates.
29+type UpdateApplier interface {
30+ // CanSelfUpdate checks if the current binary is writable by the current user.
31+ // Returns true if auto-update is possible, false if user lacks write permission.
32+ CanSelfUpdate(ctx context.Context) bool
33+
34+ // GetBinaryPath returns the path to the currently running binary.
35+ GetBinaryPath() (string, error)
36+
37+ // StageUpdate prepares the new binary to be applied on exit.
38+ // The staged update will be applied when ApplyOnExit is called.
39+ StageUpdate(ctx context.Context, newBinaryPath string) error
40+
41+ // HasStagedUpdate checks if there is an update staged and ready to apply.
42+ HasStagedUpdate(ctx context.Context) bool
43+
44+ // ApplyOnExit replaces the current binary with the staged update.
45+ // This should be called during graceful shutdown.
46+ // Returns the path to the backup of the old binary.
47+ ApplyOnExit(ctx context.Context) (backupPath string, err error)
48+
49+ // ClearStagedUpdate removes any staged update without applying it.
50+ ClearStagedUpdate(ctx context.Context) error
51+}
A internal/application/port/webkit_context.go +102 -0
@@ -0,0 +1,102 @@
1+// Package port defines interfaces for external dependencies.
2+package port
3+
4+import "context"
5+
6+// MemoryPressureConfig holds memory pressure settings for a WebKit process.
7+// Zero values mean "use WebKit defaults".
8+type MemoryPressureConfig struct {
9+ // MemoryLimitMB sets the memory limit in megabytes.
10+ // 0 means unset (uses WebKit default: system RAM capped at 3GB).
11+ MemoryLimitMB int
12+
13+ // PollIntervalSec sets the interval for memory usage checks.
14+ // 0 means unset (uses WebKit default: 30 seconds).
15+ PollIntervalSec float64
16+
17+ // ConservativeThreshold sets threshold for conservative memory release.
18+ // Must be in (0, 1). 0 means unset (uses WebKit default: 0.33).
19+ ConservativeThreshold float64
20+
21+ // StrictThreshold sets threshold for strict memory release.
22+ // Must be in (0, 1). 0 means unset (uses WebKit default: 0.5).
23+ StrictThreshold float64
24+}
25+
26+// IsConfigured returns true if any memory pressure setting is configured.
27+func (c *MemoryPressureConfig) IsConfigured() bool {
28+ if c == nil {
29+ return false
30+ }
31+ return c.MemoryLimitMB > 0 ||
32+ c.PollIntervalSec > 0 ||
33+ c.ConservativeThreshold > 0 ||
34+ c.StrictThreshold > 0
35+}
36+
37+// WebKitContextOptions configures WebKitContext creation.
38+type WebKitContextOptions struct {
39+ // DataDir is the directory for persistent data (cookies, storage, etc.).
40+ DataDir string
41+
42+ // CacheDir is the directory for cache data.
43+ CacheDir string
44+
45+ // WebProcessMemory configures memory pressure for web processes.
46+ // nil means use WebKit defaults.
47+ WebProcessMemory *MemoryPressureConfig
48+
49+ // NetworkProcessMemory configures memory pressure for the network process.
50+ // nil means use WebKit defaults.
51+ NetworkProcessMemory *MemoryPressureConfig
52+}
53+
54+// IsWebProcessMemoryConfigured returns true if web process memory settings are configured.
55+func (o *WebKitContextOptions) IsWebProcessMemoryConfigured() bool {
56+ if o == nil {
57+ return false
58+ }
59+ return o.WebProcessMemory.IsConfigured()
60+}
61+
62+// IsNetworkProcessMemoryConfigured returns true if network process memory settings are configured.
63+func (o *WebKitContextOptions) IsNetworkProcessMemoryConfigured() bool {
64+ if o == nil {
65+ return false
66+ }
67+ return o.NetworkProcessMemory.IsConfigured()
68+}
69+
70+// MemoryPressureApplier applies memory pressure settings to WebKit processes.
71+// This interface abstracts the WebKit-specific memory pressure configuration
72+// to allow testing without WebKit dependencies.
73+type MemoryPressureApplier interface {
74+ // ApplyNetworkProcessSettings applies memory pressure settings to the network process.
75+ // Must be called BEFORE creating any NetworkSession.
76+ ApplyNetworkProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) error
77+
78+ // ApplyWebProcessSettings applies memory pressure settings to web processes.
79+ // Returns an opaque settings object that should be passed to WebContext creation.
80+ // Returns nil if no settings are configured.
81+ ApplyWebProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) (any, error)
82+}
83+
84+// WebKitContextProvider creates and manages WebKit contexts.
85+// This interface abstracts WebKit context creation for testability.
86+type WebKitContextProvider interface {
87+ // Initialize sets up the WebKit context with the given options.
88+ // Must be called before any other methods.
89+ Initialize(ctx context.Context, opts WebKitContextOptions) error
90+
91+ // DataDir returns the data directory path.
92+ DataDir() string
93+
94+ // CacheDir returns the cache directory path.
95+ CacheDir() string
96+
97+ // PrefetchDNS prefetches DNS for the given hostname.
98+ PrefetchDNS(hostname string)
99+
100+ // Close releases resources held by the context.
101+ Close() error
102+}
A internal/application/port/webkit_context_test.go +156 -0
@@ -0,0 +1,156 @@
1+package port_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestMemoryPressureConfig_IsConfigured(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ config *port.MemoryPressureConfig
14+ expected bool
15+ }{
16+ {
17+ name: "nil config",
18+ config: nil,
19+ expected: false,
20+ },
21+ {
22+ name: "empty config",
23+ config: &port.MemoryPressureConfig{},
24+ expected: false,
25+ },
26+ {
27+ name: "only MemoryLimitMB set",
28+ config: &port.MemoryPressureConfig{
29+ MemoryLimitMB: 1024,
30+ },
31+ expected: true,
32+ },
33+ {
34+ name: "only PollIntervalSec set",
35+ config: &port.MemoryPressureConfig{
36+ PollIntervalSec: 30.0,
37+ },
38+ expected: true,
39+ },
40+ {
41+ name: "only ConservativeThreshold set",
42+ config: &port.MemoryPressureConfig{
43+ ConservativeThreshold: 0.33,
44+ },
45+ expected: true,
46+ },
47+ {
48+ name: "only StrictThreshold set",
49+ config: &port.MemoryPressureConfig{
50+ StrictThreshold: 0.5,
51+ },
52+ expected: true,
53+ },
54+ {
55+ name: "all defaults (unset)",
56+ config: &port.MemoryPressureConfig{
57+ MemoryLimitMB: 0,
58+ PollIntervalSec: 0,
59+ ConservativeThreshold: 0,
60+ StrictThreshold: 0,
61+ },
62+ expected: false,
63+ },
64+ {
65+ name: "fully configured",
66+ config: &port.MemoryPressureConfig{
67+ MemoryLimitMB: 2048,
68+ PollIntervalSec: 15.0,
69+ ConservativeThreshold: 0.25,
70+ StrictThreshold: 0.6,
71+ },
72+ expected: true,
73+ },
74+ }
75+
76+ for _, tt := range tests {
77+ t.Run(tt.name, func(t *testing.T) {
78+ result := tt.config.IsConfigured()
79+ assert.Equal(t, tt.expected, result)
80+ })
81+ }
82+}
83+
84+func TestWebKitContextOptions_IsWebProcessMemoryConfigured(t *testing.T) {
85+ tests := []struct {
86+ name string
87+ opts port.WebKitContextOptions
88+ expected bool
89+ }{
90+ {
91+ name: "nil WebProcessMemory",
92+ opts: port.WebKitContextOptions{},
93+ expected: false,
94+ },
95+ {
96+ name: "empty WebProcessMemory",
97+ opts: port.WebKitContextOptions{
98+ WebProcessMemory: &port.MemoryPressureConfig{},
99+ },
100+ expected: false,
101+ },
102+ {
103+ name: "configured WebProcessMemory",
104+ opts: port.WebKitContextOptions{
105+ WebProcessMemory: &port.MemoryPressureConfig{
106+ MemoryLimitMB: 1024,
107+ },
108+ },
109+ expected: true,
110+ },
111+ }
112+
113+ for _, tt := range tests {
114+ t.Run(tt.name, func(t *testing.T) {
115+ result := tt.opts.IsWebProcessMemoryConfigured()
116+ assert.Equal(t, tt.expected, result)
117+ })
118+ }
119+}
120+
121+func TestWebKitContextOptions_IsNetworkProcessMemoryConfigured(t *testing.T) {
122+ tests := []struct {
123+ name string
124+ opts port.WebKitContextOptions
125+ expected bool
126+ }{
127+ {
128+ name: "nil NetworkProcessMemory",
129+ opts: port.WebKitContextOptions{},
130+ expected: false,
131+ },
132+ {
133+ name: "empty NetworkProcessMemory",
134+ opts: port.WebKitContextOptions{
135+ NetworkProcessMemory: &port.MemoryPressureConfig{},
136+ },
137+ expected: false,
138+ },
139+ {
140+ name: "configured NetworkProcessMemory",
141+ opts: port.WebKitContextOptions{
142+ NetworkProcessMemory: &port.MemoryPressureConfig{
143+ PollIntervalSec: 20.0,
144+ },
145+ },
146+ expected: true,
147+ },
148+ }
149+
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ result := tt.opts.IsNetworkProcessMemoryConfigured()
153+ assert.Equal(t, tt.expected, result)
154+ })
155+ }
156+}
M internal/application/port/webui_config.go +16 -0
@@ -5,11 +5,27 @@ import "context"
5 // WebUIConfig represents the subset of configuration editable in dumb://config.5 // WebUIConfig represents the subset of configuration editable in dumb://config.
6 type WebUIConfig struct {6 type WebUIConfig struct {
7 Appearance WebUIAppearanceConfig `json:"appearance"`7 Appearance WebUIAppearanceConfig `json:"appearance"`
8+ Performance WebUIPerformanceConfig `json:"performance"`
8 DefaultUIScale float64 `json:"default_ui_scale"`9 DefaultUIScale float64 `json:"default_ui_scale"`
9 DefaultSearchEngine string `json:"default_search_engine"`10 DefaultSearchEngine string `json:"default_search_engine"`
10 SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`11 SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
11 }12 }
12 13
14+// WebUIPerformanceConfig represents performance settings editable in dumb://config.
15+type WebUIPerformanceConfig struct {
16+ Profile string `json:"profile"`
17+ Custom WebUICustomPerformanceConfig `json:"custom"`
18+}
19+
20+// WebUICustomPerformanceConfig holds user-editable fields for custom profile.
21+type WebUICustomPerformanceConfig struct {
22+ SkiaCPUThreads int `json:"skia_cpu_threads"`
23+ SkiaGPUThreads int `json:"skia_gpu_threads"`
24+ WebProcessMemoryMB int `json:"web_process_memory_mb"`
25+ NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
26+ WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
27+}
28+
13 type WebUIAppearanceConfig struct {29 type WebUIAppearanceConfig struct {
14 SansFont string `json:"sans_font"`30 SansFont string `json:"sans_font"`
15 SerifFont string `json:"serif_font"`31 SerifFont string `json:"serif_font"`
M internal/application/port/webview.go +11 -0
@@ -89,6 +89,9 @@ type WebViewCallbacks struct {
89 OnCreate func(request PopupRequest) WebView89 OnCreate func(request PopupRequest) WebView
90 // OnReadyToShow is called when a popup WebView is ready to display.90 // OnReadyToShow is called when a popup WebView is ready to display.
91 OnReadyToShow func()91 OnReadyToShow func()
92+ // OnLinkHover is called when hovering over a link, image, or media element.
93+ // The uri parameter contains the target URL, or empty string when leaving.
94+ OnLinkHover func(uri string)
92 }95 }
93 96
94 // FindOptions configures search behavior.97 // FindOptions configures search behavior.
@@ -190,6 +193,14 @@ type WebView interface {
190 // Pass nil to clear all callbacks.193 // Pass nil to clear all callbacks.
191 SetCallbacks(callbacks *WebViewCallbacks)194 SetCallbacks(callbacks *WebViewCallbacks)
192 195
196+ // --- Media/Fullscreen State ---
197+
198+ // IsFullscreen returns true if the WebView is currently in fullscreen mode.
199+ IsFullscreen() bool
200+
201+ // IsPlayingAudio returns true if the WebView is currently playing audio.
202+ IsPlayingAudio() bool
203+
193 // --- Lifecycle ---204 // --- Lifecycle ---
194 205
195 // IsDestroyed returns true if the WebView has been destroyed.206 // IsDestroyed returns true if the WebView has been destroyed.
M internal/application/port/xdg.go +4 -0
@@ -10,4 +10,8 @@ type XDGPaths interface {
10 FilterJSONDir() (string, error)10 FilterJSONDir() (string, error)
11 FilterStoreDir() (string, error)11 FilterStoreDir() (string, error)
12 FilterCacheDir() (string, error)12 FilterCacheDir() (string, error)
13+
14+ // ManDir returns the user man page directory (man1 section).
15+ // Typically $XDG_DATA_HOME/man/man1 or ~/.local/share/man/man1.
16+ ManDir() (string, error)
13 }17 }
A internal/application/usecase/apply_update.go +151 -0
@@ -0,0 +1,151 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+const (
15+ // File permission for directories.
16+ updateDirPerm = 0o755
17+)
18+
19+// ApplyUpdateInput holds the input for the apply update use case.
20+type ApplyUpdateInput struct {
21+ // DownloadURL is the URL to download the update from.
22+ DownloadURL string
23+}
24+
25+// ApplyUpdateOutput holds the result of the update application.
26+type ApplyUpdateOutput struct {
27+ // Status is the current update status.
28+ Status entity.UpdateStatus
29+ // Message is a human-readable status message.
30+ Message string
31+ // StagedPath is the path to the staged binary (if status is Ready).
32+ StagedPath string
33+}
34+
35+// ApplyUpdateUseCase downloads and stages updates for application on exit.
36+type ApplyUpdateUseCase struct {
37+ downloader port.UpdateDownloader
38+ applier port.UpdateApplier
39+ cacheDir string
40+}
41+
42+// NewApplyUpdateUseCase creates a new apply update use case.
43+func NewApplyUpdateUseCase(
44+ downloader port.UpdateDownloader,
45+ applier port.UpdateApplier,
46+ cacheDir string,
47+) *ApplyUpdateUseCase {
48+ return &ApplyUpdateUseCase{
49+ downloader: downloader,
50+ applier: applier,
51+ cacheDir: cacheDir,
52+ }
53+}
54+
55+// Execute downloads and stages the update for application on exit.
56+func (uc *ApplyUpdateUseCase) Execute(ctx context.Context, input ApplyUpdateInput) (*ApplyUpdateOutput, error) {
57+ log := logging.FromContext(ctx)
58+
59+ // Check if we can self-update.
60+ if !uc.applier.CanSelfUpdate(ctx) {
61+ return &ApplyUpdateOutput{
62+ Status: entity.UpdateStatusFailed,
63+ Message: "Cannot auto-update: binary is not writable",
64+ }, nil
65+ }
66+
67+ // Create download directory.
68+ downloadDir := filepath.Join(uc.cacheDir, "updates")
69+ if err := os.MkdirAll(downloadDir, updateDirPerm); err != nil {
70+ return nil, fmt.Errorf("failed to create download directory: %w", err)
71+ }
72+
73+ log.Info().Str("url", input.DownloadURL).Msg("downloading update")
74+
75+ // Download the archive.
76+ archivePath, err := uc.downloader.Download(ctx, input.DownloadURL, downloadDir)
77+ if err != nil {
78+ return &ApplyUpdateOutput{
79+ Status: entity.UpdateStatusFailed,
80+ Message: fmt.Sprintf("Download failed: %v", err),
81+ }, nil
82+ }
83+
84+ log.Info().Str("archive", archivePath).Msg("extracting update")
85+
86+ // Extract the binary.
87+ binaryPath, err := uc.downloader.Extract(ctx, archivePath, downloadDir)
88+ if err != nil {
89+ _ = os.Remove(archivePath)
90+ return &ApplyUpdateOutput{
91+ Status: entity.UpdateStatusFailed,
92+ Message: fmt.Sprintf("Extraction failed: %v", err),
93+ }, nil
94+ }
95+
96+ log.Info().Str("binary", binaryPath).Msg("staging update")
97+
98+ // Stage the update.
99+ if err := uc.applier.StageUpdate(ctx, binaryPath); err != nil {
100+ _ = os.Remove(archivePath)
101+ _ = os.Remove(binaryPath)
102+ return &ApplyUpdateOutput{
103+ Status: entity.UpdateStatusFailed,
104+ Message: fmt.Sprintf("Staging failed: %v", err),
105+ }, nil
106+ }
107+
108+ // Clean up download artifacts.
109+ _ = os.Remove(archivePath)
110+ _ = os.Remove(binaryPath)
111+
112+ log.Info().Msg("update staged successfully, will apply on exit")
113+
114+ return &ApplyUpdateOutput{
115+ Status: entity.UpdateStatusReady,
116+ Message: "Update ready - will apply on exit",
117+ }, nil
118+}
119+
120+// FinalizeOnExit applies the staged update during shutdown.
121+// This should be called from the app's shutdown handler.
122+func (uc *ApplyUpdateUseCase) FinalizeOnExit(ctx context.Context) error {
123+ log := logging.FromContext(ctx)
124+
125+ if !uc.applier.HasStagedUpdate(ctx) {
126+ log.Debug().Msg("no staged update to apply")
127+ return nil
128+ }
129+
130+ backupPath, err := uc.applier.ApplyOnExit(ctx)
131+ if err != nil {
132+ log.Error().Err(err).Msg("failed to apply update on exit")
133+ return err
134+ }
135+
136+ log.Info().
137+ Str("backup", backupPath).
138+ Msg("update applied successfully")
139+
140+ return nil
141+}
142+
143+// HasPendingUpdate checks if there's an update waiting to be applied on exit.
144+func (uc *ApplyUpdateUseCase) HasPendingUpdate(ctx context.Context) bool {
145+ return uc.applier.HasStagedUpdate(ctx)
146+}
147+
148+// ClearPendingUpdate removes any staged update.
149+func (uc *ApplyUpdateUseCase) ClearPendingUpdate(ctx context.Context) error {
150+ return uc.applier.ClearStagedUpdate(ctx)
151+}
A internal/application/usecase/check_update.go +80 -0
@@ -0,0 +1,80 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/domain/build"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+// CheckUpdateInput holds the input for the check update use case.
12+type CheckUpdateInput struct{}
13+
14+// CheckUpdateOutput holds the result of the update check.
15+type CheckUpdateOutput struct {
16+ // UpdateAvailable is true if a newer version exists.
17+ UpdateAvailable bool
18+ // CanAutoUpdate is true if the binary is writable and auto-update is possible.
19+ CanAutoUpdate bool
20+ // CurrentVersion is the version of the running binary.
21+ CurrentVersion string
22+ // LatestVersion is the latest available version.
23+ LatestVersion string
24+ // ReleaseURL is the URL to the GitHub release page.
25+ ReleaseURL string
26+ // DownloadURL is the direct download URL for the archive.
27+ DownloadURL string
28+}
29+
30+// CheckUpdateUseCase checks for available updates.
31+type CheckUpdateUseCase struct {
32+ checker port.UpdateChecker
33+ applier port.UpdateApplier
34+ buildInfo build.Info
35+}
36+
37+// NewCheckUpdateUseCase creates a new check update use case.
38+func NewCheckUpdateUseCase(
39+ checker port.UpdateChecker,
40+ applier port.UpdateApplier,
41+ buildInfo build.Info,
42+) *CheckUpdateUseCase {
43+ return &CheckUpdateUseCase{
44+ checker: checker,
45+ applier: applier,
46+ buildInfo: buildInfo,
47+ }
48+}
49+
50+// Execute checks for available updates.
51+func (uc *CheckUpdateUseCase) Execute(ctx context.Context, _ CheckUpdateInput) (*CheckUpdateOutput, error) {
52+ log := logging.FromContext(ctx)
53+
54+ info, err := uc.checker.CheckForUpdate(ctx, uc.buildInfo.Version)
55+ if err != nil {
56+ log.Warn().Err(err).Msg("update check failed")
57+ return nil, err
58+ }
59+
60+ canAutoUpdate := false
61+ if info.IsNewer {
62+ canAutoUpdate = uc.applier.CanSelfUpdate(ctx)
63+ }
64+
65+ log.Debug().
66+ Str("current", uc.buildInfo.Version).
67+ Str("latest", info.LatestVersion).
68+ Bool("update_available", info.IsNewer).
69+ Bool("can_auto_update", canAutoUpdate).
70+ Msg("update check completed")
71+
72+ return &CheckUpdateOutput{
73+ UpdateAvailable: info.IsNewer,
74+ CanAutoUpdate: canAutoUpdate,
75+ CurrentVersion: info.CurrentVersion,
76+ LatestVersion: info.LatestVersion,
77+ ReleaseURL: info.ReleaseURL,
78+ DownloadURL: info.DownloadURL,
79+ }, nil
80+}
A internal/application/usecase/cleanup_sessions.go +98 -0
@@ -0,0 +1,98 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/repository"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+// CleanupSessionsUseCase handles automatic cleanup of old exited sessions.
12+type CleanupSessionsUseCase struct {
13+ sessionRepo repository.SessionRepository
14+}
15+
16+// NewCleanupSessionsUseCase creates a new CleanupSessionsUseCase.
17+func NewCleanupSessionsUseCase(sessionRepo repository.SessionRepository) *CleanupSessionsUseCase {
18+ return &CleanupSessionsUseCase{
19+ sessionRepo: sessionRepo,
20+ }
21+}
22+
23+// CleanupSessionsInput contains the cleanup configuration.
24+type CleanupSessionsInput struct {
25+ // MaxExitedSessions is the maximum number of exited sessions to keep.
26+ // Sessions beyond this count will be deleted (oldest first).
27+ MaxExitedSessions int
28+
29+ // MaxExitedSessionAgeDays is the maximum age in days for exited sessions.
30+ // Sessions older than this will be deleted.
31+ // A value of 0 disables age-based cleanup.
32+ MaxExitedSessionAgeDays int
33+}
34+
35+// CleanupSessionsOutput contains the cleanup results.
36+type CleanupSessionsOutput struct {
37+ // DeletedByAge is the number of sessions deleted due to age.
38+ DeletedByAge int64
39+
40+ // DeletedByCount is the number of sessions deleted due to count limit.
41+ DeletedByCount int64
42+
43+ // TotalDeleted is the total number of sessions deleted.
44+ TotalDeleted int64
45+}
46+
47+// Execute cleans up old exited sessions based on the provided configuration.
48+// It first deletes sessions older than MaxExitedSessionAgeDays,
49+// then enforces the MaxExitedSessions count limit.
50+func (uc *CleanupSessionsUseCase) Execute(ctx context.Context, input CleanupSessionsInput) (CleanupSessionsOutput, error) {
51+ log := logging.FromContext(ctx)
52+ output := CleanupSessionsOutput{}
53+
54+ // 1. Delete by age first (if enabled)
55+ if input.MaxExitedSessionAgeDays > 0 {
56+ cutoff := time.Now().AddDate(0, 0, -input.MaxExitedSessionAgeDays)
57+ deleted, err := uc.sessionRepo.DeleteExitedBefore(ctx, cutoff)
58+ if err != nil {
59+ log.Warn().Err(err).Msg("failed to delete sessions by age")
60+ } else {
61+ output.DeletedByAge = deleted
62+ if deleted > 0 {
63+ log.Info().
64+ Int64("deleted", deleted).
65+ Int("max_age_days", input.MaxExitedSessionAgeDays).
66+ Msg("cleaned up old sessions by age")
67+ }
68+ }
69+ }
70+
71+ // 2. Enforce count limit on remaining sessions
72+ if input.MaxExitedSessions >= 0 {
73+ deleted, err := uc.sessionRepo.DeleteOldestExited(ctx, input.MaxExitedSessions)
74+ if err != nil {
75+ log.Warn().Err(err).Msg("failed to delete sessions by count")
76+ } else {
77+ output.DeletedByCount = deleted
78+ if deleted > 0 {
79+ log.Info().
80+ Int64("deleted", deleted).
81+ Int("max_count", input.MaxExitedSessions).
82+ Msg("cleaned up old sessions by count")
83+ }
84+ }
85+ }
86+
87+ output.TotalDeleted = output.DeletedByAge + output.DeletedByCount
88+
89+ if output.TotalDeleted > 0 {
90+ log.Info().
91+ Int64("total_deleted", output.TotalDeleted).
92+ Int64("by_age", output.DeletedByAge).
93+ Int64("by_count", output.DeletedByCount).
94+ Msg("session cleanup completed")
95+ }
96+
97+ return output, nil
98+}
A internal/application/usecase/cleanup_sessions_test.go +224 -0
@@ -0,0 +1,224 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
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 TestCleanupSessionsUseCase_Execute_AgeAndCountCleanup(t *testing.T) {
16+ ctx := testContext()
17+
18+ sessionRepo := repomocks.NewMockSessionRepository(t)
19+
20+ // Expect age-based deletion
21+ sessionRepo.EXPECT().
22+ DeleteExitedBefore(ctx, mock.MatchedBy(func(cutoff time.Time) bool {
23+ // Should be approximately 7 days ago
24+ expected := time.Now().AddDate(0, 0, -7)
25+ diff := expected.Sub(cutoff)
26+ return diff > -time.Minute && diff < time.Minute
27+ })).
28+ Return(int64(3), nil)
29+
30+ // Expect count-based deletion
31+ sessionRepo.EXPECT().
32+ DeleteOldestExited(ctx, 50).
33+ Return(int64(2), nil)
34+
35+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
36+
37+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
38+ MaxExitedSessions: 50,
39+ MaxExitedSessionAgeDays: 7,
40+ })
41+
42+ require.NoError(t, err)
43+ assert.Equal(t, int64(3), output.DeletedByAge)
44+ assert.Equal(t, int64(2), output.DeletedByCount)
45+ assert.Equal(t, int64(5), output.TotalDeleted)
46+}
47+
48+func TestCleanupSessionsUseCase_Execute_AgeOnlyCleanup(t *testing.T) {
49+ ctx := testContext()
50+
51+ sessionRepo := repomocks.NewMockSessionRepository(t)
52+
53+ // Expect age-based deletion
54+ sessionRepo.EXPECT().
55+ DeleteExitedBefore(ctx, mock.Anything).
56+ Return(int64(5), nil)
57+
58+ // Count-based with -1 should be skipped (disabled)
59+ // MaxExitedSessions >= 0 triggers count cleanup, so use -1 to disable
60+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
61+
62+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
63+ MaxExitedSessions: -1, // Disabled
64+ MaxExitedSessionAgeDays: 30,
65+ })
66+
67+ require.NoError(t, err)
68+ assert.Equal(t, int64(5), output.DeletedByAge)
69+ assert.Equal(t, int64(0), output.DeletedByCount)
70+ assert.Equal(t, int64(5), output.TotalDeleted)
71+}
72+
73+func TestCleanupSessionsUseCase_Execute_CountOnlyCleanup(t *testing.T) {
74+ ctx := testContext()
75+
76+ sessionRepo := repomocks.NewMockSessionRepository(t)
77+
78+ // Count-based deletion only (age disabled with 0)
79+ sessionRepo.EXPECT().
80+ DeleteOldestExited(ctx, 10).
81+ Return(int64(4), nil)
82+
83+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
84+
85+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
86+ MaxExitedSessions: 10,
87+ MaxExitedSessionAgeDays: 0, // Disabled
88+ })
89+
90+ require.NoError(t, err)
91+ assert.Equal(t, int64(0), output.DeletedByAge)
92+ assert.Equal(t, int64(4), output.DeletedByCount)
93+ assert.Equal(t, int64(4), output.TotalDeleted)
94+}
95+
96+func TestCleanupSessionsUseCase_Execute_NoCleanupNeeded(t *testing.T) {
97+ ctx := testContext()
98+
99+ sessionRepo := repomocks.NewMockSessionRepository(t)
100+
101+ // Nothing to delete
102+ sessionRepo.EXPECT().
103+ DeleteExitedBefore(ctx, mock.Anything).
104+ Return(int64(0), nil)
105+
106+ sessionRepo.EXPECT().
107+ DeleteOldestExited(ctx, 50).
108+ Return(int64(0), nil)
109+
110+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
111+
112+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
113+ MaxExitedSessions: 50,
114+ MaxExitedSessionAgeDays: 7,
115+ })
116+
117+ require.NoError(t, err)
118+ assert.Equal(t, int64(0), output.DeletedByAge)
119+ assert.Equal(t, int64(0), output.DeletedByCount)
120+ assert.Equal(t, int64(0), output.TotalDeleted)
121+}
122+
123+func TestCleanupSessionsUseCase_Execute_AgeDeleteError_ContinuesToCount(t *testing.T) {
124+ ctx := testContext()
125+
126+ sessionRepo := repomocks.NewMockSessionRepository(t)
127+
128+ // Age-based deletion fails
129+ sessionRepo.EXPECT().
130+ DeleteExitedBefore(ctx, mock.Anything).
131+ Return(int64(0), errors.New("db error"))
132+
133+ // Should still attempt count-based deletion
134+ sessionRepo.EXPECT().
135+ DeleteOldestExited(ctx, 50).
136+ Return(int64(3), nil)
137+
138+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
139+
140+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
141+ MaxExitedSessions: 50,
142+ MaxExitedSessionAgeDays: 7,
143+ })
144+
145+ // Should not return error, just log warning
146+ require.NoError(t, err)
147+ assert.Equal(t, int64(0), output.DeletedByAge)
148+ assert.Equal(t, int64(3), output.DeletedByCount)
149+ assert.Equal(t, int64(3), output.TotalDeleted)
150+}
151+
152+func TestCleanupSessionsUseCase_Execute_CountDeleteError(t *testing.T) {
153+ ctx := testContext()
154+
155+ sessionRepo := repomocks.NewMockSessionRepository(t)
156+
157+ // Age-based deletion succeeds
158+ sessionRepo.EXPECT().
159+ DeleteExitedBefore(ctx, mock.Anything).
160+ Return(int64(2), nil)
161+
162+ // Count-based deletion fails
163+ sessionRepo.EXPECT().
164+ DeleteOldestExited(ctx, 50).
165+ Return(int64(0), errors.New("db error"))
166+
167+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
168+
169+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
170+ MaxExitedSessions: 50,
171+ MaxExitedSessionAgeDays: 7,
172+ })
173+
174+ // Should not return error, just log warning
175+ require.NoError(t, err)
176+ assert.Equal(t, int64(2), output.DeletedByAge)
177+ assert.Equal(t, int64(0), output.DeletedByCount)
178+ assert.Equal(t, int64(2), output.TotalDeleted)
179+}
180+
181+func TestCleanupSessionsUseCase_Execute_BothDisabled(t *testing.T) {
182+ ctx := testContext()
183+
184+ sessionRepo := repomocks.NewMockSessionRepository(t)
185+
186+ // No expectations - nothing should be called
187+ // Actually, count with 0 means "keep 0" which will trigger cleanup
188+ // So we need -1 for count and 0 for age to fully disable
189+
190+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
191+
192+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
193+ MaxExitedSessions: -1, // Disabled
194+ MaxExitedSessionAgeDays: 0, // Disabled
195+ })
196+
197+ require.NoError(t, err)
198+ assert.Equal(t, int64(0), output.DeletedByAge)
199+ assert.Equal(t, int64(0), output.DeletedByCount)
200+ assert.Equal(t, int64(0), output.TotalDeleted)
201+}
202+
203+func TestCleanupSessionsUseCase_Execute_ZeroMaxSessions(t *testing.T) {
204+ ctx := testContext()
205+
206+ sessionRepo := repomocks.NewMockSessionRepository(t)
207+
208+ // MaxExitedSessions = 0 means keep 0 sessions (delete all)
209+ sessionRepo.EXPECT().
210+ DeleteOldestExited(ctx, 0).
211+ Return(int64(10), nil)
212+
213+ uc := usecase.NewCleanupSessionsUseCase(sessionRepo)
214+
215+ output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{
216+ MaxExitedSessions: 0, // Keep 0 = delete all
217+ MaxExitedSessionAgeDays: 0, // Disabled
218+ })
219+
220+ require.NoError(t, err)
221+ assert.Equal(t, int64(0), output.DeletedByAge)
222+ assert.Equal(t, int64(10), output.DeletedByCount)
223+ assert.Equal(t, int64(10), output.TotalDeleted)
224+}
A internal/application/usecase/delete_session.go +79 -0
@@ -0,0 +1,79 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// DeleteSessionUseCase errors.
13+var (
14+ ErrCannotDeleteCurrentSession = errors.New("cannot delete current session")
15+ ErrCannotDeleteActiveSession = errors.New("cannot delete active session")
16+)
17+
18+// DeleteSessionUseCase handles session deletion with proper validation.
19+type DeleteSessionUseCase struct {
20+ stateRepo repository.SessionStateRepository
21+ sessionRepo repository.SessionRepository
22+ lockDir string
23+}
24+
25+// NewDeleteSessionUseCase creates a new DeleteSessionUseCase.
26+func NewDeleteSessionUseCase(
27+ stateRepo repository.SessionStateRepository,
28+ sessionRepo repository.SessionRepository,
29+ lockDir string,
30+) *DeleteSessionUseCase {
31+ return &DeleteSessionUseCase{
32+ stateRepo: stateRepo,
33+ sessionRepo: sessionRepo,
34+ lockDir: lockDir,
35+ }
36+}
37+
38+// DeleteSessionInput contains the parameters for session deletion.
39+type DeleteSessionInput struct {
40+ SessionID entity.SessionID
41+ CurrentSessionID entity.SessionID
42+}
43+
44+// Execute deletes a session and its state snapshot.
45+// It validates that the session is not current or active before deletion.
46+func (uc *DeleteSessionUseCase) Execute(ctx context.Context, input DeleteSessionInput) error {
47+ log := logging.FromContext(ctx)
48+
49+ // Validate not deleting current session
50+ if input.SessionID == input.CurrentSessionID && input.CurrentSessionID != "" {
51+ return ErrCannotDeleteCurrentSession
52+ }
53+
54+ // Check session exists
55+ session, err := uc.sessionRepo.FindByID(ctx, input.SessionID)
56+ if err != nil {
57+ return err
58+ }
59+ if session == nil {
60+ return ErrSessionNotFound
61+ }
62+
63+ // Check not active (EndedAt is nil OR has lock file)
64+ if session.IsActive() || isSessionLocked(uc.lockDir, input.SessionID) {
65+ return ErrCannotDeleteActiveSession
66+ }
67+
68+ log.Info().Str("session_id", string(input.SessionID)).Msg("deleting session")
69+
70+ // Delete snapshot (ignore "not found" - session may not have a snapshot)
71+ if uc.stateRepo != nil {
72+ if err := uc.stateRepo.DeleteSnapshot(ctx, input.SessionID); err != nil {
73+ log.Debug().Err(err).Msg("failed to delete session snapshot (may not exist)")
74+ }
75+ }
76+
77+ // Delete session record
78+ return uc.sessionRepo.Delete(ctx, input.SessionID)
79+}
A internal/application/usecase/delete_session_test.go +255 -0
@@ -0,0 +1,255 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestDeleteSessionUseCase_Execute_Success(t *testing.T) {
18+ ctx := testContext()
19+
20+ stateRepo := repomocks.NewMockSessionStateRepository(t)
21+ sessionRepo := repomocks.NewMockSessionRepository(t)
22+
23+ sessionID := entity.SessionID("20251224_120000_delete")
24+
25+ endedAt := time.Now().Add(-time.Hour)
26+ session := &entity.Session{
27+ ID: sessionID,
28+ Type: entity.SessionTypeBrowser,
29+ StartedAt: time.Now().Add(-2 * time.Hour),
30+ EndedAt: &endedAt,
31+ }
32+
33+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
34+ stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
35+ sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
36+
37+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
38+
39+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
40+ SessionID: sessionID,
41+ CurrentSessionID: "other-session",
42+ })
43+ require.NoError(t, err)
44+}
45+
46+func TestDeleteSessionUseCase_Execute_CannotDeleteCurrentSession(t *testing.T) {
47+ ctx := testContext()
48+
49+ stateRepo := repomocks.NewMockSessionStateRepository(t)
50+ sessionRepo := repomocks.NewMockSessionRepository(t)
51+
52+ sessionID := entity.SessionID("20251224_120000_current")
53+
54+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
55+
56+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
57+ SessionID: sessionID,
58+ CurrentSessionID: sessionID, // Same as current
59+ })
60+ require.Error(t, err)
61+ assert.ErrorIs(t, err, usecase.ErrCannotDeleteCurrentSession)
62+}
63+
64+func TestDeleteSessionUseCase_Execute_SessionNotFound(t *testing.T) {
65+ ctx := testContext()
66+
67+ stateRepo := repomocks.NewMockSessionStateRepository(t)
68+ sessionRepo := repomocks.NewMockSessionRepository(t)
69+
70+ sessionID := entity.SessionID("20251224_120000_notfound")
71+
72+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, nil)
73+
74+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
75+
76+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
77+ SessionID: sessionID,
78+ CurrentSessionID: "other-session",
79+ })
80+ require.Error(t, err)
81+ assert.ErrorIs(t, err, usecase.ErrSessionNotFound)
82+}
83+
84+func TestDeleteSessionUseCase_Execute_CannotDeleteActiveSession(t *testing.T) {
85+ ctx := testContext()
86+
87+ stateRepo := repomocks.NewMockSessionStateRepository(t)
88+ sessionRepo := repomocks.NewMockSessionRepository(t)
89+
90+ sessionID := entity.SessionID("20251224_120000_active")
91+
92+ // Session with nil EndedAt (still active)
93+ session := &entity.Session{
94+ ID: sessionID,
95+ Type: entity.SessionTypeBrowser,
96+ StartedAt: time.Now().Add(-time.Hour),
97+ EndedAt: nil, // Active
98+ }
99+
100+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
101+
102+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
103+
104+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
105+ SessionID: sessionID,
106+ CurrentSessionID: "other-session",
107+ })
108+ require.Error(t, err)
109+ assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession)
110+}
111+
112+func TestDeleteSessionUseCase_Execute_CannotDeleteLockedSession(t *testing.T) {
113+ ctx := testContext()
114+
115+ stateRepo := repomocks.NewMockSessionStateRepository(t)
116+ sessionRepo := repomocks.NewMockSessionRepository(t)
117+
118+ // Create a temporary lock directory and lock file
119+ lockDir := t.TempDir()
120+ sessionID := entity.SessionID("20251224_120000_locked")
121+ lockPath := filepath.Join(lockDir, "session_"+string(sessionID)+".lock")
122+ f, err := os.Create(lockPath)
123+ require.NoError(t, err)
124+ f.Close()
125+
126+ endedAt := time.Now().Add(-time.Hour)
127+ session := &entity.Session{
128+ ID: sessionID,
129+ Type: entity.SessionTypeBrowser,
130+ StartedAt: time.Now().Add(-2 * time.Hour),
131+ EndedAt: &endedAt, // Ended, but has lock file
132+ }
133+
134+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
135+
136+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, lockDir)
137+
138+ err = uc.Execute(ctx, usecase.DeleteSessionInput{
139+ SessionID: sessionID,
140+ CurrentSessionID: "other-session",
141+ })
142+ require.Error(t, err)
143+ assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession)
144+}
145+
146+func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) {
147+ ctx := testContext()
148+
149+ stateRepo := repomocks.NewMockSessionStateRepository(t)
150+ sessionRepo := repomocks.NewMockSessionRepository(t)
151+
152+ sessionID := entity.SessionID("20251224_120000_dberror")
153+
154+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, errors.New("db error"))
155+
156+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
157+
158+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
159+ SessionID: sessionID,
160+ CurrentSessionID: "other-session",
161+ })
162+ require.Error(t, err)
163+ assert.Contains(t, err.Error(), "db error")
164+}
165+
166+func TestDeleteSessionUseCase_Execute_DeleteSnapshotErrorIgnored(t *testing.T) {
167+ ctx := testContext()
168+
169+ stateRepo := repomocks.NewMockSessionStateRepository(t)
170+ sessionRepo := repomocks.NewMockSessionRepository(t)
171+
172+ sessionID := entity.SessionID("20251224_120000_nosnapshot")
173+
174+ endedAt := time.Now().Add(-time.Hour)
175+ session := &entity.Session{
176+ ID: sessionID,
177+ Type: entity.SessionTypeBrowser,
178+ StartedAt: time.Now().Add(-2 * time.Hour),
179+ EndedAt: &endedAt,
180+ }
181+
182+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
183+ // Snapshot deletion fails (e.g., no snapshot exists) - should be ignored
184+ stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(errors.New("no rows"))
185+ sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
186+
187+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
188+
189+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
190+ SessionID: sessionID,
191+ CurrentSessionID: "other-session",
192+ })
193+ // Should succeed even though DeleteSnapshot failed
194+ require.NoError(t, err)
195+}
196+
197+func TestDeleteSessionUseCase_Execute_DeleteSessionError(t *testing.T) {
198+ ctx := testContext()
199+
200+ stateRepo := repomocks.NewMockSessionStateRepository(t)
201+ sessionRepo := repomocks.NewMockSessionRepository(t)
202+
203+ sessionID := entity.SessionID("20251224_120000_deleteerror")
204+
205+ endedAt := time.Now().Add(-time.Hour)
206+ session := &entity.Session{
207+ ID: sessionID,
208+ Type: entity.SessionTypeBrowser,
209+ StartedAt: time.Now().Add(-2 * time.Hour),
210+ EndedAt: &endedAt,
211+ }
212+
213+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
214+ stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
215+ sessionRepo.EXPECT().Delete(ctx, sessionID).Return(errors.New("delete failed"))
216+
217+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
218+
219+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
220+ SessionID: sessionID,
221+ CurrentSessionID: "other-session",
222+ })
223+ require.Error(t, err)
224+ assert.Contains(t, err.Error(), "delete failed")
225+}
226+
227+func TestDeleteSessionUseCase_Execute_EmptyCurrentSessionAllowed(t *testing.T) {
228+ ctx := testContext()
229+
230+ stateRepo := repomocks.NewMockSessionStateRepository(t)
231+ sessionRepo := repomocks.NewMockSessionRepository(t)
232+
233+ sessionID := entity.SessionID("20251224_120000_nocurrent")
234+
235+ endedAt := time.Now().Add(-time.Hour)
236+ session := &entity.Session{
237+ ID: sessionID,
238+ Type: entity.SessionTypeBrowser,
239+ StartedAt: time.Now().Add(-2 * time.Hour),
240+ EndedAt: &endedAt,
241+ }
242+
243+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
244+ stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
245+ sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
246+
247+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
248+
249+ // CurrentSessionID is empty (e.g., CLI context)
250+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
251+ SessionID: sessionID,
252+ CurrentSessionID: "",
253+ })
254+ require.NoError(t, err)
255+}
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+}
A internal/application/usecase/get_last_restorable_session.go +124 -0
@@ -0,0 +1,124 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/bnema/dumber/internal/domain/repository"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+const (
12+ // maxSessionsToCheck is the number of recent sessions to check for restoration.
13+ maxSessionsToCheck = 10
14+)
15+
16+// GetLastRestorableSessionUseCase finds the most recent session that can be auto-restored.
17+// A session is restorable if:
18+// - It is not the current session
19+// - It is not actively running (has ended or crashed without lock file)
20+// - It has a valid snapshot with at least one tab
21+type GetLastRestorableSessionUseCase struct {
22+ sessionRepo repository.SessionRepository
23+ stateRepo repository.SessionStateRepository
24+ lockDir string
25+}
26+
27+// NewGetLastRestorableSessionUseCase creates a new GetLastRestorableSessionUseCase.
28+func NewGetLastRestorableSessionUseCase(
29+ sessionRepo repository.SessionRepository,
30+ stateRepo repository.SessionStateRepository,
31+ lockDir string,
32+) *GetLastRestorableSessionUseCase {
33+ return &GetLastRestorableSessionUseCase{
34+ sessionRepo: sessionRepo,
35+ stateRepo: stateRepo,
36+ lockDir: lockDir,
37+ }
38+}
39+
40+// GetLastRestorableSessionInput contains the parameters for finding a restorable session.
41+type GetLastRestorableSessionInput struct {
42+ // ExcludeSessionID is the current session ID to exclude from results.
43+ ExcludeSessionID entity.SessionID
44+}
45+
46+// GetLastRestorableSessionOutput contains the found restorable session, if any.
47+type GetLastRestorableSessionOutput struct {
48+ // SessionID is the ID of the restorable session, or empty if none found.
49+ SessionID entity.SessionID
50+ // State is the session state to restore, or nil if none found.
51+ State *entity.SessionState
52+}
53+
54+// Execute finds the most recent restorable session.
55+// Returns an empty output (not an error) if no restorable session is found.
56+func (uc *GetLastRestorableSessionUseCase) Execute(
57+ ctx context.Context,
58+ input GetLastRestorableSessionInput,
59+) (*GetLastRestorableSessionOutput, error) {
60+ log := logging.FromContext(ctx)
61+
62+ sessions, err := uc.sessionRepo.GetRecent(ctx, maxSessionsToCheck)
63+ if err != nil {
64+ return nil, err
65+ }
66+
67+ for _, session := range sessions {
68+ // Only consider browser sessions
69+ if session.Type != entity.SessionTypeBrowser {
70+ continue
71+ }
72+
73+ // Skip current session
74+ if session.ID == input.ExcludeSessionID {
75+ continue
76+ }
77+
78+ // Check if session is actively running
79+ // A session is "active" if ended_at IS NULL AND it has a lock file
80+ if session.IsActive() && isSessionLocked(uc.lockDir, session.ID) {
81+ log.Debug().
82+ Str("session_id", string(session.ID)).
83+ Msg("auto-restore: skipping active session")
84+ continue
85+ }
86+
87+ // Try to get snapshot
88+ state, err := uc.stateRepo.GetSnapshot(ctx, session.ID)
89+ if err != nil {
90+ log.Debug().
91+ Err(err).
92+ Str("session_id", string(session.ID)).
93+ Msg("auto-restore: failed to get snapshot")
94+ continue
95+ }
96+ if state == nil {
97+ continue
98+ }
99+
100+ // Skip sessions with no tabs
101+ if len(state.Tabs) == 0 {
102+ log.Debug().
103+ Str("session_id", string(session.ID)).
104+ Msg("auto-restore: skipping session with no tabs")
105+ continue
106+ }
107+
108+ // Found a valid restorable session
109+ log.Info().
110+ Str("session_id", string(session.ID)).
111+ Int("tabs", len(state.Tabs)).
112+ Int("panes", state.CountPanes()).
113+ Msg("auto-restore: found restorable session")
114+
115+ return &GetLastRestorableSessionOutput{
116+ SessionID: session.ID,
117+ State: state,
118+ }, nil
119+ }
120+
121+ // No restorable session found
122+ log.Debug().Msg("auto-restore: no restorable session found")
123+ return &GetLastRestorableSessionOutput{}, nil
124+}
A internal/application/usecase/get_last_restorable_session_test.go +408 -0
@@ -0,0 +1,408 @@
1+package usecase_test
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestGetLastRestorableSessionUseCase_NoSessions(t *testing.T) {
17+ ctx := testContext()
18+
19+ sessionRepo := repomocks.NewMockSessionRepository(t)
20+ stateRepo := repomocks.NewMockSessionStateRepository(t)
21+
22+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return([]*entity.Session{}, nil)
23+
24+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
25+
26+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
27+ ExcludeSessionID: "current",
28+ })
29+
30+ require.NoError(t, err)
31+ require.NotNil(t, output)
32+ assert.Empty(t, output.SessionID)
33+ assert.Nil(t, output.State)
34+}
35+
36+func TestGetLastRestorableSessionUseCase_OnlyCurrentSession(t *testing.T) {
37+ ctx := testContext()
38+
39+ sessionRepo := repomocks.NewMockSessionRepository(t)
40+ stateRepo := repomocks.NewMockSessionStateRepository(t)
41+
42+ currentID := entity.SessionID("20251225_120000_curr")
43+ endedAt := time.Now()
44+
45+ sessions := []*entity.Session{
46+ {
47+ ID: currentID,
48+ Type: entity.SessionTypeBrowser,
49+ StartedAt: time.Now().Add(-1 * time.Hour),
50+ EndedAt: &endedAt,
51+ },
52+ }
53+
54+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
55+
56+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
57+
58+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
59+ ExcludeSessionID: currentID,
60+ })
61+
62+ require.NoError(t, err)
63+ require.NotNil(t, output)
64+ assert.Empty(t, output.SessionID)
65+}
66+
67+func TestGetLastRestorableSessionUseCase_SkipsCLISessions(t *testing.T) {
68+ ctx := testContext()
69+
70+ sessionRepo := repomocks.NewMockSessionRepository(t)
71+ stateRepo := repomocks.NewMockSessionStateRepository(t)
72+
73+ endedAt := time.Now()
74+ sessions := []*entity.Session{
75+ {
76+ ID: "20251225_120000_cli1",
77+ Type: entity.SessionTypeCLI, // Not a browser session
78+ StartedAt: time.Now().Add(-1 * time.Hour),
79+ EndedAt: &endedAt,
80+ },
81+ }
82+
83+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
84+
85+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
86+
87+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
88+ ExcludeSessionID: "current",
89+ })
90+
91+ require.NoError(t, err)
92+ assert.Empty(t, output.SessionID)
93+}
94+
95+func TestGetLastRestorableSessionUseCase_SkipsActiveWithLock(t *testing.T) {
96+ ctx := testContext()
97+
98+ // Create temp dir for lock files
99+ lockDir := t.TempDir()
100+
101+ sessionRepo := repomocks.NewMockSessionRepository(t)
102+ stateRepo := repomocks.NewMockSessionStateRepository(t)
103+
104+ activeID := entity.SessionID("20251225_120000_actv")
105+
106+ // Create lock file to simulate running session
107+ lockPath := filepath.Join(lockDir, "session_"+string(activeID)+".lock")
108+ f, err := os.Create(lockPath)
109+ require.NoError(t, err)
110+ f.Close()
111+
112+ sessions := []*entity.Session{
113+ {
114+ ID: activeID,
115+ Type: entity.SessionTypeBrowser,
116+ StartedAt: time.Now().Add(-1 * time.Hour),
117+ EndedAt: nil, // Active (no ended_at)
118+ },
119+ }
120+
121+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
122+
123+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir)
124+
125+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
126+ ExcludeSessionID: "current",
127+ })
128+
129+ require.NoError(t, err)
130+ assert.Empty(t, output.SessionID)
131+}
132+
133+func TestGetLastRestorableSessionUseCase_SkipsNoSnapshot(t *testing.T) {
134+ ctx := testContext()
135+
136+ sessionRepo := repomocks.NewMockSessionRepository(t)
137+ stateRepo := repomocks.NewMockSessionStateRepository(t)
138+
139+ endedAt := time.Now()
140+ sessionID := entity.SessionID("20251225_120000_nosn")
141+
142+ sessions := []*entity.Session{
143+ {
144+ ID: sessionID,
145+ Type: entity.SessionTypeBrowser,
146+ StartedAt: time.Now().Add(-1 * time.Hour),
147+ EndedAt: &endedAt,
148+ },
149+ }
150+
151+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
152+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil) // No snapshot
153+
154+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
155+
156+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
157+ ExcludeSessionID: "current",
158+ })
159+
160+ require.NoError(t, err)
161+ assert.Empty(t, output.SessionID)
162+}
163+
164+func TestGetLastRestorableSessionUseCase_SkipsEmptyTabs(t *testing.T) {
165+ ctx := testContext()
166+
167+ sessionRepo := repomocks.NewMockSessionRepository(t)
168+ stateRepo := repomocks.NewMockSessionStateRepository(t)
169+
170+ endedAt := time.Now()
171+ sessionID := entity.SessionID("20251225_120000_emty")
172+
173+ sessions := []*entity.Session{
174+ {
175+ ID: sessionID,
176+ Type: entity.SessionTypeBrowser,
177+ StartedAt: time.Now().Add(-1 * time.Hour),
178+ EndedAt: &endedAt,
179+ },
180+ }
181+
182+ emptyState := &entity.SessionState{
183+ Version: entity.SessionStateVersion,
184+ SessionID: sessionID,
185+ Tabs: []entity.TabSnapshot{}, // No tabs
186+ SavedAt: time.Now(),
187+ }
188+
189+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
190+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(emptyState, nil)
191+
192+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
193+
194+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
195+ ExcludeSessionID: "current",
196+ })
197+
198+ require.NoError(t, err)
199+ assert.Empty(t, output.SessionID)
200+}
201+
202+func TestGetLastRestorableSessionUseCase_ReturnsEndedSession(t *testing.T) {
203+ ctx := testContext()
204+
205+ sessionRepo := repomocks.NewMockSessionRepository(t)
206+ stateRepo := repomocks.NewMockSessionStateRepository(t)
207+
208+ endedAt := time.Now()
209+ sessionID := entity.SessionID("20251225_120000_good")
210+
211+ sessions := []*entity.Session{
212+ {
213+ ID: sessionID,
214+ Type: entity.SessionTypeBrowser,
215+ StartedAt: time.Now().Add(-1 * time.Hour),
216+ EndedAt: &endedAt, // Gracefully ended
217+ },
218+ }
219+
220+ validState := &entity.SessionState{
221+ Version: entity.SessionStateVersion,
222+ SessionID: sessionID,
223+ Tabs: []entity.TabSnapshot{
224+ {
225+ ID: "tab-1",
226+ Name: "Test Tab",
227+ Workspace: entity.WorkspaceSnapshot{
228+ Root: &entity.PaneNodeSnapshot{
229+ Pane: &entity.PaneSnapshot{
230+ ID: "pane-1",
231+ URI: "https://example.com",
232+ Title: "Example",
233+ },
234+ },
235+ },
236+ },
237+ },
238+ SavedAt: time.Now(),
239+ }
240+
241+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
242+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)
243+
244+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
245+
246+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
247+ ExcludeSessionID: "current",
248+ })
249+
250+ require.NoError(t, err)
251+ require.NotNil(t, output)
252+ assert.Equal(t, sessionID, output.SessionID)
253+ assert.Equal(t, validState, output.State)
254+}
255+
256+func TestGetLastRestorableSessionUseCase_ReturnsCrashedSession(t *testing.T) {
257+ ctx := testContext()
258+
259+ // Empty lock dir (no lock files)
260+ lockDir := t.TempDir()
261+
262+ sessionRepo := repomocks.NewMockSessionRepository(t)
263+ stateRepo := repomocks.NewMockSessionStateRepository(t)
264+
265+ sessionID := entity.SessionID("20251225_120000_crsh")
266+
267+ // Session with no ended_at (crashed) but no lock file
268+ sessions := []*entity.Session{
269+ {
270+ ID: sessionID,
271+ Type: entity.SessionTypeBrowser,
272+ StartedAt: time.Now().Add(-1 * time.Hour),
273+ EndedAt: nil, // Not ended - simulates crash
274+ },
275+ }
276+
277+ validState := &entity.SessionState{
278+ Version: entity.SessionStateVersion,
279+ SessionID: sessionID,
280+ Tabs: []entity.TabSnapshot{
281+ {
282+ ID: "tab-1",
283+ Name: "Crashed Tab",
284+ Workspace: entity.WorkspaceSnapshot{
285+ Root: &entity.PaneNodeSnapshot{
286+ Pane: &entity.PaneSnapshot{
287+ ID: "pane-1",
288+ URI: "https://example.com",
289+ Title: "Example",
290+ },
291+ },
292+ },
293+ },
294+ },
295+ SavedAt: time.Now(),
296+ }
297+
298+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
299+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)
300+
301+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir)
302+
303+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
304+ ExcludeSessionID: "current",
305+ })
306+
307+ require.NoError(t, err)
308+ require.NotNil(t, output)
309+ assert.Equal(t, sessionID, output.SessionID)
310+ assert.Equal(t, validState, output.State)
311+}
312+
313+func TestGetLastRestorableSessionUseCase_ReturnsMostRecent(t *testing.T) {
314+ ctx := testContext()
315+
316+ sessionRepo := repomocks.NewMockSessionRepository(t)
317+ stateRepo := repomocks.NewMockSessionStateRepository(t)
318+
319+ endedAt := time.Now()
320+ recentID := entity.SessionID("20251225_140000_new1")
321+ olderID := entity.SessionID("20251225_120000_old1")
322+
323+ // Sessions ordered by started_at DESC (most recent first)
324+ sessions := []*entity.Session{
325+ {
326+ ID: recentID,
327+ Type: entity.SessionTypeBrowser,
328+ StartedAt: time.Now().Add(-1 * time.Hour),
329+ EndedAt: &endedAt,
330+ },
331+ {
332+ ID: olderID,
333+ Type: entity.SessionTypeBrowser,
334+ StartedAt: time.Now().Add(-2 * time.Hour),
335+ EndedAt: &endedAt,
336+ },
337+ }
338+
339+ recentState := &entity.SessionState{
340+ Version: entity.SessionStateVersion,
341+ SessionID: recentID,
342+ Tabs: []entity.TabSnapshot{
343+ {ID: "tab-1", Name: "Recent Tab"},
344+ },
345+ SavedAt: time.Now(),
346+ }
347+
348+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
349+ stateRepo.EXPECT().GetSnapshot(ctx, recentID).Return(recentState, nil)
350+ // Note: GetSnapshot for olderID should NOT be called since we return first match
351+
352+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
353+
354+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
355+ ExcludeSessionID: "current",
356+ })
357+
358+ require.NoError(t, err)
359+ assert.Equal(t, recentID, output.SessionID)
360+}
361+
362+func TestGetLastRestorableSessionUseCase_SkipsFirstIfNoSnapshot(t *testing.T) {
363+ ctx := testContext()
364+
365+ sessionRepo := repomocks.NewMockSessionRepository(t)
366+ stateRepo := repomocks.NewMockSessionStateRepository(t)
367+
368+ endedAt := time.Now()
369+ noSnapID := entity.SessionID("20251225_140000_nosn")
370+ goodID := entity.SessionID("20251225_120000_good")
371+
372+ sessions := []*entity.Session{
373+ {
374+ ID: noSnapID,
375+ Type: entity.SessionTypeBrowser,
376+ StartedAt: time.Now().Add(-1 * time.Hour),
377+ EndedAt: &endedAt,
378+ },
379+ {
380+ ID: goodID,
381+ Type: entity.SessionTypeBrowser,
382+ StartedAt: time.Now().Add(-2 * time.Hour),
383+ EndedAt: &endedAt,
384+ },
385+ }
386+
387+ goodState := &entity.SessionState{
388+ Version: entity.SessionStateVersion,
389+ SessionID: goodID,
390+ Tabs: []entity.TabSnapshot{
391+ {ID: "tab-1", Name: "Good Tab"},
392+ },
393+ SavedAt: time.Now(),
394+ }
395+
396+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
397+ stateRepo.EXPECT().GetSnapshot(ctx, noSnapID).Return(nil, nil) // No snapshot
398+ stateRepo.EXPECT().GetSnapshot(ctx, goodID).Return(goodState, nil)
399+
400+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
401+
402+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
403+ ExcludeSessionID: "current",
404+ })
405+
406+ require.NoError(t, err)
407+ assert.Equal(t, goodID, output.SessionID)
408+}
A internal/application/usecase/list_sessions.go +256 -0
@@ -0,0 +1,256 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// ListSessionsUseCase handles listing sessions with their state information.
13+type ListSessionsUseCase struct {
14+ sessionRepo repository.SessionRepository
15+ stateRepo repository.SessionStateRepository
16+ lockDir string // Directory where lock files are stored
17+}
18+
19+// NewListSessionsUseCase creates a new ListSessionsUseCase.
20+func NewListSessionsUseCase(
21+ sessionRepo repository.SessionRepository,
22+ stateRepo repository.SessionStateRepository,
23+ lockDir string,
24+) *ListSessionsUseCase {
25+ return &ListSessionsUseCase{
26+ sessionRepo: sessionRepo,
27+ stateRepo: stateRepo,
28+ lockDir: lockDir,
29+ }
30+}
31+
32+// ListSessionsOutput contains the list of sessions with their info.
33+type ListSessionsOutput struct {
34+ Sessions []entity.SessionInfo
35+}
36+
37+// Execute returns a list of sessions with their state information.
38+// Sessions are sorted with active first, then by date (most recent first).
39+func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) {
40+ log := logging.FromContext(ctx)
41+
42+ if limit <= 0 {
43+ limit = 50
44+ }
45+
46+ sessions, err := uc.sessionRepo.GetRecent(ctx, limit)
47+ if err != nil {
48+ return nil, err
49+ }
50+
51+ // Get all snapshots
52+ snapshots, err := uc.stateRepo.GetAllSnapshots(ctx)
53+ if err != nil {
54+ log.Warn().Err(err).Msg("failed to get session snapshots")
55+ // Continue without snapshots
56+ snapshots = nil
57+ }
58+
59+ // Build snapshot lookup map
60+ snapshotMap := make(map[entity.SessionID]*entity.SessionState)
61+ for _, s := range snapshots {
62+ snapshotMap[s.SessionID] = s
63+ }
64+
65+ // Build session info list
66+ result := make([]entity.SessionInfo, 0, len(sessions))
67+ for _, session := range sessions {
68+ // Only include browser sessions
69+ if session.Type != entity.SessionTypeBrowser {
70+ continue
71+ }
72+
73+ info := entity.SessionInfo{
74+ Session: session,
75+ IsCurrent: session.ID == currentSessionID,
76+ IsActive: session.IsActive() || uc.hasActiveLock(session.ID),
77+ }
78+
79+ if state, ok := snapshotMap[session.ID]; ok {
80+ info.State = state
81+ info.TabCount = len(state.Tabs)
82+ info.PaneCount = state.CountPanes()
83+ info.UpdatedAt = state.SavedAt
84+ } else {
85+ info.UpdatedAt = session.StartedAt
86+ }
87+
88+ result = append(result, info)
89+ }
90+
91+ // Sort: current first, then active, then by date
92+ sortSessionInfos(result, currentSessionID)
93+
94+ return &ListSessionsOutput{Sessions: result}, nil
95+}
96+
97+// hasActiveLock checks if a session has an active lock file.
98+func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool {
99+ return isSessionLocked(uc.lockDir, sessionID)
100+}
101+
102+// sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.
103+func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) {
104+ // Simple bubble sort for small lists
105+ n := len(infos)
106+ for i := 0; i < n-1; i++ {
107+ for j := 0; j < n-i-1; j++ {
108+ if shouldSwap(infos[j], infos[j+1], currentID) {
109+ infos[j], infos[j+1] = infos[j+1], infos[j]
110+ }
111+ }
112+ }
113+}
114+
115+func shouldSwap(a, b entity.SessionInfo, currentID entity.SessionID) bool {
116+ // Current session always first
117+ if a.Session.ID == currentID {
118+ return false
119+ }
120+ if b.Session.ID == currentID {
121+ return true
122+ }
123+
124+ // Active sessions before inactive
125+ if a.IsActive && !b.IsActive {
126+ return false
127+ }
128+ if !a.IsActive && b.IsActive {
129+ return true
130+ }
131+
132+ // Most recent first
133+ return a.UpdatedAt.Before(b.UpdatedAt)
134+}
135+
136+// GetSessionInfo returns info for a specific session.
137+func (uc *ListSessionsUseCase) GetSessionInfo(
138+ ctx context.Context,
139+ sessionID, currentSessionID entity.SessionID,
140+) (*entity.SessionInfo, error) {
141+ session, err := uc.sessionRepo.FindByID(ctx, sessionID)
142+ if err != nil {
143+ return nil, err
144+ }
145+ if session == nil {
146+ return nil, nil
147+ }
148+
149+ info := &entity.SessionInfo{
150+ Session: session,
151+ IsCurrent: session.ID == currentSessionID,
152+ IsActive: session.IsActive() || uc.hasActiveLock(session.ID),
153+ UpdatedAt: session.StartedAt,
154+ }
155+
156+ state, err := uc.stateRepo.GetSnapshot(ctx, sessionID)
157+ if err != nil {
158+ logging.FromContext(ctx).Warn().Err(err).Str("session_id", string(sessionID)).Msg("failed to get session state")
159+ }
160+ if state != nil {
161+ info.State = state
162+ info.TabCount = len(state.Tabs)
163+ info.PaneCount = state.CountPanes()
164+ info.UpdatedAt = state.SavedAt
165+ }
166+
167+ return info, nil
168+}
169+
170+const (
171+ hoursPerDay = 24
172+ daysPerWeek = 7
173+ maxIDDisplay = 20
174+)
175+
176+// GetRelativeTime returns a human-readable relative time string.
177+func GetRelativeTime(t time.Time) string {
178+ now := time.Now()
179+ diff := now.Sub(t)
180+
181+ switch {
182+ case diff < time.Minute:
183+ return "just now"
184+ case diff < time.Hour:
185+ mins := int(diff.Minutes())
186+ if mins == 1 {
187+ return "1m ago"
188+ }
189+ return formatDuration(mins, "m")
190+ case diff < hoursPerDay*time.Hour:
191+ hours := int(diff.Hours())
192+ if hours == 1 {
193+ return "1h ago"
194+ }
195+ return formatDuration(hours, "h")
196+ case diff < daysPerWeek*hoursPerDay*time.Hour:
197+ days := int(diff.Hours() / hoursPerDay)
198+ if days == 1 {
199+ return "1d ago"
200+ }
201+ return formatDuration(days, "d")
202+ default:
203+ weeks := int(diff.Hours() / hoursPerDay / daysPerWeek)
204+ if weeks == 1 {
205+ return "1w ago"
206+ }
207+ return formatDuration(weeks, "w")
208+ }
209+}
210+
211+func formatDuration(n int, unit string) string {
212+ return string(rune('0'+n/10)) + string(rune('0'+n%10)) + unit + " ago"
213+}
214+
215+// PurgeableSessionsOutput contains inactive sessions that can be purged.
216+type PurgeableSessionsOutput struct {
217+ Sessions []entity.SessionPurgeItem
218+ TotalSize int64
219+}
220+
221+// GetPurgeableSessions returns all inactive sessions that can be safely deleted.
222+// These are sessions that have ended and have no active lock file.
223+func (uc *ListSessionsUseCase) GetPurgeableSessions(ctx context.Context) (*PurgeableSessionsOutput, error) {
224+ log := logging.FromContext(ctx)
225+
226+ // Get all sessions (no current session in CLI context)
227+ output, err := uc.Execute(ctx, "", maxPurgeableSessions)
228+ if err != nil {
229+ return nil, err
230+ }
231+
232+ // Filter to only inactive sessions
233+ var purgeable []entity.SessionPurgeItem
234+ for _, info := range output.Sessions {
235+ if !info.IsActive {
236+ purgeable = append(purgeable, entity.SessionPurgeItem{
237+ Info: info,
238+ Selected: true, // Default to selected
239+ })
240+ }
241+ }
242+
243+ // Get total size of all snapshots
244+ totalSize, err := uc.stateRepo.GetTotalSnapshotsSize(ctx)
245+ if err != nil {
246+ log.Warn().Err(err).Msg("failed to get total snapshots size")
247+ totalSize = 0
248+ }
249+
250+ return &PurgeableSessionsOutput{
251+ Sessions: purgeable,
252+ TotalSize: totalSize,
253+ }, nil
254+}
255+
256+const maxPurgeableSessions = 1000
A internal/application/usecase/list_sessions_test.go +178 -0
@@ -0,0 +1,178 @@
1+package usecase_test
2+
3+import (
4+ "testing"
5+ "time"
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/require"
12+)
13+
14+func TestListSessionsUseCase_Execute_ReturnsSessionsWithState(t *testing.T) {
15+ ctx := testContext()
16+
17+ sessionRepo := repomocks.NewMockSessionRepository(t)
18+ stateRepo := repomocks.NewMockSessionStateRepository(t)
19+
20+ endedAt := time.Now().Add(-time.Hour)
21+ sessions := []*entity.Session{
22+ {
23+ ID: "20251224_120000_abc1",
24+ Type: entity.SessionTypeBrowser,
25+ StartedAt: time.Now().Add(-2 * time.Hour),
26+ EndedAt: &endedAt,
27+ },
28+ {
29+ ID: "20251224_130000_def2",
30+ Type: entity.SessionTypeBrowser,
31+ StartedAt: time.Now().Add(-time.Hour),
32+ EndedAt: nil, // Active
33+ },
34+ }
35+
36+ states := []*entity.SessionState{
37+ {
38+ SessionID: "20251224_120000_abc1",
39+ SavedAt: time.Now().Add(-time.Hour),
40+ Tabs: []entity.TabSnapshot{
41+ {ID: "tab1"},
42+ {ID: "tab2"},
43+ },
44+ },
45+ }
46+
47+ sessionRepo.EXPECT().GetRecent(ctx, 50).Return(sessions, nil)
48+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(states, nil)
49+
50+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
51+
52+ output, err := uc.Execute(ctx, "", 50)
53+ require.NoError(t, err)
54+ require.NotNil(t, output)
55+ require.Len(t, output.Sessions, 2)
56+
57+ // Active session should be first (or current, but we have no current)
58+ // The sorting puts active sessions before inactive
59+ activeSession := output.Sessions[0]
60+ assert.True(t, activeSession.IsActive)
61+
62+ // Check session with state has tab count
63+ var sessionWithState *entity.SessionInfo
64+ for i := range output.Sessions {
65+ if output.Sessions[i].Session.ID == "20251224_120000_abc1" {
66+ sessionWithState = &output.Sessions[i]
67+ break
68+ }
69+ }
70+ require.NotNil(t, sessionWithState)
71+ assert.Equal(t, 2, sessionWithState.TabCount)
72+}
73+
74+func TestListSessionsUseCase_GetPurgeableSessions_ReturnsOnlyInactive(t *testing.T) {
75+ ctx := testContext()
76+
77+ sessionRepo := repomocks.NewMockSessionRepository(t)
78+ stateRepo := repomocks.NewMockSessionStateRepository(t)
79+
80+ endedAt := time.Now().Add(-time.Hour)
81+ sessions := []*entity.Session{
82+ {
83+ ID: "20251224_120000_inactive1",
84+ Type: entity.SessionTypeBrowser,
85+ StartedAt: time.Now().Add(-3 * time.Hour),
86+ EndedAt: &endedAt, // Ended - purgeable
87+ },
88+ {
89+ ID: "20251224_130000_active1",
90+ Type: entity.SessionTypeBrowser,
91+ StartedAt: time.Now().Add(-time.Hour),
92+ EndedAt: nil, // Active - NOT purgeable
93+ },
94+ {
95+ ID: "20251224_140000_inactive2",
96+ Type: entity.SessionTypeBrowser,
97+ StartedAt: time.Now().Add(-2 * time.Hour),
98+ EndedAt: &endedAt, // Ended - purgeable
99+ },
100+ }
101+
102+ sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil)
103+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
104+ stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(12345), nil)
105+
106+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
107+
108+ output, err := uc.GetPurgeableSessions(ctx)
109+ require.NoError(t, err)
110+ require.NotNil(t, output)
111+
112+ // Should only have 2 inactive sessions, not the active one
113+ require.Len(t, output.Sessions, 2)
114+ assert.Equal(t, int64(12345), output.TotalSize)
115+
116+ // Verify all returned sessions are inactive
117+ for _, s := range output.Sessions {
118+ assert.False(t, s.Info.IsActive, "session %s should not be active", s.Info.Session.ID)
119+ assert.True(t, s.Selected, "sessions should be selected by default")
120+ }
121+}
122+
123+func TestListSessionsUseCase_GetPurgeableSessions_EmptyWhenAllActive(t *testing.T) {
124+ ctx := testContext()
125+
126+ sessionRepo := repomocks.NewMockSessionRepository(t)
127+ stateRepo := repomocks.NewMockSessionStateRepository(t)
128+
129+ sessions := []*entity.Session{
130+ {
131+ ID: "20251224_130000_active1",
132+ Type: entity.SessionTypeBrowser,
133+ StartedAt: time.Now().Add(-time.Hour),
134+ EndedAt: nil, // Active
135+ },
136+ }
137+
138+ sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil)
139+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
140+ stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), nil)
141+
142+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
143+
144+ output, err := uc.GetPurgeableSessions(ctx)
145+ require.NoError(t, err)
146+ require.NotNil(t, output)
147+ require.Empty(t, output.Sessions)
148+}
149+
150+func TestListSessionsUseCase_GetPurgeableSessions_HandlesGetSizeError(t *testing.T) {
151+ ctx := testContext()
152+
153+ sessionRepo := repomocks.NewMockSessionRepository(t)
154+ stateRepo := repomocks.NewMockSessionStateRepository(t)
155+
156+ endedAt := time.Now().Add(-time.Hour)
157+ sessions := []*entity.Session{
158+ {
159+ ID: "20251224_120000_inactive1",
160+ Type: entity.SessionTypeBrowser,
161+ StartedAt: time.Now().Add(-2 * time.Hour),
162+ EndedAt: &endedAt,
163+ },
164+ }
165+
166+ sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil)
167+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
168+ stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), assert.AnError)
169+
170+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
171+
172+ output, err := uc.GetPurgeableSessions(ctx)
173+ // Should succeed even if size query fails
174+ require.NoError(t, err)
175+ require.NotNil(t, output)
176+ require.Len(t, output.Sessions, 1)
177+ assert.Equal(t, int64(0), output.TotalSize) // Size defaults to 0 on error
178+}
M internal/application/usecase/manage_favorites.go +71 -0
@@ -196,6 +196,77 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b
196 return fav != nil, nil196 return fav != nil, nil
197 }197 }
198 198
199+// ToggleResult indicates the result of a toggle operation.
200+type ToggleResult struct {
201+ Added bool // true if favorite was added, false if removed
202+ URL string
203+ Title string
204+ Message string // User-friendly message
205+}
206+
207+// Toggle adds or removes a URL from favorites based on current state.
208+// If the URL is already a favorite, it removes it. Otherwise, it adds it.
209+func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) {
210+ log := logging.FromContext(ctx)
211+ log.Debug().Str("url", url).Msg("toggling favorite")
212+
213+ if url == "" {
214+ return nil, fmt.Errorf("URL cannot be empty")
215+ }
216+
217+ // Check if already favorited
218+ existing, err := uc.favoriteRepo.FindByURL(ctx, url)
219+ if err != nil {
220+ return nil, fmt.Errorf("failed to check existing favorite: %w", err)
221+ }
222+
223+ if existing != nil {
224+ // Remove favorite
225+ if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
226+ return nil, fmt.Errorf("failed to remove favorite: %w", err)
227+ }
228+ log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
229+ return &ToggleResult{
230+ Added: false,
231+ URL: url,
232+ Title: existing.Title,
233+ Message: "Favorite removed",
234+ }, nil
235+ }
236+
237+ // Add favorite
238+ fav := entity.NewFavorite(url, title)
239+ if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
240+ return nil, fmt.Errorf("failed to add favorite: %w", err)
241+ }
242+ log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
243+ return &ToggleResult{
244+ Added: true,
245+ URL: url,
246+ Title: title,
247+ Message: "Favorite added",
248+ }, nil
249+}
250+
251+// GetAllURLs returns a set of all favorited URLs for efficient batch lookup.
252+func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) {
253+ log := logging.FromContext(ctx)
254+ log.Debug().Msg("getting all favorite URLs")
255+
256+ favs, err := uc.favoriteRepo.GetAll(ctx)
257+ if err != nil {
258+ return nil, fmt.Errorf("failed to get favorites: %w", err)
259+ }
260+
261+ urls := make(map[string]struct{}, len(favs))
262+ for _, fav := range favs {
263+ urls[fav.URL] = struct{}{}
264+ }
265+
266+ log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
267+ return urls, nil
268+}
269+
199 // GetTree builds a complete hierarchical view of folders and favorites.270 // GetTree builds a complete hierarchical view of folders and favorites.
200 func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {271 func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {
201 log := logging.FromContext(ctx)272 log := logging.FromContext(ctx)
A internal/application/usecase/manage_favorites_test.go +200 -0
@@ -0,0 +1,200 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestManageFavoritesUseCase_GetAllURLs_ReturnsURLSet(t *testing.T) {
16+ ctx := testContext()
17+
18+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
19+ folderRepo := repomocks.NewMockFolderRepository(t)
20+ tagRepo := repomocks.NewMockTagRepository(t)
21+
22+ favorites := []*entity.Favorite{
23+ {ID: 1, URL: "https://example.com"},
24+ {ID: 2, URL: "https://github.com"},
25+ {ID: 3, URL: "https://google.com"},
26+ }
27+
28+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
29+
30+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
31+
32+ urls, err := uc.GetAllURLs(ctx)
33+ require.NoError(t, err)
34+ require.NotNil(t, urls)
35+
36+ assert.Len(t, urls, 3)
37+ _, hasExample := urls["https://example.com"]
38+ _, hasGithub := urls["https://github.com"]
39+ _, hasGoogle := urls["https://google.com"]
40+ assert.True(t, hasExample)
41+ assert.True(t, hasGithub)
42+ assert.True(t, hasGoogle)
43+}
44+
45+func TestManageFavoritesUseCase_GetAllURLs_ReturnsEmptySetWhenNoFavorites(t *testing.T) {
46+ ctx := testContext()
47+
48+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
49+ folderRepo := repomocks.NewMockFolderRepository(t)
50+ tagRepo := repomocks.NewMockTagRepository(t)
51+
52+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
53+
54+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
55+
56+ urls, err := uc.GetAllURLs(ctx)
57+ require.NoError(t, err)
58+ require.NotNil(t, urls)
59+ assert.Empty(t, urls)
60+}
61+
62+func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing.T) {
63+ ctx := testContext()
64+
65+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
66+ folderRepo := repomocks.NewMockFolderRepository(t)
67+ tagRepo := repomocks.NewMockTagRepository(t)
68+
69+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(nil, errors.New("db connection failed"))
70+
71+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
72+
73+ urls, err := uc.GetAllURLs(ctx)
74+ require.Error(t, err)
75+ assert.Nil(t, urls)
76+ assert.Contains(t, err.Error(), "failed to get favorites")
77+}
78+
79+func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) {
80+ ctx := testContext()
81+
82+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
83+ folderRepo := repomocks.NewMockFolderRepository(t)
84+ tagRepo := repomocks.NewMockTagRepository(t)
85+
86+ // URL is not a favorite
87+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil)
88+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(f *entity.Favorite) bool {
89+ return f.URL == "https://example.com" && f.Title == "Example"
90+ })).Return(nil)
91+
92+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
93+
94+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
95+ require.NoError(t, err)
96+ require.NotNil(t, result)
97+
98+ assert.True(t, result.Added)
99+ assert.Equal(t, "https://example.com", result.URL)
100+ assert.Equal(t, "Example", result.Title)
101+ assert.Equal(t, "Favorite added", result.Message)
102+}
103+
104+func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T) {
105+ ctx := testContext()
106+
107+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
108+ folderRepo := repomocks.NewMockFolderRepository(t)
109+ tagRepo := repomocks.NewMockTagRepository(t)
110+
111+ existingFav := &entity.Favorite{
112+ ID: 42,
113+ URL: "https://example.com",
114+ Title: "Example Site",
115+ }
116+
117+ // URL is already a favorite
118+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)
119+ favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil)
120+
121+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
122+
123+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
124+ require.NoError(t, err)
125+ require.NotNil(t, result)
126+
127+ assert.False(t, result.Added)
128+ assert.Equal(t, "https://example.com", result.URL)
129+ assert.Equal(t, "Example Site", result.Title) // Uses existing title
130+ assert.Equal(t, "Favorite removed", result.Message)
131+}
132+
133+func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) {
134+ ctx := testContext()
135+
136+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
137+ folderRepo := repomocks.NewMockFolderRepository(t)
138+ tagRepo := repomocks.NewMockTagRepository(t)
139+
140+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
141+
142+ result, err := uc.Toggle(ctx, "", "Title")
143+ require.Error(t, err)
144+ assert.Nil(t, result)
145+ assert.Contains(t, err.Error(), "URL cannot be empty")
146+}
147+
148+func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) {
149+ ctx := testContext()
150+
151+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
152+ folderRepo := repomocks.NewMockFolderRepository(t)
153+ tagRepo := repomocks.NewMockTagRepository(t)
154+
155+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error"))
156+
157+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
158+
159+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
160+ require.Error(t, err)
161+ assert.Nil(t, result)
162+ assert.Contains(t, err.Error(), "failed to check existing favorite")
163+}
164+
165+func TestManageFavoritesUseCase_Toggle_SaveErrorReturnsError(t *testing.T) {
166+ ctx := testContext()
167+
168+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
169+ folderRepo := repomocks.NewMockFolderRepository(t)
170+ tagRepo := repomocks.NewMockTagRepository(t)
171+
172+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil)
173+ favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error"))
174+
175+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
176+
177+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
178+ require.Error(t, err)
179+ assert.Nil(t, result)
180+ assert.Contains(t, err.Error(), "failed to add favorite")
181+}
182+
183+func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) {
184+ ctx := testContext()
185+
186+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
187+ folderRepo := repomocks.NewMockFolderRepository(t)
188+ tagRepo := repomocks.NewMockTagRepository(t)
189+
190+ existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"}
191+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)
192+ favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error"))
193+
194+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
195+
196+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
197+ require.Error(t, err)
198+ assert.Nil(t, result)
199+ assert.Contains(t, err.Error(), "failed to remove favorite")
200+}
M internal/application/usecase/manage_panes.go +851 -1
@@ -2,10 +2,14 @@ package usecase
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
7+ "math"
6 "sort"8 "sort"
9+ "strings"
7 10
8 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12+ domainurl "github.com/bnema/dumber/internal/domain/url"
9 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
10 )14 )
11 15
@@ -29,6 +33,42 @@ const (
29 NavDown NavigateDirection = "down"33 NavDown NavigateDirection = "down"
30 )34 )
31 35
36+// ResizeDirection indicates the direction for pane resizing.
37+type ResizeDirection string
38+
39+const (
40+ ResizeIncreaseLeft ResizeDirection = "increase_left"
41+ ResizeIncreaseRight ResizeDirection = "increase_right"
42+ ResizeIncreaseUp ResizeDirection = "increase_up"
43+ ResizeIncreaseDown ResizeDirection = "increase_down"
44+
45+ ResizeDecreaseLeft ResizeDirection = "decrease_left"
46+ ResizeDecreaseRight ResizeDirection = "decrease_right"
47+ ResizeDecreaseUp ResizeDirection = "decrease_up"
48+ ResizeDecreaseDown ResizeDirection = "decrease_down"
49+
50+ ResizeIncrease ResizeDirection = "increase"
51+ ResizeDecrease ResizeDirection = "decrease"
52+)
53+
54+var ErrNothingToResize = errors.New("nothing to resize")
55+
56+type ConsumeOrExpelDirection string
57+
58+const (
59+ ConsumeOrExpelLeft ConsumeOrExpelDirection = "left"
60+ ConsumeOrExpelRight ConsumeOrExpelDirection = "right"
61+ ConsumeOrExpelUp ConsumeOrExpelDirection = "up"
62+ ConsumeOrExpelDown ConsumeOrExpelDirection = "down"
63+)
64+
65+type ConsumeOrExpelResult struct {
66+ Action string // "consumed", "expelled", or "none"
67+ ErrorMessage string
68+}
69+
70+const consumeOrExpelExpelCycleMarker = "_expel_cycle"
71+
32 // ManagePanesUseCase handles pane tree operations.72 // ManagePanesUseCase handles pane tree operations.
33 type ManagePanesUseCase struct {73 type ManagePanesUseCase struct {
34 idGenerator IDGenerator74 idGenerator IDGenerator
@@ -91,7 +131,7 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
91 paneID := entity.PaneID(uc.idGenerator())131 paneID := entity.PaneID(uc.idGenerator())
92 newPane = entity.NewPane(paneID)132 newPane = entity.NewPane(paneID)
93 if input.InitialURL != "" {133 if input.InitialURL != "" {
94- newPane.URI = input.InitialURL134+ newPane.URI = domainurl.Normalize(input.InitialURL)
95 } else {135 } else {
96 newPane.URI = "about:blank"136 newPane.URI = "about:blank"
97 }137 }
@@ -166,6 +206,254 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
166 }, nil206 }, nil
167 }207 }
168 208
209+// Resize adjusts the nearest applicable split ratio for the given direction.
210+// stepPercent is applied per keystroke (e.g. 5.0 means 5%).
211+// minPanePercent enforces a minimum size for each side of a split.
212+func (uc *ManagePanesUseCase) Resize(
213+ ctx context.Context,
214+ ws *entity.Workspace,
215+ paneNode *entity.PaneNode,
216+ dir ResizeDirection,
217+ stepPercent float64,
218+ minPanePercent float64,
219+) error {
220+ log := logging.FromContext(ctx)
221+ if uc == nil {
222+ return fmt.Errorf("manage panes use case is nil")
223+ }
224+
225+ if ws == nil {
226+ return fmt.Errorf("workspace is required")
227+ }
228+ if ws.Root == nil {
229+ return ErrNothingToResize
230+ }
231+ if paneNode == nil {
232+ return fmt.Errorf("pane node is required")
233+ }
234+
235+ target := paneNode
236+ if target.Parent != nil && target.Parent.IsStacked {
237+ target = target.Parent
238+ }
239+
240+ actualDir := dir
241+ switch dir {
242+ case ResizeIncrease:
243+ actualDir = findSmartResizeDirection(target, true)
244+ case ResizeDecrease:
245+ actualDir = findSmartResizeDirection(target, false)
246+ }
247+ if actualDir == "" {
248+ return ErrNothingToResize
249+ }
250+
251+ axis, ok := axisForResizeDirection(actualDir)
252+ if !ok {
253+ return ErrNothingToResize
254+ }
255+
256+ splitNode := findNearestSplitForAxis(target, axis)
257+ if splitNode == nil {
258+ return ErrNothingToResize
259+ }
260+
261+ // We treat resize directions as moving the split divider.
262+ // SplitRatio is the proportion allocated to the first child (left/top).
263+ // - Moving the divider right/down increases SplitRatio.
264+ // - Moving the divider left/up decreases SplitRatio.
265+ delta := deltaForDividerMove(actualDir, stepPercent)
266+
267+ minRatio := minPanePercent / 100.0
268+ maxRatio := 1.0 - minRatio
269+ oldRatio := splitNode.SplitRatio
270+ splitNode.SplitRatio = clampFloat64(splitNode.SplitRatio+delta, minRatio, maxRatio)
271+
272+ log.Debug().
273+ Str("direction", string(dir)).
274+ Str("actual_direction", string(actualDir)).
275+ Float64("old_ratio", oldRatio).
276+ Float64("new_ratio", splitNode.SplitRatio).
277+ Msg("pane resized")
278+
279+ return nil
280+}
281+
282+type SetSplitRatioInput struct {
283+ Workspace *entity.Workspace
284+ SplitNodeID string
285+ Ratio float64
286+ MinPanePercent float64
287+}
288+
289+func (uc *ManagePanesUseCase) SetSplitRatio(ctx context.Context, input SetSplitRatioInput) error {
290+ log := logging.FromContext(ctx)
291+ if uc == nil {
292+ return fmt.Errorf("manage panes use case is nil")
293+ }
294+ if input.Workspace == nil {
295+ return fmt.Errorf("workspace is required")
296+ }
297+ if input.Workspace.Root == nil {
298+ return ErrNothingToResize
299+ }
300+ if input.SplitNodeID == "" {
301+ return fmt.Errorf("split node id is required")
302+ }
303+
304+ var splitNode *entity.PaneNode
305+ input.Workspace.Root.Walk(func(node *entity.PaneNode) bool {
306+ if node.ID == input.SplitNodeID {
307+ splitNode = node
308+ return false
309+ }
310+ return true
311+ })
312+
313+ if splitNode == nil || !splitNode.IsSplit() {
314+ return fmt.Errorf("split node not found: %s", input.SplitNodeID)
315+ }
316+
317+ minRatio := input.MinPanePercent / 100.0
318+ maxRatio := 1.0 - minRatio
319+ oldRatio := splitNode.SplitRatio
320+ clamped := clampFloat64(input.Ratio, minRatio, maxRatio)
321+ splitNode.SplitRatio = roundSplitRatio(clamped)
322+
323+ log.Debug().
324+ Str("split_node_id", input.SplitNodeID).
325+ Float64("old_ratio", oldRatio).
326+ Float64("new_ratio", splitNode.SplitRatio).
327+ Msg("split ratio set")
328+
329+ return nil
330+}
331+
332+func findSmartResizeDirection(target *entity.PaneNode, growActive bool) ResizeDirection {
333+ splitNode, axis, isStartChild := findNearestSplitForResize(target)
334+ if splitNode == nil {
335+ return ""
336+ }
337+
338+ // Smart resize means "grow/shrink the active pane".
339+ // SplitRatio is the proportion allocated to the first child (left/top).
340+ // - If active is first child: grow by increasing ratio, shrink by decreasing.
341+ // - If active is second child: grow by decreasing ratio, shrink by increasing.
342+ growMeansIncreaseRatio := isStartChild
343+ if !growActive {
344+ growMeansIncreaseRatio = !growMeansIncreaseRatio
345+ }
346+
347+ switch axis {
348+ case resizeAxisHorizontal:
349+ if growMeansIncreaseRatio {
350+ return ResizeIncreaseRight
351+ }
352+ return ResizeIncreaseLeft
353+ case resizeAxisVertical:
354+ if growMeansIncreaseRatio {
355+ return ResizeIncreaseDown
356+ }
357+ return ResizeIncreaseUp
358+ default:
359+ return ""
360+ }
361+}
362+
363+// findNearestSplitForResize returns the nearest split ancestor for the active pane.
364+// It prefers horizontal splits over vertical when both are available at the same depth.
365+func findNearestSplitForResize(node *entity.PaneNode) (splitNode *entity.PaneNode, axis resizeAxis, isStartChild bool) {
366+ current := node
367+ for current != nil && current.Parent != nil {
368+ parent := current.Parent
369+ if parent.IsSplit() {
370+ isStartChild = parent.Left() == current
371+ switch parent.SplitDir {
372+ case entity.SplitHorizontal:
373+ return parent, resizeAxisHorizontal, isStartChild
374+ case entity.SplitVertical:
375+ return parent, resizeAxisVertical, isStartChild
376+ }
377+ }
378+ current = parent
379+ }
380+ return nil, resizeAxisNone, false
381+}
382+
383+type resizeAxis int
384+
385+const (
386+ resizeAxisNone resizeAxis = iota
387+ resizeAxisHorizontal
388+ resizeAxisVertical
389+)
390+
391+func axisForResizeDirection(dir ResizeDirection) (resizeAxis, bool) {
392+ switch dir {
393+ case ResizeIncreaseLeft, ResizeIncreaseRight, ResizeDecreaseLeft, ResizeDecreaseRight:
394+ return resizeAxisHorizontal, true
395+ case ResizeIncreaseUp, ResizeIncreaseDown, ResizeDecreaseUp, ResizeDecreaseDown:
396+ return resizeAxisVertical, true
397+ default:
398+ return resizeAxisNone, false
399+ }
400+}
401+
402+func deltaForDividerMove(dir ResizeDirection, stepPercent float64) float64 {
403+ if stepPercent < 0 {
404+ stepPercent = -stepPercent
405+ }
406+ delta := stepPercent / 100.0
407+
408+ switch dir {
409+ case ResizeIncreaseRight, ResizeIncreaseDown:
410+ return delta
411+ case ResizeIncreaseLeft, ResizeIncreaseUp:
412+ return -delta
413+ case ResizeDecreaseRight, ResizeDecreaseDown:
414+ return -delta
415+ case ResizeDecreaseLeft, ResizeDecreaseUp:
416+ return delta
417+ default:
418+ return 0
419+ }
420+}
421+
422+// findNearestSplitForAxis walks up the tree to find the nearest split matching the axis.
423+func findNearestSplitForAxis(node *entity.PaneNode, axis resizeAxis) *entity.PaneNode {
424+ current := node
425+ for current != nil && current.Parent != nil {
426+ parent := current.Parent
427+ if parent.IsSplit() {
428+ if axis == resizeAxisHorizontal && parent.SplitDir == entity.SplitHorizontal {
429+ return parent
430+ }
431+ if axis == resizeAxisVertical && parent.SplitDir == entity.SplitVertical {
432+ return parent
433+ }
434+ }
435+ current = parent
436+ }
437+ return nil
438+}
439+
440+const splitRatioRoundFactor = 100.0
441+
442+func roundSplitRatio(ratio float64) float64 {
443+ // Keep snapshots stable and readable; avoids persisting noisy float values.
444+ return math.Round(ratio*splitRatioRoundFactor) / splitRatioRoundFactor
445+}
446+
447+func clampFloat64(v, minVal, maxVal float64) float64 {
448+ if v < minVal {
449+ return minVal
450+ }
451+ if v > maxVal {
452+ return maxVal
453+ }
454+ return v
455+}
456+
169 // Close removes a pane and promotes its sibling.457 // Close removes a pane and promotes its sibling.
170 // Returns the sibling node that was promoted, or nil if this was the last pane.458 // Returns the sibling node that was promoted, or nil if this was the last pane.
171 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {459 func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) {
@@ -803,6 +1091,568 @@ func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *en
803 return nil1091 return nil
804 }1092 }
805 1093
1094+func (uc *ManagePanesUseCase) ConsumeOrExpel(
1095+ ctx context.Context,
1096+ ws *entity.Workspace,
1097+ activeNode *entity.PaneNode,
1098+ direction ConsumeOrExpelDirection,
1099+) (*ConsumeOrExpelResult, error) {
1100+ if uc == nil {
1101+ return nil, fmt.Errorf("manage panes use case is nil")
1102+ }
1103+ if ws == nil {
1104+ return nil, fmt.Errorf("workspace is required")
1105+ }
1106+ if ws.Root == nil {
1107+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1108+ }
1109+ if activeNode == nil {
1110+ return nil, fmt.Errorf("active pane node is required")
1111+ }
1112+ if activeNode.Pane == nil {
1113+ return nil, fmt.Errorf("active pane node has no pane")
1114+ }
1115+
1116+ if ws.PaneCount() <= 1 {
1117+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil
1118+ }
1119+
1120+ // Expel if active pane is inside a stack.
1121+ if activeNode.Parent != nil && activeNode.Parent.IsStacked {
1122+ return uc.expelFromStack(ctx, ws, activeNode, direction)
1123+ }
1124+
1125+ // Consume if active pane is not inside a stack.
1126+ return uc.consumeIntoSiblingStack(ctx, ws, activeNode, direction)
1127+}
1128+
1129+func (*ManagePanesUseCase) consumeIntoSiblingStack(
1130+ ctx context.Context,
1131+ ws *entity.Workspace,
1132+ activeNode *entity.PaneNode,
1133+ direction ConsumeOrExpelDirection,
1134+) (*ConsumeOrExpelResult, error) {
1135+ log := logging.FromContext(ctx)
1136+
1137+ sibling, _ := findAdjacentSiblingForConsumeOrExpel(activeNode, direction)
1138+ if sibling == nil {
1139+ sibling = fallbackSiblingForConsumeOrExpel(activeNode, direction)
1140+ }
1141+ if sibling == nil {
1142+ return &ConsumeOrExpelResult{Action: "none", ErrorMessage: directionNoSiblingMessage(direction)}, nil
1143+ }
1144+
1145+ // Left/right consume cycles layout for immediate split siblings:
1146+ // 1) horizontal split -> vertical split (target on top)
1147+ // 2) vertical split -> stack (top then bottom)
1148+ if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1149+ cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction)
1150+ if err != nil {
1151+ return nil, err
1152+ }
1153+ if cycled {
1154+ switch cycleAction {
1155+ case "vertical_split":
1156+ log.Info().
1157+ Str("pane_id", activeNode.ID).
1158+ Str("sibling_id", sibling.ID).
1159+ Msg("pane moved below sibling")
1160+ case "stack":
1161+ log.Info().
1162+ Str("pane_id", activeNode.ID).
1163+ Str("sibling_id", sibling.ID).
1164+ Msg("split converted to stack")
1165+ }
1166+ return &ConsumeOrExpelResult{Action: "consumed"}, nil
1167+ }
1168+ }
1169+
1170+ moved, err := detachLeafByPromotingSibling(ws, activeNode)
1171+ if err != nil {
1172+ return nil, err
1173+ }
1174+ moved.Parent = nil
1175+
1176+ stackNode := sibling
1177+ if stackNode.IsLeaf() {
1178+ stackNode = convertLeafToStackContainer(stackNode)
1179+ }
1180+ if !stackNode.IsStacked {
1181+ return nil, fmt.Errorf("target sibling is not stackable")
1182+ }
1183+
1184+ // Insert consumed pane at the bottom/end.
1185+ moved.Parent = stackNode
1186+ stackNode.Children = append(stackNode.Children, moved)
1187+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1188+ ws.ActivePaneID = moved.Pane.ID
1189+
1190+ log.Info().
1191+ Str("pane_id", moved.ID).
1192+ Str("stack_id", stackNode.ID).
1193+ Int("stack_size", len(stackNode.Children)).
1194+ Msg("pane consumed into sibling stack")
1195+
1196+ return &ConsumeOrExpelResult{Action: "consumed"}, nil
1197+}
1198+
1199+func (uc *ManagePanesUseCase) expelFromStack(
1200+ ctx context.Context,
1201+ ws *entity.Workspace,
1202+ activeNode *entity.PaneNode,
1203+ direction ConsumeOrExpelDirection,
1204+) (*ConsumeOrExpelResult, error) {
1205+ log := logging.FromContext(ctx)
1206+
1207+ stackNode := activeNode.Parent
1208+ if stackNode == nil || !stackNode.IsStacked {
1209+ return &ConsumeOrExpelResult{Action: "none"}, nil
1210+ }
1211+
1212+ expelled, err := removeLeafFromStack(stackNode, activeNode)
1213+ if err != nil {
1214+ return nil, err
1215+ }
1216+ expelled.Parent = nil
1217+
1218+ // If only one pane remains, dissolve stack into a leaf node.
1219+ if len(stackNode.Children) == 1 {
1220+ dissolveStackIntoLeaf(stackNode)
1221+ }
1222+
1223+ // Split around the remaining container (stackNode) with expelled pane.
1224+ // For left/right, expel cycles stack -> vertical split first:
1225+ // - left: expelled pane above
1226+ // - right: expelled pane below
1227+ splitDirection := direction
1228+ switch direction {
1229+ case ConsumeOrExpelLeft:
1230+ splitDirection = ConsumeOrExpelUp
1231+ case ConsumeOrExpelRight:
1232+ splitDirection = ConsumeOrExpelDown
1233+ }
1234+
1235+ if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight {
1236+ err := splitExistingNodeWithMarker(
1237+ ws,
1238+ stackNode,
1239+ expelled,
1240+ splitDirection,
1241+ uc.idGenerator,
1242+ consumeOrExpelExpelCycleMarker,
1243+ )
1244+ if err != nil {
1245+ return nil, err
1246+ }
1247+ } else {
1248+ if err := splitExistingNode(ws, stackNode, expelled, splitDirection, uc.idGenerator); err != nil {
1249+ return nil, err
1250+ }
1251+ }
1252+ ws.ActivePaneID = expelled.Pane.ID
1253+
1254+ log.Info().
1255+ Str("pane_id", expelled.ID).
1256+ Str("direction", string(direction)).
1257+ Msg("pane expelled from stack")
1258+
1259+ return &ConsumeOrExpelResult{Action: "expelled"}, nil
1260+}
1261+
1262+func directionNoSiblingMessage(direction ConsumeOrExpelDirection) string {
1263+ switch direction {
1264+ case ConsumeOrExpelLeft:
1265+ return "No pane to the left"
1266+ case ConsumeOrExpelRight:
1267+ return "No pane to the right"
1268+ case ConsumeOrExpelUp:
1269+ return "No pane above"
1270+ case ConsumeOrExpelDown:
1271+ return "No pane below"
1272+ default:
1273+ return "No pane in that direction"
1274+ }
1275+}
1276+
1277+func findAdjacentSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) (*entity.PaneNode, string) {
1278+ if node == nil {
1279+ return nil, ""
1280+ }
1281+
1282+ // Determine if we need to go horizontal or vertical.
1283+ isHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1284+ isForward := direction == ConsumeOrExpelRight || direction == ConsumeOrExpelDown
1285+
1286+ // Walk up the tree to find an ancestor split matching our direction axis.
1287+ current := node
1288+ for current.Parent != nil {
1289+ parent := current.Parent
1290+ if parent.IsSplit() {
1291+ parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal
1292+ if parentIsHorizontal == isHorizontal {
1293+ if isForward {
1294+ if parent.Right() != nil && parent.Left() == current {
1295+ return parent.Right(), ""
1296+ }
1297+ } else {
1298+ if parent.Left() != nil && parent.Right() == current {
1299+ return parent.Left(), ""
1300+ }
1301+ }
1302+ }
1303+ }
1304+ current = parent
1305+ }
1306+
1307+ return nil, directionNoSiblingMessage(direction)
1308+}
1309+
1310+func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) *entity.PaneNode {
1311+ if node == nil || node.Parent == nil {
1312+ return nil
1313+ }
1314+
1315+ parent := node.Parent
1316+ if !parent.IsSplit() {
1317+ return nil
1318+ }
1319+
1320+ axisHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight
1321+ if axisHorizontal {
1322+ if parent.SplitDir == entity.SplitHorizontal {
1323+ return nil
1324+ }
1325+ } else {
1326+ if parent.SplitDir == entity.SplitVertical {
1327+ return nil
1328+ }
1329+ }
1330+
1331+ // Cross-axis fallback is only used to enable the leaf cycling behavior
1332+ // (H -> V -> stack, and after expel: V -> H). It should never cause a leaf to
1333+ // consume into a stacked/split sibling when pressing an unrelated axis key.
1334+ var sibling *entity.PaneNode
1335+ if parent.Left() == node {
1336+ sibling = parent.Right()
1337+ } else if parent.Right() == node {
1338+ sibling = parent.Left()
1339+ }
1340+ if sibling == nil {
1341+ return nil
1342+ }
1343+ if !node.IsLeaf() || !sibling.IsLeaf() {
1344+ return nil
1345+ }
1346+ return sibling
1347+}
1348+
1349+func cycleHorizontalToVerticalThenStack(
1350+ ws *entity.Workspace,
1351+ activeNode *entity.PaneNode,
1352+ sibling *entity.PaneNode,
1353+ direction ConsumeOrExpelDirection,
1354+) (cycled bool, cycleAction string, err error) {
1355+ if ws == nil {
1356+ return false, "", fmt.Errorf("workspace is required")
1357+ }
1358+ if activeNode == nil || sibling == nil {
1359+ return false, "", nil
1360+ }
1361+ if !activeNode.IsLeaf() || !sibling.IsLeaf() {
1362+ return false, "", nil
1363+ }
1364+
1365+ parent := activeNode.Parent
1366+ if parent == nil || sibling.Parent != parent || !parent.IsSplit() {
1367+ return false, "", nil
1368+ }
1369+
1370+ switch parent.SplitDir {
1371+ case entity.SplitHorizontal:
1372+ // Horizontal -> Vertical: target sibling should be on top.
1373+ parent.SplitDir = entity.SplitVertical
1374+ if parent.Children[0] != sibling {
1375+ parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1376+ }
1377+ ws.ActivePaneID = activeNode.Pane.ID
1378+ return true, "vertical_split", nil
1379+ case entity.SplitVertical:
1380+ // Vertical -> Horizontal when pressing left/right (reverse of first step).
1381+ // This is used after expel: stack -> vertical; then Ctrl+[ / Ctrl+] should turn it into left/right.
1382+ if direction == ConsumeOrExpelRight && strings.Contains(parent.ID, consumeOrExpelExpelCycleMarker) {
1383+ parent.SplitDir = entity.SplitHorizontal
1384+ // Active should become the right child.
1385+ if parent.Children[1] != activeNode {
1386+ parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0]
1387+ }
1388+ ws.ActivePaneID = activeNode.Pane.ID
1389+ return true, "horizontal_split", nil
1390+ }
1391+
1392+ // Otherwise, vertical -> stack.
1393+ parent.SplitDir = entity.SplitNone
1394+ parent.SplitRatio = 0
1395+ parent.IsStacked = true
1396+ parent.Pane = nil
1397+ if parent.Children[0] == activeNode {
1398+ parent.ActiveStackIndex = 0
1399+ } else if parent.Children[1] == activeNode {
1400+ parent.ActiveStackIndex = 1
1401+ } else {
1402+ parent.ActiveStackIndex = 0
1403+ }
1404+ ws.ActivePaneID = activeNode.Pane.ID
1405+ return true, "stack", nil
1406+ default:
1407+ return false, "", nil
1408+ }
1409+}
1410+
1411+func detachLeafByPromotingSibling(ws *entity.Workspace, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1412+ if ws == nil {
1413+ return nil, fmt.Errorf("workspace is required")
1414+ }
1415+ if leaf == nil {
1416+ return nil, fmt.Errorf("leaf is required")
1417+ }
1418+ if !leaf.IsLeaf() {
1419+ return nil, fmt.Errorf("can only detach leaf node")
1420+ }
1421+
1422+ parent := leaf.Parent
1423+ if parent == nil {
1424+ return nil, fmt.Errorf("cannot detach root leaf")
1425+ }
1426+ if !parent.IsSplit() {
1427+ return nil, fmt.Errorf("leaf parent is not a split")
1428+ }
1429+
1430+ var sibling *entity.PaneNode
1431+ for _, child := range parent.Children {
1432+ if child != leaf {
1433+ sibling = child
1434+ break
1435+ }
1436+ }
1437+ if sibling == nil {
1438+ return nil, fmt.Errorf("no sibling found for leaf")
1439+ }
1440+
1441+ grandparent := parent.Parent
1442+ if grandparent == nil {
1443+ ws.Root = sibling
1444+ sibling.Parent = nil
1445+ } else {
1446+ for i, child := range grandparent.Children {
1447+ if child == parent {
1448+ grandparent.Children[i] = sibling
1449+ break
1450+ }
1451+ }
1452+ sibling.Parent = grandparent
1453+ }
1454+
1455+ leaf.Parent = nil
1456+ return leaf, nil
1457+}
1458+
1459+func convertLeafToStackContainer(leaf *entity.PaneNode) *entity.PaneNode {
1460+ if leaf == nil || !leaf.IsLeaf() {
1461+ return leaf
1462+ }
1463+
1464+ originalPane := leaf.Pane
1465+ originalChildNode := &entity.PaneNode{
1466+ ID: leaf.ID + "_0",
1467+ Pane: originalPane,
1468+ Parent: leaf,
1469+ }
1470+
1471+ leaf.Pane = nil
1472+ leaf.IsStacked = true
1473+ leaf.ActiveStackIndex = 0
1474+ leaf.Children = []*entity.PaneNode{originalChildNode}
1475+
1476+ return leaf
1477+}
1478+
1479+func removeLeafFromStack(stackNode, leaf *entity.PaneNode) (*entity.PaneNode, error) {
1480+ if stackNode == nil {
1481+ return nil, fmt.Errorf("stack node is required")
1482+ }
1483+ if !stackNode.IsStacked {
1484+ return nil, fmt.Errorf("node is not a stack")
1485+ }
1486+ if leaf == nil || leaf.Pane == nil {
1487+ return nil, fmt.Errorf("leaf is required")
1488+ }
1489+
1490+ idx := -1
1491+ for i, child := range stackNode.Children {
1492+ if child == leaf {
1493+ idx = i
1494+ break
1495+ }
1496+ }
1497+ if idx < 0 {
1498+ return nil, fmt.Errorf("pane not found in stack")
1499+ }
1500+
1501+ stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...)
1502+ if stackNode.ActiveStackIndex >= len(stackNode.Children) {
1503+ stackNode.ActiveStackIndex = len(stackNode.Children) - 1
1504+ }
1505+ if stackNode.ActiveStackIndex < 0 {
1506+ stackNode.ActiveStackIndex = 0
1507+ }
1508+
1509+ leaf.Parent = nil
1510+ return leaf, nil
1511+}
1512+
1513+func dissolveStackIntoLeaf(stackNode *entity.PaneNode) {
1514+ if stackNode == nil || !stackNode.IsStacked || len(stackNode.Children) != 1 {
1515+ return
1516+ }
1517+
1518+ remaining := stackNode.Children[0]
1519+ stackNode.Pane = remaining.Pane
1520+ stackNode.Children = nil
1521+ stackNode.IsStacked = false
1522+ stackNode.ActiveStackIndex = 0
1523+}
1524+
1525+func splitExistingNode(
1526+ ws *entity.Workspace,
1527+ targetNode *entity.PaneNode,
1528+ newSibling *entity.PaneNode,
1529+ direction ConsumeOrExpelDirection,
1530+ idGen IDGenerator,
1531+) error {
1532+ if ws == nil {
1533+ return fmt.Errorf("workspace is required")
1534+ }
1535+ if targetNode == nil {
1536+ return fmt.Errorf("target node is required")
1537+ }
1538+ if newSibling == nil {
1539+ return fmt.Errorf("new sibling node is required")
1540+ }
1541+ if idGen == nil {
1542+ return fmt.Errorf("id generator is required")
1543+ }
1544+
1545+ var splitDir entity.SplitDirection
1546+ switch direction {
1547+ case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1548+ splitDir = entity.SplitHorizontal
1549+ case ConsumeOrExpelUp, ConsumeOrExpelDown:
1550+ splitDir = entity.SplitVertical
1551+ default:
1552+ return fmt.Errorf("invalid direction: %s", direction)
1553+ }
1554+
1555+ parentNode := &entity.PaneNode{
1556+ ID: idGen(),
1557+ SplitDir: splitDir,
1558+ SplitRatio: 0.5,
1559+ Children: make([]*entity.PaneNode, 2),
1560+ }
1561+
1562+ switch direction {
1563+ case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1564+ parentNode.Children[0] = newSibling
1565+ parentNode.Children[1] = targetNode
1566+ case ConsumeOrExpelRight, ConsumeOrExpelDown:
1567+ parentNode.Children[0] = targetNode
1568+ parentNode.Children[1] = newSibling
1569+ }
1570+
1571+ newSibling.Parent = parentNode
1572+ oldParent := targetNode.Parent
1573+ targetNode.Parent = parentNode
1574+
1575+ if oldParent == nil {
1576+ ws.Root = parentNode
1577+ } else {
1578+ for i, child := range oldParent.Children {
1579+ if child == targetNode {
1580+ oldParent.Children[i] = parentNode
1581+ break
1582+ }
1583+ }
1584+ parentNode.Parent = oldParent
1585+ }
1586+
1587+ return nil
1588+}
1589+
1590+func splitExistingNodeWithMarker(
1591+ ws *entity.Workspace,
1592+ targetNode *entity.PaneNode,
1593+ newSibling *entity.PaneNode,
1594+ direction ConsumeOrExpelDirection,
1595+ idGen IDGenerator,
1596+ marker string,
1597+) error {
1598+ if ws == nil {
1599+ return fmt.Errorf("workspace is required")
1600+ }
1601+ if targetNode == nil {
1602+ return fmt.Errorf("target node is required")
1603+ }
1604+ if newSibling == nil {
1605+ return fmt.Errorf("new sibling node is required")
1606+ }
1607+ if idGen == nil {
1608+ return fmt.Errorf("id generator is required")
1609+ }
1610+
1611+ var splitDir entity.SplitDirection
1612+ switch direction {
1613+ case ConsumeOrExpelLeft, ConsumeOrExpelRight:
1614+ splitDir = entity.SplitHorizontal
1615+ case ConsumeOrExpelUp, ConsumeOrExpelDown:
1616+ splitDir = entity.SplitVertical
1617+ default:
1618+ return fmt.Errorf("invalid direction: %s", direction)
1619+ }
1620+
1621+ parentNode := &entity.PaneNode{
1622+ ID: idGen() + marker,
1623+ SplitDir: splitDir,
1624+ SplitRatio: 0.5,
1625+ Children: make([]*entity.PaneNode, 2),
1626+ }
1627+
1628+ switch direction {
1629+ case ConsumeOrExpelLeft, ConsumeOrExpelUp:
1630+ parentNode.Children[0] = newSibling
1631+ parentNode.Children[1] = targetNode
1632+ case ConsumeOrExpelRight, ConsumeOrExpelDown:
1633+ parentNode.Children[0] = targetNode
1634+ parentNode.Children[1] = newSibling
1635+ }
1636+
1637+ newSibling.Parent = parentNode
1638+ oldParent := targetNode.Parent
1639+ targetNode.Parent = parentNode
1640+
1641+ if oldParent == nil {
1642+ ws.Root = parentNode
1643+ } else {
1644+ for i, child := range oldParent.Children {
1645+ if child == targetNode {
1646+ oldParent.Children[i] = parentNode
1647+ break
1648+ }
1649+ }
1650+ parentNode.Parent = oldParent
1651+ }
1652+
1653+ return nil
1654+}
1655+
806 // GetAllPanes returns all leaf panes in a workspace.1656 // GetAllPanes returns all leaf panes in a workspace.
807 //1657 //
808 //nolint:revive // receiver required for interface consistency1658 //nolint:revive // receiver required for interface consistency
A internal/application/usecase/manage_panes_consume_expel_test.go +438 -0
@@ -0,0 +1,438 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_CyclesHorizontalToVerticalThenStack(t *testing.T) {
11+ uc := NewManagePanesUseCase(func() string { return "id" })
12+ ctx := context.Background()
13+
14+ left := leaf("left")
15+ right := leaf("right")
16+ root := split(entity.SplitHorizontal, left, right)
17+ ws := &entity.Workspace{Root: root, ActivePaneID: right.Pane.ID}
18+
19+ // First press: horizontal -> vertical split (left on top, right on bottom).
20+ res, err := uc.ConsumeOrExpel(ctx, ws, right, ConsumeOrExpelLeft)
21+ if err != nil {
22+ t.Fatalf("unexpected error: %v", err)
23+ }
24+ if res == nil || res.Action != "consumed" {
25+ t.Fatalf("result action=%v, want consumed", res)
26+ }
27+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
28+ t.Fatalf("root should be a vertical split")
29+ }
30+ if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "left" {
31+ t.Fatalf("top pane=%v, want left", ws.Root.Left())
32+ }
33+ if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "right" {
34+ t.Fatalf("bottom pane=%v, want right", ws.Root.Right())
35+ }
36+ if ws.ActivePaneID != "right" {
37+ t.Fatalf("active=%s, want right", ws.ActivePaneID)
38+ }
39+
40+ // Second press: vertical split -> stack.
41+ active := ws.ActivePane()
42+ if active == nil {
43+ t.Fatalf("expected active pane")
44+ }
45+ res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelLeft)
46+ if err != nil {
47+ t.Fatalf("unexpected error: %v", err)
48+ }
49+ if res == nil || res.Action != "consumed" {
50+ t.Fatalf("result action=%v, want consumed", res)
51+ }
52+ if ws.Root == nil || !ws.Root.IsStacked {
53+ t.Fatalf("root should be a stack")
54+ }
55+ if got := panesInOrder(ws.Root); got != "left,right" {
56+ t.Fatalf("stack panes=%s, want left,right", got)
57+ }
58+ if ws.ActivePaneID != "right" {
59+ t.Fatalf("active=%s, want right", ws.ActivePaneID)
60+ }
61+}
62+
63+func TestManagePanesUseCase_ConsumeOrExpel_ConsumeRight_CyclesHorizontalToVerticalThenStack(t *testing.T) {
64+ uc := NewManagePanesUseCase(func() string { return "id" })
65+ ctx := context.Background()
66+
67+ left := leaf("left")
68+ right := leaf("right")
69+ root := split(entity.SplitHorizontal, left, right)
70+ ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID}
71+
72+ // First press: horizontal -> vertical split (right on top, left on bottom).
73+ res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelRight)
74+ if err != nil {
75+ t.Fatalf("unexpected error: %v", err)
76+ }
77+ if res == nil || res.Action != "consumed" {
78+ t.Fatalf("result action=%v, want consumed", res)
79+ }
80+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
81+ t.Fatalf("root should be a vertical split")
82+ }
83+ if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "right" {
84+ t.Fatalf("top pane=%v, want right", ws.Root.Left())
85+ }
86+ if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "left" {
87+ t.Fatalf("bottom pane=%v, want left", ws.Root.Right())
88+ }
89+ if ws.ActivePaneID != "left" {
90+ t.Fatalf("active=%s, want left", ws.ActivePaneID)
91+ }
92+
93+ // Second press: vertical split -> stack.
94+ active := ws.ActivePane()
95+ if active == nil {
96+ t.Fatalf("expected active pane")
97+ }
98+ res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelRight)
99+ if err != nil {
100+ t.Fatalf("unexpected error: %v", err)
101+ }
102+ if res == nil || res.Action != "consumed" {
103+ t.Fatalf("result action=%v, want consumed", res)
104+ }
105+ if ws.Root == nil || !ws.Root.IsStacked {
106+ t.Fatalf("root should be a stack")
107+ }
108+ if got := panesInOrder(ws.Root); got != "right,left" {
109+ t.Fatalf("stack panes=%s, want right,left", got)
110+ }
111+ if ws.ActivePaneID != "left" {
112+ t.Fatalf("active=%s, want left", ws.ActivePaneID)
113+ }
114+}
115+
116+func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd(t *testing.T) {
117+ uc := NewManagePanesUseCase(func() string { return "id" })
118+ ctx := context.Background()
119+
120+ stackNode := stack(leaf("a"), leaf("b"))
121+ c := leaf("c")
122+ root := split(entity.SplitHorizontal, stackNode, c)
123+ ws := &entity.Workspace{Root: root, ActivePaneID: c.Pane.ID}
124+
125+ res, err := uc.ConsumeOrExpel(ctx, ws, c, ConsumeOrExpelLeft)
126+ if err != nil {
127+ t.Fatalf("unexpected error: %v", err)
128+ }
129+ if res == nil || res.Action != "consumed" {
130+ t.Fatalf("result action=%v, want consumed", res)
131+ }
132+ if ws.Root == nil || !ws.Root.IsStacked {
133+ t.Fatalf("root should be a stack")
134+ }
135+ if got := panesInOrder(ws.Root); got != "a,b,c" {
136+ t.Fatalf("stack panes=%s, want a,b,c", got)
137+ }
138+ if ws.ActivePaneID != "c" {
139+ t.Fatalf("active=%s, want c", ws.ActivePaneID)
140+ }
141+}
142+
143+func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_InVerticalSplitWithStackedSibling_Noops(t *testing.T) {
144+ uc := NewManagePanesUseCase(func() string { return "id" })
145+ ctx := context.Background()
146+
147+ top := leaf("top")
148+ bottomStack := stack(leaf("a"), leaf("b"))
149+ root := split(entity.SplitVertical, top, bottomStack)
150+ ws := &entity.Workspace{Root: root, ActivePaneID: top.Pane.ID}
151+
152+ res, err := uc.ConsumeOrExpel(ctx, ws, top, ConsumeOrExpelLeft)
153+ if err != nil {
154+ t.Fatalf("unexpected error: %v", err)
155+ }
156+ if res == nil || res.Action != "none" {
157+ t.Fatalf("result action=%v, want none", res)
158+ }
159+ if res.ErrorMessage != "No pane to the left" {
160+ t.Fatalf("error=%q, want %q", res.ErrorMessage, "No pane to the left")
161+ }
162+ if ws.Root != root {
163+ t.Fatalf("tree should be unchanged")
164+ }
165+}
166+
167+func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) {
168+ uc := NewManagePanesUseCase(func() string { return "id" })
169+ ctx := context.Background()
170+
171+ stackNode := stack(leaf("a"), leaf("b"))
172+ b := stackNode.Children[1]
173+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
174+
175+ res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight)
176+ if err != nil {
177+ t.Fatalf("unexpected error: %v", err)
178+ }
179+ if res == nil || res.Action != "expelled" {
180+ t.Fatalf("result action=%v, want expelled", res)
181+ }
182+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
183+ t.Fatalf("root should be a vertical split")
184+ }
185+
186+ top := ws.Root.Left()
187+ bottom := ws.Root.Right()
188+ if top == nil || bottom == nil {
189+ t.Fatalf("split children should exist")
190+ }
191+ if !top.IsLeaf() || top.Pane == nil || top.Pane.ID != "a" {
192+ t.Fatalf("top child should be leaf a")
193+ }
194+ if !bottom.IsLeaf() || bottom.Pane == nil || bottom.Pane.ID != "b" {
195+ t.Fatalf("bottom child should be leaf b")
196+ }
197+ if ws.ActivePaneID != "b" {
198+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
199+ }
200+}
201+
202+func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeRight_CyclesVerticalToHorizontal(t *testing.T) {
203+ uc := NewManagePanesUseCase(func() string { return "id" })
204+ ctx := context.Background()
205+
206+ stackNode := stack(leaf("a"), leaf("b"))
207+ b := stackNode.Children[1]
208+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
209+
210+ // First press (on stacked pane): stack -> vertical split with active on bottom.
211+ mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled")
212+ mustSplitRoot(t, ws, entity.SplitVertical)
213+ if string(ws.ActivePaneID) != "b" {
214+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
215+ }
216+
217+ active := ws.ActivePane()
218+ mustLeafPaneID(t, active, "b")
219+
220+ // Second press (on bottom pane): vertical -> horizontal split with active on right.
221+ mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelRight, "consumed")
222+ root := mustSplitRoot(t, ws, entity.SplitHorizontal)
223+ mustLeafPaneID(t, root.Left(), "a")
224+ mustLeafPaneID(t, root.Right(), "b")
225+ if string(ws.ActivePaneID) != "b" {
226+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
227+ }
228+}
229+
230+func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeLeft_ReturnsToStack(t *testing.T) {
231+ uc := NewManagePanesUseCase(func() string { return "id" })
232+ ctx := context.Background()
233+
234+ stackNode := stack(leaf("a"), leaf("b"))
235+ b := stackNode.Children[1]
236+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
237+
238+ // First press (on stacked pane): stack -> vertical split with active on bottom.
239+ mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled")
240+ mustSplitRoot(t, ws, entity.SplitVertical)
241+ if string(ws.ActivePaneID) != "b" {
242+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
243+ }
244+
245+ active := ws.ActivePane()
246+ mustLeafPaneID(t, active, "b")
247+
248+ // Second press (on bottom pane): vertical -> stack (return to original).
249+ mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelLeft, "consumed")
250+ if ws.Root == nil || !ws.Root.IsStacked {
251+ t.Fatalf("root should be a stack")
252+ }
253+ if got := panesInOrder(ws.Root); got != "a,b" {
254+ t.Fatalf("stack panes=%s, want a,b", got)
255+ }
256+ if string(ws.ActivePaneID) != "b" {
257+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
258+ }
259+}
260+
261+func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) {
262+ uc := NewManagePanesUseCase(func() string { return "id" })
263+ ctx := context.Background()
264+
265+ stackNode := stack(leaf("a"), leaf("b"), leaf("c"))
266+ b := stackNode.Children[1]
267+ ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID}
268+
269+ res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight)
270+ if err != nil {
271+ t.Fatalf("unexpected error: %v", err)
272+ }
273+ if res == nil || res.Action != "expelled" {
274+ t.Fatalf("result action=%v, want expelled", res)
275+ }
276+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
277+ t.Fatalf("root should be a vertical split")
278+ }
279+ top := ws.Root.Left()
280+ bottom := ws.Root.Right()
281+ if top == nil || !top.IsStacked {
282+ t.Fatalf("top child should be stack")
283+ }
284+ if panesInOrder(top) != "a,c" {
285+ t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(top))
286+ }
287+ if bottom == nil || !bottom.IsLeaf() || bottom.Pane.ID != "b" {
288+ t.Fatalf("bottom child should be expelled leaf b")
289+ }
290+ if ws.ActivePaneID != "b" {
291+ t.Fatalf("active=%s, want b", ws.ActivePaneID)
292+ }
293+}
294+
295+func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *testing.T) {
296+ uc := NewManagePanesUseCase(func() string { return "id" })
297+ ctx := context.Background()
298+
299+ left := leaf("left")
300+ right := leaf("right")
301+ root := split(entity.SplitHorizontal, left, right)
302+ ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID}
303+
304+ res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelLeft)
305+ if err != nil {
306+ t.Fatalf("unexpected error: %v", err)
307+ }
308+ if res == nil || res.Action != "none" {
309+ t.Fatalf("result action=%v, want none", res)
310+ }
311+ if res.ErrorMessage == "" {
312+ t.Fatalf("expected warning message")
313+ }
314+ if ws.Root != root {
315+ t.Fatalf("tree should be unchanged")
316+ }
317+}
318+
319+func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSiblingOnly(t *testing.T) {
320+ uc := NewManagePanesUseCase(func() string { return "id" })
321+ ctx := context.Background()
322+
323+ a := leaf("a")
324+ b := leaf("b")
325+ top := split(entity.SplitHorizontal, a, b)
326+ c := leaf("c")
327+ root := split(entity.SplitVertical, top, c)
328+ ws := &entity.Workspace{Root: root, ActivePaneID: a.Pane.ID}
329+
330+ res, err := uc.ConsumeOrExpel(ctx, ws, a, ConsumeOrExpelDown)
331+ if err != nil {
332+ t.Fatalf("unexpected error: %v", err)
333+ }
334+ if res == nil || res.Action != "consumed" {
335+ t.Fatalf("result action=%v, want consumed", res)
336+ }
337+ if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical {
338+ t.Fatalf("root should remain a vertical split")
339+ }
340+ if ws.Root.Right() == nil || !ws.Root.Right().IsStacked {
341+ t.Fatalf("expected bottom pane to become a stack")
342+ }
343+ if panesInOrder(ws.Root.Right()) != "c,a" {
344+ t.Fatalf("bottom stack panes=%s, want c,a", panesInOrder(ws.Root.Right()))
345+ }
346+}
347+
348+func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) {
349+ uc := NewManagePanesUseCase(func() string { return "id" })
350+ ctx := context.Background()
351+
352+ only := leaf("only")
353+ ws := &entity.Workspace{Root: only, ActivePaneID: only.Pane.ID}
354+
355+ res, err := uc.ConsumeOrExpel(ctx, ws, only, ConsumeOrExpelLeft)
356+ if err != nil {
357+ t.Fatalf("unexpected error: %v", err)
358+ }
359+ if res == nil || res.Action != "none" {
360+ t.Fatalf("result action=%v, want none", res)
361+ }
362+ if res.ErrorMessage == "" {
363+ t.Fatalf("expected warning message")
364+ }
365+}
366+
367+func mustConsumeOrExpelAction(
368+ t *testing.T,
369+ uc *ManagePanesUseCase,
370+ ctx context.Context,
371+ ws *entity.Workspace,
372+ node *entity.PaneNode,
373+ direction ConsumeOrExpelDirection,
374+ wantAction string,
375+) {
376+ t.Helper()
377+ res, err := uc.ConsumeOrExpel(ctx, ws, node, direction)
378+ if err != nil {
379+ t.Fatalf("unexpected error: %v", err)
380+ }
381+ if res == nil || res.Action != wantAction {
382+ t.Fatalf("result action=%v, want %s", res, wantAction)
383+ }
384+}
385+
386+func mustSplitRoot(t *testing.T, ws *entity.Workspace, dir entity.SplitDirection) *entity.PaneNode {
387+ t.Helper()
388+ if ws == nil || ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != dir {
389+ t.Fatalf("root should be a %v split", dir)
390+ }
391+ return ws.Root
392+}
393+
394+func mustLeafPaneID(t *testing.T, node *entity.PaneNode, want string) {
395+ t.Helper()
396+ if node == nil || !node.IsLeaf() || node.Pane == nil || string(node.Pane.ID) != want {
397+ t.Fatalf("leaf pane=%v, want %s", node, want)
398+ }
399+}
400+
401+func leaf(id string) *entity.PaneNode {
402+ return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}}
403+}
404+
405+func split(dir entity.SplitDirection, left, right *entity.PaneNode) *entity.PaneNode {
406+ root := &entity.PaneNode{ID: "split", SplitDir: dir, SplitRatio: 0.5, Children: []*entity.PaneNode{left, right}}
407+ left.Parent = root
408+ right.Parent = root
409+ return root
410+}
411+
412+func stack(panes ...*entity.PaneNode) *entity.PaneNode {
413+ n := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0}
414+ children := make([]*entity.PaneNode, 0, len(panes))
415+ for _, p := range panes {
416+ p.Parent = n
417+ children = append(children, p)
418+ }
419+ n.Children = children
420+ return n
421+}
422+
423+func panesInOrder(stackNode *entity.PaneNode) string {
424+ if stackNode == nil || len(stackNode.Children) == 0 {
425+ return ""
426+ }
427+ out := ""
428+ for i, child := range stackNode.Children {
429+ if child == nil || child.Pane == nil {
430+ continue
431+ }
432+ if i > 0 {
433+ out += ","
434+ }
435+ out += string(child.Pane.ID)
436+ }
437+ return out
438+}
A internal/application/usecase/manage_panes_resize_test.go +163 -0
@@ -0,0 +1,163 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func TestManagePanesUseCase_Resize_Errors(t *testing.T) {
12+ uc := NewManagePanesUseCase(func() string { return "id" })
13+
14+ ctx := context.Background()
15+
16+ err := uc.Resize(ctx, nil, nil, ResizeIncreaseDown, 5, 10)
17+ if err == nil {
18+ t.Fatalf("expected error when workspace is nil")
19+ }
20+
21+ ws := &entity.Workspace{}
22+ err = uc.Resize(ctx, ws, nil, ResizeIncreaseDown, 5, 10)
23+ if err == nil {
24+ t.Fatalf("expected error when pane node is nil")
25+ }
26+
27+ // Root nil should return ErrNothingToResize.
28+ leaf := &entity.PaneNode{ID: "p1", Pane: &entity.Pane{ID: "p1"}}
29+ err = uc.Resize(ctx, ws, leaf, ResizeIncreaseDown, 5, 10)
30+ if !errors.Is(err, ErrNothingToResize) {
31+ t.Fatalf("expected ErrNothingToResize, got %v", err)
32+ }
33+}
34+
35+func TestManagePanesUseCase_Resize_VerticalDividerMove(t *testing.T) {
36+ uc := NewManagePanesUseCase(func() string { return "id" })
37+ ctx := context.Background()
38+
39+ top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}}
40+ bottom := &entity.PaneNode{ID: "bottom", Pane: &entity.Pane{ID: "bottom"}}
41+ root := &entity.PaneNode{
42+ ID: "split",
43+ SplitDir: entity.SplitVertical,
44+ SplitRatio: 0.5,
45+ Children: []*entity.PaneNode{top, bottom},
46+ }
47+ top.Parent = root
48+ bottom.Parent = root
49+
50+ ws := &entity.Workspace{Root: root, ActivePaneID: "bottom"}
51+
52+ // Moving divider down increases the first child's ratio.
53+ if err := uc.Resize(ctx, ws, bottom, ResizeIncreaseDown, 5.0, 10.0); err != nil {
54+ t.Fatalf("unexpected error: %v", err)
55+ }
56+ if got, want := root.SplitRatio, 0.55; got != want {
57+ t.Fatalf("split ratio = %v, want %v", got, want)
58+ }
59+
60+ // Moving divider up decreases the first child's ratio.
61+ if err := uc.Resize(ctx, ws, bottom, ResizeIncreaseUp, 5.0, 10.0); err != nil {
62+ t.Fatalf("unexpected error: %v", err)
63+ }
64+ if got, want := root.SplitRatio, 0.5; got != want {
65+ t.Fatalf("split ratio = %v, want %v", got, want)
66+ }
67+}
68+
69+func TestManagePanesUseCase_Resize_SmartResizeGrowsAndShrinksActivePane(t *testing.T) {
70+ uc := NewManagePanesUseCase(func() string { return "id" })
71+ ctx := context.Background()
72+
73+ top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}}
74+ bottom := &entity.PaneNode{ID: "bottom", Pane: &entity.Pane{ID: "bottom"}}
75+ root := &entity.PaneNode{
76+ ID: "split",
77+ SplitDir: entity.SplitVertical,
78+ SplitRatio: 0.5,
79+ Children: []*entity.PaneNode{top, bottom},
80+ }
81+ top.Parent = root
82+ bottom.Parent = root
83+
84+ ws := &entity.Workspace{Root: root, ActivePaneID: "bottom"}
85+
86+ // Smart increase should grow the active (bottom) pane: decrease SplitRatio.
87+ if err := uc.Resize(ctx, ws, bottom, ResizeIncrease, 5.0, 10.0); err != nil {
88+ t.Fatalf("unexpected error: %v", err)
89+ }
90+ if got, want := root.SplitRatio, 0.45; got != want {
91+ t.Fatalf("split ratio = %v, want %v", got, want)
92+ }
93+
94+ // Smart decrease should shrink the active pane: increase SplitRatio.
95+ if err := uc.Resize(ctx, ws, bottom, ResizeDecrease, 5.0, 10.0); err != nil {
96+ t.Fatalf("unexpected error: %v", err)
97+ }
98+ if got, want := root.SplitRatio, 0.5; got != want {
99+ t.Fatalf("split ratio = %v, want %v", got, want)
100+ }
101+}
102+
103+func TestManagePanesUseCase_Resize_ClampsToMinPanePercent(t *testing.T) {
104+ uc := NewManagePanesUseCase(func() string { return "id" })
105+ ctx := context.Background()
106+
107+ left := &entity.PaneNode{ID: "left", Pane: &entity.Pane{ID: "left"}}
108+ right := &entity.PaneNode{ID: "right", Pane: &entity.Pane{ID: "right"}}
109+ root := &entity.PaneNode{
110+ ID: "split",
111+ SplitDir: entity.SplitHorizontal,
112+ SplitRatio: 0.9,
113+ Children: []*entity.PaneNode{left, right},
114+ }
115+ left.Parent = root
116+ right.Parent = root
117+
118+ ws := &entity.Workspace{Root: root, ActivePaneID: "left"}
119+
120+ // maxRatio = 1 - (minPanePercent/100) = 0.9
121+ if err := uc.Resize(ctx, ws, left, ResizeIncreaseRight, 5.0, 10.0); err != nil {
122+ t.Fatalf("unexpected error: %v", err)
123+ }
124+ if got, want := root.SplitRatio, 0.9; got != want {
125+ t.Fatalf("split ratio = %v, want %v", got, want)
126+ }
127+}
128+
129+func TestManagePanesUseCase_Resize_TargetsStackContainer(t *testing.T) {
130+ uc := NewManagePanesUseCase(func() string { return "id" })
131+ ctx := context.Background()
132+
133+ stackLeaf1 := &entity.PaneNode{ID: "s1", Pane: &entity.Pane{ID: "s1"}}
134+ stackLeaf2 := &entity.PaneNode{ID: "s2", Pane: &entity.Pane{ID: "s2"}}
135+ stack := &entity.PaneNode{
136+ ID: "stack",
137+ IsStacked: true,
138+ ActiveStackIndex: 0,
139+ Children: []*entity.PaneNode{stackLeaf1, stackLeaf2},
140+ }
141+ stackLeaf1.Parent = stack
142+ stackLeaf2.Parent = stack
143+
144+ right := &entity.PaneNode{ID: "right", Pane: &entity.Pane{ID: "right"}}
145+ root := &entity.PaneNode{
146+ ID: "split",
147+ SplitDir: entity.SplitHorizontal,
148+ SplitRatio: 0.5,
149+ Children: []*entity.PaneNode{stack, right},
150+ }
151+ stack.Parent = root
152+ right.Parent = root
153+
154+ ws := &entity.Workspace{Root: root, ActivePaneID: "s1"}
155+
156+ // Pass the leaf inside the stack; Resize should act on the split above the stack.
157+ if err := uc.Resize(ctx, ws, stackLeaf1, ResizeIncreaseRight, 5.0, 10.0); err != nil {
158+ t.Fatalf("unexpected error: %v", err)
159+ }
160+ if got, want := root.SplitRatio, 0.55; got != want {
161+ t.Fatalf("split ratio = %v, want %v", got, want)
162+ }
163+}
M internal/application/usecase/manage_zoom.go +45 -2
@@ -13,20 +13,28 @@ import (
13 )13 )
14 14
15 // ManageZoomUseCase handles per-domain zoom level operations.15 // ManageZoomUseCase handles per-domain zoom level operations.
16+// It uses an LRU cache to avoid database queries on every navigation.
16 type ManageZoomUseCase struct {17 type ManageZoomUseCase struct {
17 zoomRepo repository.ZoomRepository18 zoomRepo repository.ZoomRepository
18 defaultZoom float6419 defaultZoom float64
20+ cache port.Cache[string, *entity.ZoomLevel]
19 }21 }
20 22
21 // NewManageZoomUseCase creates a new zoom management use case.23 // NewManageZoomUseCase creates a new zoom management use case.
22 // defaultZoom is the zoom level to use when resetting (typically from config).24 // defaultZoom is the zoom level to use when resetting (typically from config).
23-func NewManageZoomUseCase(zoomRepo repository.ZoomRepository, defaultZoom float64) *ManageZoomUseCase {25+// cache is an LRU cache for zoom levels to avoid database queries on repeat visits.
26+func NewManageZoomUseCase(
27+ zoomRepo repository.ZoomRepository,
28+ defaultZoom float64,
29+ cache port.Cache[string, *entity.ZoomLevel],
30+) *ManageZoomUseCase {
24 if defaultZoom <= 0 {31 if defaultZoom <= 0 {
25 defaultZoom = entity.ZoomDefault32 defaultZoom = entity.ZoomDefault
26 }33 }
27 return &ManageZoomUseCase{34 return &ManageZoomUseCase{
28 zoomRepo: zoomRepo,35 zoomRepo: zoomRepo,
29 defaultZoom: defaultZoom,36 defaultZoom: defaultZoom,
37+ cache: cache,
30 }38 }
31 }39 }
32 40
@@ -37,10 +45,20 @@ func (uc *ManageZoomUseCase) DefaultZoom() float64 {
37 45
38 // GetZoom retrieves the zoom level for a domain.46 // GetZoom retrieves the zoom level for a domain.
39 // Returns the configured default zoom level if none is set.47 // Returns the configured default zoom level if none is set.
48+// Uses LRU cache to avoid database queries on repeat visits.
40 func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entity.ZoomLevel, error) {49 func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
41 log := logging.FromContext(ctx)50 log := logging.FromContext(ctx)
42- log.Debug().Str("domain", domain).Msg("getting zoom level")
43 51
52+ // Check cache first (fast path - no I/O)
53+ if uc.cache != nil {
54+ if cached, ok := uc.cache.Get(domain); ok {
55+ log.Debug().Str("domain", domain).Float64("zoom", cached.ZoomFactor).Msg("zoom level from cache")
56+ return cached, nil
57+ }
58+ }
59+
60+ // Cache miss - fetch from database
61+ log.Debug().Str("domain", domain).Msg("zoom cache miss, querying database")
44 zoom, err := uc.zoomRepo.Get(ctx, domain)62 zoom, err := uc.zoomRepo.Get(ctx, domain)
45 if err != nil {63 if err != nil {
46 return nil, fmt.Errorf("failed to get zoom level: %w", err)64 return nil, fmt.Errorf("failed to get zoom level: %w", err)
@@ -51,6 +69,11 @@ func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entit
51 log.Debug().Str("domain", domain).Float64("zoom", zoom.ZoomFactor).Msg("using default zoom")69 log.Debug().Str("domain", domain).Float64("zoom", zoom.ZoomFactor).Msg("using default zoom")
52 }70 }
53 71
72+ // Cache for future lookups
73+ if uc.cache != nil {
74+ uc.cache.Set(domain, zoom)
75+ }
76+
54 return zoom, nil77 return zoom, nil
55 }78 }
56 79
@@ -64,6 +87,11 @@ func (uc *ManageZoomUseCase) SetZoom(ctx context.Context, domain string, factor
64 return fmt.Errorf("failed to set zoom level: %w", err)87 return fmt.Errorf("failed to set zoom level: %w", err)
65 }88 }
66 89
90+ // Update cache
91+ if uc.cache != nil {
92+ uc.cache.Set(domain, zoom)
93+ }
94+
67 log.Info().Str("domain", domain).Float64("factor", zoom.ZoomFactor).Msg("zoom level saved")95 log.Info().Str("domain", domain).Float64("factor", zoom.ZoomFactor).Msg("zoom level saved")
68 return nil96 return nil
69 }97 }
@@ -77,6 +105,11 @@ func (uc *ManageZoomUseCase) ResetZoom(ctx context.Context, domain string) error
77 return fmt.Errorf("failed to reset zoom level: %w", err)105 return fmt.Errorf("failed to reset zoom level: %w", err)
78 }106 }
79 107
108+ // Invalidate cache
109+ if uc.cache != nil {
110+ uc.cache.Remove(domain)
111+ }
112+
80 log.Info().Str("domain", domain).Msg("zoom level reset to default")113 log.Info().Str("domain", domain).Msg("zoom level reset to default")
81 return nil114 return nil
82 }115 }
@@ -98,6 +131,11 @@ func (uc *ManageZoomUseCase) ZoomIn(ctx context.Context, domain string, current
98 return nil, fmt.Errorf("failed to save zoom level: %w", err)131 return nil, fmt.Errorf("failed to save zoom level: %w", err)
99 }132 }
100 133
134+ // Update cache
135+ if uc.cache != nil {
136+ uc.cache.Set(domain, zoom)
137+ }
138+
101 return zoom, nil139 return zoom, nil
102 }140 }
103 141
@@ -118,6 +156,11 @@ func (uc *ManageZoomUseCase) ZoomOut(ctx context.Context, domain string, current
118 return nil, fmt.Errorf("failed to save zoom level: %w", err)156 return nil, fmt.Errorf("failed to save zoom level: %w", err)
119 }157 }
120 158
159+ // Update cache
160+ if uc.cache != nil {
161+ uc.cache.Set(domain, zoom)
162+ }
163+
121 return zoom, nil164 return zoom, nil
122 }165 }
123 166
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+}
M internal/application/usecase/navigate.go +92 -11
@@ -13,12 +13,30 @@ import (
13 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
14 )14 )
15 15
16+const (
17+ // historyQueueSize is the buffer size for the async history queue.
18+ // If the queue is full, new records are dropped with a warning.
19+ historyQueueSize = 100
20+)
21+
22+// historyRecord holds data for async history recording.
23+type historyRecord struct {
24+ url string
25+ timestamp time.Time
26+}
27+
16 // NavigateUseCase handles URL navigation with history recording and zoom application.28 // NavigateUseCase handles URL navigation with history recording and zoom application.
17 type NavigateUseCase struct {29 type NavigateUseCase struct {
18 historyRepo repository.HistoryRepository30 historyRepo repository.HistoryRepository
19 zoomRepo repository.ZoomRepository31 zoomRepo repository.ZoomRepository
20 defaultZoom float6432 defaultZoom float64
21 recentVisits sync.Map // key: url, value: time.Time - for deduplication33 recentVisits sync.Map // key: url, value: time.Time - for deduplication
34+
35+ // Async history recording
36+ historyQueue chan historyRecord
37+ done chan struct{}
38+ wg sync.WaitGroup
39+ ctx context.Context // Base context for background worker
22 }40 }
23 41
24 // NewNavigateUseCase creates a new navigation use case.42 // NewNavigateUseCase creates a new navigation use case.
@@ -31,11 +49,28 @@ func NewNavigateUseCase(
31 if defaultZoom <= 0 {49 if defaultZoom <= 0 {
32 defaultZoom = entity.ZoomDefault50 defaultZoom = entity.ZoomDefault
33 }51 }
34- return &NavigateUseCase{52+
35- historyRepo: historyRepo,53+ uc := &NavigateUseCase{
36- zoomRepo: zoomRepo,54+ historyRepo: historyRepo,
37- defaultZoom: defaultZoom,55+ zoomRepo: zoomRepo,
56+ defaultZoom: defaultZoom,
57+ historyQueue: make(chan historyRecord, historyQueueSize),
58+ done: make(chan struct{}),
59+ ctx: context.Background(),
38 }60 }
61+
62+ // Start background history worker
63+ uc.wg.Add(1)
64+ go uc.historyWorker()
65+
66+ return uc
67+}
68+
69+// Close shuts down the background history worker and drains any pending records.
70+// This should be called when the application is shutting down.
71+func (uc *NavigateUseCase) Close() {
72+ close(uc.done)
73+ uc.wg.Wait()
39 }74 }
40 75
41 // NavigateInput contains parameters for navigation.76 // NavigateInput contains parameters for navigation.
@@ -79,7 +114,8 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
79 // This prevents inflation from redirects and rapid navigation.114 // This prevents inflation from redirects and rapid navigation.
80 const historyDeduplicationWindow = 2 * time.Second115 const historyDeduplicationWindow = 2 * time.Second
81 116
82-// RecordHistory saves or updates the history entry.117+// RecordHistory queues a history entry for async recording.
118+// This is non-blocking to avoid SQLite I/O on the GTK main thread.
83 // Should be called on LoadCommitted when URI is guaranteed correct.119 // Should be called on LoadCommitted when URI is guaranteed correct.
84 func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {120 func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
85 log := logging.FromContext(ctx)121 log := logging.FromContext(ctx)
@@ -88,6 +124,7 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
88 url = normalizeURLForHistory(url)124 url = normalizeURLForHistory(url)
89 125
90 // Time-based deduplication: skip if same URL was recorded recently126 // Time-based deduplication: skip if same URL was recorded recently
127+ // This check is fast (sync.Map lookup) and stays on the main thread
91 now := time.Now()128 now := time.Now()
92 if lastTime, ok := uc.recentVisits.Load(url); ok {129 if lastTime, ok := uc.recentVisits.Load(url); ok {
93 if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {130 if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {
@@ -97,23 +134,67 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
97 }134 }
98 uc.recentVisits.Store(url, now)135 uc.recentVisits.Store(url, now)
99 136
137+ // Non-blocking send to async queue
138+ select {
139+ case uc.historyQueue <- historyRecord{url: url, timestamp: now}:
140+ log.Debug().Str("url", url).Msg("history record queued")
141+ default:
142+ // Queue full - log warning but don't block navigation
143+ log.Warn().Str("url", url).Msg("history queue full, dropping record")
144+ }
145+}
146+
147+// historyWorker is a background goroutine that processes history records.
148+// It drains the queue and persists records to the database without blocking the UI.
149+func (uc *NavigateUseCase) historyWorker() {
150+ defer uc.wg.Done()
151+
152+ log := logging.FromContext(uc.ctx).With().
153+ Str("component", "history-worker").
154+ Logger()
155+
156+ for {
157+ select {
158+ case record := <-uc.historyQueue:
159+ uc.persistHistory(uc.ctx, record)
160+ case <-uc.done:
161+ // Drain remaining records before shutdown
162+ log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
163+ for {
164+ select {
165+ case record := <-uc.historyQueue:
166+ uc.persistHistory(uc.ctx, record)
167+ default:
168+ log.Debug().Msg("history worker shutdown complete")
169+ return
170+ }
171+ }
172+ }
173+ }
174+}
175+
176+// persistHistory writes a history record to the database.
177+// Called from the background worker goroutine.
178+func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRecord) {
179+ log := logging.FromContext(ctx)
180+
100 // Check if entry exists181 // Check if entry exists
101- existing, err := uc.historyRepo.FindByURL(ctx, url)182+ existing, err := uc.historyRepo.FindByURL(ctx, record.url)
102 if err != nil {183 if err != nil {
103- log.Warn().Err(err).Str("url", url).Msg("failed to check history")184+ log.Warn().Err(err).Str("url", record.url).Msg("failed to check history")
104 return185 return
105 }186 }
106 187
107 if existing != nil {188 if existing != nil {
108 // Increment visit count189 // Increment visit count
109- if err := uc.historyRepo.IncrementVisitCount(ctx, url); err != nil {190+ if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
110- log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")191+ log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
111 }192 }
112 } else {193 } else {
113 // Create new entry194 // Create new entry
114- entry := entity.NewHistoryEntry(url, "")195+ entry := entity.NewHistoryEntry(record.url, "")
115 if err := uc.historyRepo.Save(ctx, entry); err != nil {196 if err := uc.historyRepo.Save(ctx, entry); err != nil {
116- log.Warn().Err(err).Str("url", url).Msg("failed to save history")197+ log.Warn().Err(err).Str("url", record.url).Msg("failed to save history")
117 }198 }
118 }199 }
119 }200 }
A internal/application/usecase/restore_session.go +87 -0
@@ -0,0 +1,87 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/repository"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// ErrSessionNotFound is returned when a session state cannot be found.
14+var ErrSessionNotFound = errors.New("session not found")
15+
16+// ErrVersionMismatch is returned when the session state version is incompatible.
17+var ErrVersionMismatch = errors.New("session state version mismatch")
18+
19+// RestoreSessionUseCase handles restoring session state from a snapshot.
20+type RestoreSessionUseCase struct {
21+ stateRepo repository.SessionStateRepository
22+ sessionRepo repository.SessionRepository
23+}
24+
25+// NewRestoreSessionUseCase creates a new RestoreSessionUseCase.
26+func NewRestoreSessionUseCase(
27+ stateRepo repository.SessionStateRepository,
28+ sessionRepo repository.SessionRepository,
29+) *RestoreSessionUseCase {
30+ return &RestoreSessionUseCase{
31+ stateRepo: stateRepo,
32+ sessionRepo: sessionRepo,
33+ }
34+}
35+
36+// RestoreInput contains the parameters for restoring a session.
37+type RestoreInput struct {
38+ SessionID entity.SessionID
39+}
40+
41+// RestoreOutput contains the restored session state.
42+type RestoreOutput struct {
43+ State *entity.SessionState
44+}
45+
46+// Execute loads and validates a session state for restoration.
47+func (uc *RestoreSessionUseCase) Execute(ctx context.Context, input RestoreInput) (*RestoreOutput, error) {
48+ log := logging.FromContext(ctx)
49+
50+ if input.SessionID == "" {
51+ return nil, fmt.Errorf("session id required")
52+ }
53+
54+ log.Info().
55+ Str("session_id", string(input.SessionID)).
56+ Msg("restoring session state")
57+
58+ state, err := uc.stateRepo.GetSnapshot(ctx, input.SessionID)
59+ if err != nil {
60+ return nil, fmt.Errorf("get session snapshot: %w", err)
61+ }
62+ if state == nil {
63+ return nil, ErrSessionNotFound
64+ }
65+
66+ // Validate version compatibility
67+ if state.Version > entity.SessionStateVersion {
68+ log.Warn().
69+ Int("state_version", state.Version).
70+ Int("current_version", entity.SessionStateVersion).
71+ Msg("session state version is newer than current version")
72+ return nil, ErrVersionMismatch
73+ }
74+
75+ log.Info().
76+ Str("session_id", string(input.SessionID)).
77+ Int("tab_count", len(state.Tabs)).
78+ Int("pane_count", state.CountPanes()).
79+ Msg("session state loaded for restoration")
80+
81+ return &RestoreOutput{State: state}, nil
82+}
83+
84+// DeleteSnapshot removes a session's snapshot (for cleanup after failed restore or user deletion).
85+func (uc *RestoreSessionUseCase) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
86+ return uc.stateRepo.DeleteSnapshot(ctx, sessionID)
87+}
A internal/application/usecase/restore_session_test.go +140 -0
@@ -0,0 +1,140 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestRestoreSessionUseCase_Execute_ReturnsState(t *testing.T) {
16+ ctx := testContext()
17+
18+ stateRepo := repomocks.NewMockSessionStateRepository(t)
19+ sessionRepo := repomocks.NewMockSessionRepository(t)
20+
21+ sessionID := entity.SessionID("20251224_120000_restore")
22+
23+ expectedState := &entity.SessionState{
24+ Version: entity.SessionStateVersion,
25+ SessionID: sessionID,
26+ Tabs: []entity.TabSnapshot{
27+ {
28+ ID: "tab-1",
29+ Name: "Test Tab",
30+ Workspace: entity.WorkspaceSnapshot{
31+ Root: &entity.PaneNodeSnapshot{
32+ Pane: &entity.PaneSnapshot{
33+ ID: "pane-1",
34+ URI: "https://example.com",
35+ Title: "Example",
36+ },
37+ },
38+ },
39+ },
40+ },
41+ SavedAt: time.Now(),
42+ }
43+
44+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(expectedState, nil)
45+
46+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
47+
48+ output, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
49+ require.NoError(t, err)
50+ require.NotNil(t, output)
51+ assert.Equal(t, expectedState, output.State)
52+}
53+
54+func TestRestoreSessionUseCase_Execute_RequiresSessionID(t *testing.T) {
55+ ctx := testContext()
56+
57+ stateRepo := repomocks.NewMockSessionStateRepository(t)
58+ sessionRepo := repomocks.NewMockSessionRepository(t)
59+
60+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
61+
62+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: ""})
63+ require.Error(t, err)
64+ assert.Contains(t, err.Error(), "session id required")
65+}
66+
67+func TestRestoreSessionUseCase_Execute_SessionNotFound(t *testing.T) {
68+ ctx := testContext()
69+
70+ stateRepo := repomocks.NewMockSessionStateRepository(t)
71+ sessionRepo := repomocks.NewMockSessionRepository(t)
72+
73+ sessionID := entity.SessionID("20251224_120000_notfound")
74+
75+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil)
76+
77+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
78+
79+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
80+ require.Error(t, err)
81+ assert.ErrorIs(t, err, usecase.ErrSessionNotFound)
82+}
83+
84+func TestRestoreSessionUseCase_Execute_VersionMismatch(t *testing.T) {
85+ ctx := testContext()
86+
87+ stateRepo := repomocks.NewMockSessionStateRepository(t)
88+ sessionRepo := repomocks.NewMockSessionRepository(t)
89+
90+ sessionID := entity.SessionID("20251224_120000_version")
91+
92+ // Return a state with a future version
93+ futureState := &entity.SessionState{
94+ Version: entity.SessionStateVersion + 1,
95+ SessionID: sessionID,
96+ Tabs: []entity.TabSnapshot{},
97+ SavedAt: time.Now(),
98+ }
99+
100+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(futureState, nil)
101+
102+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
103+
104+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
105+ require.Error(t, err)
106+ assert.ErrorIs(t, err, usecase.ErrVersionMismatch)
107+}
108+
109+func TestRestoreSessionUseCase_Execute_GetSnapshotError(t *testing.T) {
110+ ctx := testContext()
111+
112+ stateRepo := repomocks.NewMockSessionStateRepository(t)
113+ sessionRepo := repomocks.NewMockSessionRepository(t)
114+
115+ sessionID := entity.SessionID("20251224_120000_error")
116+
117+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, errors.New("db error"))
118+
119+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
120+
121+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
122+ require.Error(t, err)
123+ assert.Contains(t, err.Error(), "get session snapshot")
124+}
125+
126+func TestRestoreSessionUseCase_DeleteSnapshot(t *testing.T) {
127+ ctx := testContext()
128+
129+ stateRepo := repomocks.NewMockSessionStateRepository(t)
130+ sessionRepo := repomocks.NewMockSessionRepository(t)
131+
132+ sessionID := entity.SessionID("20251224_120000_delete")
133+
134+ stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
135+
136+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
137+
138+ err := uc.DeleteSnapshot(ctx, sessionID)
139+ require.NoError(t, err)
140+}
M internal/application/usecase/search_history.go +50 -0
@@ -75,6 +75,56 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
75 return entries, nil75 return entries, nil
76 }76 }
77 77
78+// GetRecentSince retrieves history entries visited within the last N days.
79+// If days is 0, returns all history entries.
80+// If days is negative, defaults to 30 days.
81+func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
82+ if days < 0 {
83+ days = 30 // Default to 30 days for negative values
84+ }
85+
86+ var entries []*entity.HistoryEntry
87+ var err error
88+
89+ switch days {
90+ case 0:
91+ entries, err = uc.historyRepo.GetAllRecentHistory(ctx)
92+ default:
93+ entries, err = uc.historyRepo.GetRecentSince(ctx, days)
94+ }
95+
96+ if err != nil {
97+ return nil, fmt.Errorf("failed to get recent history: %w", err)
98+ }
99+
100+ return entries, nil
101+}
102+
103+// GetMostVisited retrieves history entries sorted by visit count within the last N days.
104+// If days is 0, returns all history entries sorted by visit count.
105+// If days is negative, defaults to 30 days.
106+func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
107+ if days < 0 {
108+ days = 30 // Default to 30 days for negative values
109+ }
110+
111+ var entries []*entity.HistoryEntry
112+ var err error
113+
114+ switch days {
115+ case 0:
116+ entries, err = uc.historyRepo.GetAllMostVisited(ctx)
117+ default:
118+ entries, err = uc.historyRepo.GetMostVisited(ctx, days)
119+ }
120+
121+ if err != nil {
122+ return nil, fmt.Errorf("failed to get most visited history: %w", err)
123+ }
124+
125+ return entries, nil
126+}
127+
78 // FindByURL retrieves a history entry by its URL.128 // FindByURL retrieves a history entry by its URL.
79 func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {129 func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
80 log := logging.FromContext(ctx)130 log := logging.FromContext(ctx)
A internal/application/usecase/search_history_test.go +153 -0
@@ -0,0 +1,153 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
17+ ctx := testContext()
18+
19+ historyRepo := repomocks.NewMockHistoryRepository(t)
20+
21+ entries := []*entity.HistoryEntry{
22+ {ID: 1, URL: "https://example.com", Title: "Example", LastVisited: time.Now()},
23+ {ID: 2, URL: "https://github.com", Title: "GitHub", LastVisited: time.Now().Add(-24 * time.Hour)},
24+ }
25+
26+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
27+
28+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
29+
30+ result, err := uc.GetRecentSince(ctx, 30)
31+ require.NoError(t, err)
32+ require.Len(t, result, 2)
33+ assert.Equal(t, "https://example.com", result[0].URL)
34+ assert.Equal(t, "https://github.com", result[1].URL)
35+}
36+
37+func TestSearchHistoryUseCase_GetRecentSince_ZeroMeansAll(t *testing.T) {
38+ ctx := testContext()
39+
40+ historyRepo := repomocks.NewMockHistoryRepository(t)
41+
42+ entries := []*entity.HistoryEntry{
43+ {ID: 1, URL: "https://example.com", Title: "Example"},
44+ }
45+
46+ // When days == 0, should call GetAllRecentHistory
47+ historyRepo.EXPECT().GetAllRecentHistory(mock.Anything).Return(entries, nil)
48+
49+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
50+
51+ result, err := uc.GetRecentSince(ctx, 0)
52+ require.NoError(t, err)
53+ require.Len(t, result, 1)
54+}
55+
56+func TestSearchHistoryUseCase_GetRecentSince_NegativeDaysDefaultsTo30(t *testing.T) {
57+ ctx := testContext()
58+
59+ historyRepo := repomocks.NewMockHistoryRepository(t)
60+
61+ entries := []*entity.HistoryEntry{}
62+
63+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
64+
65+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
66+
67+ result, err := uc.GetRecentSince(ctx, -5)
68+ require.NoError(t, err)
69+ require.Empty(t, result)
70+}
71+
72+func TestSearchHistoryUseCase_GetRecentSince_ReturnsErrorOnRepoFailure(t *testing.T) {
73+ ctx := testContext()
74+
75+ historyRepo := repomocks.NewMockHistoryRepository(t)
76+
77+ historyRepo.EXPECT().GetRecentSince(mock.Anything, 7).Return(nil, errors.New("db connection failed"))
78+
79+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
80+
81+ result, err := uc.GetRecentSince(ctx, 7)
82+ require.Error(t, err)
83+ assert.Nil(t, result)
84+ assert.Contains(t, err.Error(), "failed to get recent history")
85+}
86+
87+func TestSearchHistoryUseCase_GetMostVisited_ReturnsEntries(t *testing.T) {
88+ ctx := testContext()
89+
90+ historyRepo := repomocks.NewMockHistoryRepository(t)
91+
92+ entries := []*entity.HistoryEntry{
93+ {ID: 1, URL: "https://github.com", Title: "GitHub", VisitCount: 100},
94+ {ID: 2, URL: "https://example.com", Title: "Example", VisitCount: 50},
95+ }
96+
97+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil)
98+
99+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
100+
101+ result, err := uc.GetMostVisited(ctx, 30)
102+ require.NoError(t, err)
103+ require.Len(t, result, 2)
104+ assert.Equal(t, "https://github.com", result[0].URL)
105+ assert.Equal(t, int64(100), result[0].VisitCount)
106+}
107+
108+func TestSearchHistoryUseCase_GetMostVisited_ZeroMeansAll(t *testing.T) {
109+ ctx := testContext()
110+
111+ historyRepo := repomocks.NewMockHistoryRepository(t)
112+
113+ entries := []*entity.HistoryEntry{}
114+
115+ // When days == 0, should call GetAllMostVisited
116+ historyRepo.EXPECT().GetAllMostVisited(mock.Anything).Return(entries, nil)
117+
118+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
119+
120+ result, err := uc.GetMostVisited(ctx, 0)
121+ require.NoError(t, err)
122+ require.Empty(t, result)
123+}
124+
125+func TestSearchHistoryUseCase_GetMostVisited_ReturnsErrorOnRepoFailure(t *testing.T) {
126+ ctx := testContext()
127+
128+ historyRepo := repomocks.NewMockHistoryRepository(t)
129+
130+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 14).Return(nil, errors.New("query failed"))
131+
132+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
133+
134+ result, err := uc.GetMostVisited(ctx, 14)
135+ require.Error(t, err)
136+ assert.Nil(t, result)
137+ assert.Contains(t, err.Error(), "failed to get most visited history")
138+}
139+
140+func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testing.T) {
141+ ctx := testContext()
142+
143+ historyRepo := repomocks.NewMockHistoryRepository(t)
144+
145+ historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return([]*entity.HistoryEntry{}, nil)
146+
147+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
148+
149+ result, err := uc.GetMostVisited(ctx, 30)
150+ require.NoError(t, err)
151+ require.NotNil(t, result)
152+ assert.Empty(t, result)
153+}
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/application/usecase/session_lock.go +21 -0
@@ -0,0 +1,21 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+// isSessionLocked checks if a session has an active lock file.
12+// Lock files are created in lockDir with format: session_<sessionID>.lock
13+// This matches the format used by bootstrap.sessionLockPath().
14+func isSessionLocked(lockDir string, sessionID entity.SessionID) bool {
15+ if lockDir == "" {
16+ return false
17+ }
18+ lockPath := filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
19+ _, err := os.Stat(lockPath)
20+ return err == nil
21+}
A internal/application/usecase/snapshot_session.go +49 -0
@@ -0,0 +1,49 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// SnapshotSessionUseCase handles saving session state snapshots.
13+type SnapshotSessionUseCase struct {
14+ stateRepo repository.SessionStateRepository
15+}
16+
17+// NewSnapshotSessionUseCase creates a new SnapshotSessionUseCase.
18+func NewSnapshotSessionUseCase(stateRepo repository.SessionStateRepository) *SnapshotSessionUseCase {
19+ return &SnapshotSessionUseCase{stateRepo: stateRepo}
20+}
21+
22+// SnapshotInput contains the parameters for creating a session snapshot.
23+type SnapshotInput struct {
24+ SessionID entity.SessionID
25+ TabList *entity.TabList
26+}
27+
28+// Execute creates a snapshot of the current session state and saves it.
29+func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error {
30+ log := logging.FromContext(ctx)
31+
32+ if input.SessionID == "" {
33+ return fmt.Errorf("session id required")
34+ }
35+
36+ state := entity.SnapshotFromTabList(input.SessionID, input.TabList)
37+
38+ log.Debug().
39+ Str("session_id", string(input.SessionID)).
40+ Int("tab_count", len(state.Tabs)).
41+ Int("pane_count", state.CountPanes()).
42+ Msg("creating session snapshot")
43+
44+ if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil {
45+ return fmt.Errorf("save session snapshot: %w", err)
46+ }
47+
48+ return nil
49+}
A internal/application/usecase/snapshot_session_test.go +173 -0
@@ -0,0 +1,173 @@
1+package usecase_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestSnapshotSessionUseCase_Execute_SavesSnapshot(t *testing.T) {
17+ ctx := testContext()
18+
19+ stateRepo := repomocks.NewMockSessionStateRepository(t)
20+
21+ // Create a tab list with some tabs
22+ tabList := entity.NewTabList()
23+ pane := entity.NewPane("pane-1")
24+ pane.URI = "https://example.com"
25+ pane.Title = "Example"
26+ tab := entity.NewTab("tab-1", "ws-1", pane)
27+ tab.Name = "Test Tab"
28+ tabList.Add(tab)
29+
30+ sessionID := entity.SessionID("20251224_120000_test")
31+
32+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
33+ Run(func(_ context.Context, state *entity.SessionState) {
34+ require.Equal(t, sessionID, state.SessionID)
35+ require.Len(t, state.Tabs, 1)
36+ require.Equal(t, "Test Tab", state.Tabs[0].Name)
37+ require.Equal(t, entity.SessionStateVersion, state.Version)
38+ }).
39+ Return(nil)
40+
41+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
42+
43+ err := uc.Execute(ctx, usecase.SnapshotInput{
44+ SessionID: sessionID,
45+ TabList: tabList,
46+ })
47+ require.NoError(t, err)
48+}
49+
50+func TestSnapshotSessionUseCase_Execute_EmptyTabList(t *testing.T) {
51+ ctx := testContext()
52+
53+ stateRepo := repomocks.NewMockSessionStateRepository(t)
54+
55+ sessionID := entity.SessionID("20251224_120000_empty")
56+
57+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
58+ Run(func(_ context.Context, state *entity.SessionState) {
59+ require.Equal(t, sessionID, state.SessionID)
60+ require.Empty(t, state.Tabs)
61+ }).
62+ Return(nil)
63+
64+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
65+
66+ err := uc.Execute(ctx, usecase.SnapshotInput{
67+ SessionID: sessionID,
68+ TabList: nil,
69+ })
70+ require.NoError(t, err)
71+}
72+
73+func TestSnapshotSessionUseCase_Execute_RequiresSessionID(t *testing.T) {
74+ ctx := testContext()
75+
76+ stateRepo := repomocks.NewMockSessionStateRepository(t)
77+
78+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
79+
80+ err := uc.Execute(ctx, usecase.SnapshotInput{
81+ SessionID: "",
82+ TabList: entity.NewTabList(),
83+ })
84+ require.Error(t, err)
85+ assert.Contains(t, err.Error(), "session id required")
86+}
87+
88+func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {
89+ sessionID := entity.SessionID("20251224_120000_snap")
90+
91+ // Create a workspace with multiple panes
92+ pane1 := entity.NewPane("pane-1")
93+ pane1.URI = "https://github.com"
94+ pane1.Title = "GitHub"
95+ pane1.ZoomFactor = 1.2
96+
97+ pane2 := entity.NewPane("pane-2")
98+ pane2.URI = "https://google.com"
99+ pane2.Title = "Google"
100+
101+ tab := entity.NewTab("tab-1", "ws-1", pane1)
102+ tab.Name = "Dev Tab"
103+ tab.IsPinned = true
104+
105+ tabList := entity.NewTabList()
106+ tabList.Add(tab)
107+
108+ state := entity.SnapshotFromTabList(sessionID, tabList)
109+
110+ require.NotNil(t, state)
111+ assert.Equal(t, entity.SessionStateVersion, state.Version)
112+ assert.Equal(t, sessionID, state.SessionID)
113+ assert.Len(t, state.Tabs, 1)
114+ assert.Equal(t, "Dev Tab", state.Tabs[0].Name)
115+ assert.True(t, state.Tabs[0].IsPinned)
116+ assert.NotNil(t, state.Tabs[0].Workspace.Root)
117+ assert.Equal(t, "https://github.com", state.Tabs[0].Workspace.Root.Pane.URI)
118+ assert.InDelta(t, 1.2, state.Tabs[0].Workspace.Root.Pane.ZoomFactor, 0.001)
119+ assert.False(t, state.SavedAt.IsZero())
120+}
121+
122+func TestSessionState_CountPanes(t *testing.T) {
123+ state := &entity.SessionState{
124+ Tabs: []entity.TabSnapshot{
125+ {
126+ Workspace: entity.WorkspaceSnapshot{
127+ Root: &entity.PaneNodeSnapshot{
128+ Pane: &entity.PaneSnapshot{ID: "pane-1"},
129+ },
130+ },
131+ },
132+ {
133+ Workspace: entity.WorkspaceSnapshot{
134+ Root: &entity.PaneNodeSnapshot{
135+ Children: []*entity.PaneNodeSnapshot{
136+ {Pane: &entity.PaneSnapshot{ID: "pane-2"}},
137+ {Pane: &entity.PaneSnapshot{ID: "pane-3"}},
138+ },
139+ },
140+ },
141+ },
142+ },
143+ }
144+
145+ assert.Equal(t, 3, state.CountPanes())
146+}
147+
148+func TestGetRelativeTime(t *testing.T) {
149+ now := time.Now()
150+
151+ tests := []struct {
152+ name string
153+ t time.Time
154+ expected string
155+ }{
156+ {"just now", now.Add(-30 * time.Second), "just now"},
157+ {"1 minute", now.Add(-1 * time.Minute), "1m ago"},
158+ {"5 minutes", now.Add(-5 * time.Minute), "05m ago"},
159+ {"1 hour", now.Add(-1 * time.Hour), "1h ago"},
160+ {"3 hours", now.Add(-3 * time.Hour), "03h ago"},
161+ {"1 day", now.Add(-24 * time.Hour), "1d ago"},
162+ {"2 days", now.Add(-48 * time.Hour), "02d ago"},
163+ {"1 week", now.Add(-7 * 24 * time.Hour), "1w ago"},
164+ {"2 weeks", now.Add(-14 * 24 * time.Hour), "02w ago"},
165+ }
166+
167+ for _, tt := range tests {
168+ t.Run(tt.name, func(t *testing.T) {
169+ result := usecase.GetRelativeTime(tt.t)
170+ assert.Equal(t, tt.expected, result)
171+ })
172+ }
173+}
A internal/bootstrap/gui.go +337 -0
@@ -0,0 +1,337 @@
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/port"
14+ "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/infrastructure/colorscheme"
16+ "github.com/bnema/dumber/internal/infrastructure/config"
17+ "github.com/bnema/dumber/internal/infrastructure/deps"
18+ "github.com/bnema/dumber/internal/infrastructure/media"
19+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
20+ "github.com/bnema/dumber/internal/logging"
21+ "github.com/bnema/dumber/internal/ui/theme"
22+ sqlite3 "github.com/ncruces/go-sqlite3"
23+)
24+
25+// DatabaseResult holds database connection and cleanup function.
26+type DatabaseResult struct {
27+ DB *sql.DB
28+ Cleanup func()
29+}
30+
31+// ParallelInitResult holds the results of parallel initialization phase.
32+type ParallelInitResult struct {
33+ DataDir string
34+ CacheDir string
35+ ThemeManager *theme.Manager
36+ ColorResolver port.ColorSchemeResolver
37+ AdwaitaDetector *colorscheme.AdwaitaDetector
38+ Duration time.Duration
39+}
40+
41+// DeferredInitResult holds results from deferred initialization checks.
42+type DeferredInitResult struct {
43+ RuntimeErr error
44+ MediaErr error
45+ SQLiteErr error
46+ Duration time.Duration
47+}
48+
49+// ParallelInitInput holds the input for parallel initialization.
50+type ParallelInitInput struct {
51+ Ctx context.Context
52+ Config *config.Config
53+}
54+
55+// DeferredInitInput holds the input for deferred initialization.
56+type DeferredInitInput struct {
57+ Ctx context.Context
58+ Config *config.Config
59+}
60+
61+// RuntimeRequirementsError contains details about missing runtime dependencies.
62+type RuntimeRequirementsError struct {
63+ Checks []usecase.RuntimeDependencyStatus
64+}
65+
66+func (*RuntimeRequirementsError) Error() string {
67+ return "runtime requirements not met"
68+}
69+
70+// LogDetails logs detailed information about missing dependencies.
71+func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
72+ log := logging.FromContext(ctx)
73+ for _, c := range e.Checks {
74+ if c.Installed {
75+ log.Error().
76+ Str("dependency", c.PkgConfigName).
77+ Str("have", c.Version).
78+ Str("need", c.RequiredVersion).
79+ Bool("ok", c.MeetsRequirement).
80+ Msg("runtime dependency")
81+ } else {
82+ log.Error().
83+ Str("dependency", c.PkgConfigName).
84+ Str("need", c.RequiredVersion).
85+ Msg("runtime dependency missing")
86+ }
87+ }
88+}
89+
90+// RunParallelInit runs the essential parallel initialization phase.
91+// This includes directory resolution, color scheme resolver creation, and theme creation.
92+// Returns the first fatal error encountered, or nil with the results.
93+func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
94+ var (
95+ dataDir, cacheDir string
96+ dirsErr error
97+ wg sync.WaitGroup
98+ )
99+
100+ start := time.Now()
101+
102+ // Create color scheme resolver with initial detectors.
103+ // AdwaitaDetector is created but NOT marked available yet (requires adw.Init()).
104+ configAdapter := colorscheme.NewConfigAdapter(input.Config)
105+ resolver := colorscheme.NewResolver(configAdapter)
106+
107+ // Register detectors that are available before GTK init
108+ resolver.RegisterDetector(colorscheme.NewEnvDetector())
109+ resolver.RegisterDetector(colorscheme.NewGsettingsDetector())
110+
111+ // Create adwaita detector (will be marked available after adw.Init())
112+ adwaitaDetector := colorscheme.NewAdwaitaDetector()
113+ resolver.RegisterDetector(adwaitaDetector)
114+
115+ wg.Add(2)
116+
117+ // Resolve directories
118+ go func() {
119+ defer wg.Done()
120+ dataDir, cacheDir, dirsErr = resolveWebKitDirs()
121+ }()
122+
123+ // Theme manager (CPU-bound, no I/O)
124+ var themeManager *theme.Manager
125+ go func() {
126+ defer wg.Done()
127+ themeManager = theme.NewManager(input.Ctx, input.Config, resolver)
128+ }()
129+
130+ wg.Wait()
131+ duration := time.Since(start)
132+
133+ // Return first fatal error
134+ if dirsErr != nil {
135+ return nil, fmt.Errorf("resolve directories: %w", dirsErr)
136+ }
137+
138+ return &ParallelInitResult{
139+ DataDir: dataDir,
140+ CacheDir: cacheDir,
141+ ThemeManager: themeManager,
142+ ColorResolver: resolver,
143+ AdwaitaDetector: adwaitaDetector,
144+ Duration: duration,
145+ }, nil
146+}
147+
148+// RunDeferredInit runs deferred initialization checks off the critical path.
149+// This includes SQLite WASM precompile, runtime requirements, and media checks.
150+func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
151+ var (
152+ runtimeErr error
153+ mediaErr error
154+ sqliteErr error
155+ wg sync.WaitGroup
156+ )
157+
158+ start := time.Now()
159+ wg.Add(3)
160+
161+ go func() {
162+ defer wg.Done()
163+ sqliteErr = sqlite3.Initialize()
164+ }()
165+
166+ go func() {
167+ defer wg.Done()
168+ runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config)
169+ }()
170+
171+ go func() {
172+ defer wg.Done()
173+ mediaErr = CheckMediaRequirements(input.Ctx, input.Config)
174+ }()
175+
176+ wg.Wait()
177+
178+ return DeferredInitResult{
179+ RuntimeErr: runtimeErr,
180+ MediaErr: mediaErr,
181+ SQLiteErr: sqliteErr,
182+ Duration: time.Since(start),
183+ }
184+}
185+
186+// CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies.
187+// Returns error if requirements are not met; caller should log details and exit.
188+func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error {
189+ probe := deps.NewPkgConfigProbe()
190+ checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
191+ runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
192+ Prefix: cfg.Runtime.Prefix,
193+ })
194+ if err != nil {
195+ return fmt.Errorf("runtime check execution failed: %w", err)
196+ }
197+ if runtimeOut.OK {
198+ return nil
199+ }
200+ return &RuntimeRequirementsError{Checks: runtimeOut.Checks}
201+}
202+
203+// CheckMediaRequirements verifies GStreamer and media codec availability.
204+// Returns error if media subsystem initialization fails.
205+func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error {
206+ mediaDiagAdapter := media.New()
207+ checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
208+ if _, err := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
209+ ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
210+ }); err != nil {
211+ return fmt.Errorf("media check failed: %w", err)
212+ }
213+ return nil
214+}
215+
216+// resolveWebKitDirs resolves and creates the data and cache directories for WebKit.
217+func resolveWebKitDirs() (dataDir, cacheDir string, err error) {
218+ const cacheDirPerm = 0o755
219+ dataDir, err = config.GetDataDir()
220+ if err != nil {
221+ return "", "", fmt.Errorf("resolve data directory: %w", err)
222+ }
223+ stateDir, err := config.GetStateDir()
224+ if err != nil {
225+ return "", "", fmt.Errorf("resolve state directory: %w", err)
226+ }
227+ cacheDir = filepath.Join(stateDir, "webkit-cache")
228+ if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {
229+ return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr)
230+ }
231+ return dataDir, cacheDir, nil
232+}
233+
234+// OpenDatabase opens and initializes the SQLite database using XDG paths.
235+// Returns error if database cannot be opened or migrations fail.
236+func OpenDatabase(ctx context.Context) (*DatabaseResult, error) {
237+ dbPath, err := config.GetDatabaseFile()
238+ if err != nil {
239+ return nil, fmt.Errorf("resolve database path: %w", err)
240+ }
241+ db, err := sqlite.NewConnection(ctx, dbPath)
242+ if err != nil {
243+ return nil, fmt.Errorf("initialize database at %s: %w", dbPath, err)
244+ }
245+ return &DatabaseResult{
246+ DB: db,
247+ Cleanup: func() {
248+ _ = sqlite.Close(db)
249+ },
250+ }, nil
251+}
252+
253+// CreateLazyDatabase creates a lazy database provider that defers initialization.
254+// The database is initialized on first access, allowing the UI to render faster.
255+//
256+// Currently unused: the application uses OpenDatabase with RunParallelDBWebKit for
257+// eager initialization. This function is kept for potential future use when lazy
258+// initialization past first paint becomes beneficial.
259+func CreateLazyDatabase() (*sqlite.LazyDB, error) {
260+ dbPath, err := config.GetDatabaseFile()
261+ if err != nil {
262+ return nil, fmt.Errorf("resolve database path: %w", err)
263+ }
264+ return sqlite.NewLazyDB(dbPath), nil
265+}
266+
267+// ParallelDBWebKitResult holds results from parallel DB and WebKit initialization.
268+type ParallelDBWebKitResult struct {
269+ DB *sql.DB
270+ DBCleanup func()
271+ Stack WebKitStack
272+}
273+
274+// ParallelDBWebKitInput holds inputs for parallel DB and WebKit initialization.
275+type ParallelDBWebKitInput struct {
276+ Ctx context.Context
277+ Config *config.Config
278+ DataDir string // For WebKit context
279+ CacheDir string // For WebKit cache
280+ ThemeManager *theme.Manager
281+ ColorResolver port.ColorSchemeResolver
282+}
283+
284+// Note: Database path is resolved via config.GetDatabaseFile() internally.
285+
286+// dbInitResult holds the result of database initialization for channel communication.
287+type dbInitResult struct {
288+ db *sql.DB
289+ cleanup func()
290+ err error
291+}
292+
293+// RunParallelDBWebKit initializes database in background while WebKit runs on main thread.
294+// Database init happens in goroutine (pure Go/WASM), WebKit must stay on main thread (GTK).
295+// This saves ~150ms by overlapping DB migrations with WebKit context creation.
296+func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult, error) {
297+ log := logging.FromContext(input.Ctx)
298+
299+ dbCh := make(chan dbInitResult, 1)
300+
301+ // Database initialization in background (WASM is thread-safe)
302+ go func() {
303+ dbResult, err := OpenDatabase(input.Ctx)
304+ if err != nil {
305+ dbCh <- dbInitResult{err: err}
306+ return
307+ }
308+ dbCh <- dbInitResult{
309+ db: dbResult.DB,
310+ cleanup: dbResult.Cleanup,
311+ }
312+ }()
313+
314+ // WebKit stack on main thread (GTK requirement)
315+ stack := BuildWebKitStack(WebKitStackInput{
316+ Ctx: input.Ctx,
317+ Config: input.Config,
318+ DataDir: input.DataDir,
319+ CacheDir: input.CacheDir,
320+ ThemeManager: input.ThemeManager,
321+ ColorResolver: input.ColorResolver,
322+ Logger: *log,
323+ })
324+
325+ // Wait for database
326+ dbRes := <-dbCh
327+
328+ if dbRes.err != nil {
329+ return nil, fmt.Errorf("database initialization: %w", dbRes.err)
330+ }
331+
332+ return &ParallelDBWebKitResult{
333+ DB: dbRes.db,
334+ DBCleanup: dbRes.cleanup,
335+ Stack: stack,
336+ }, nil
337+}
M internal/bootstrap/session.go +148 -39
@@ -2,20 +2,20 @@ package bootstrap
2 2
3 import (3 import (
4 "context"4 "context"
5- "database/sql"
6 "errors"5 "errors"
7 "fmt"6 "fmt"
8 "os"7 "os"
9 "path/filepath"8 "path/filepath"
9+ "sync"
10 "syscall"10 "syscall"
11 "time"11 "time"
12 12
13 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/application/usecase"14 "github.com/bnema/dumber/internal/application/usecase"
15 "github.com/bnema/dumber/internal/domain/entity"15 "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/domain/repository"
16 "github.com/bnema/dumber/internal/infrastructure/config"17 "github.com/bnema/dumber/internal/infrastructure/config"
17 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"18 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
18- "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
19 corelogging "github.com/bnema/dumber/internal/logging"19 corelogging "github.com/bnema/dumber/internal/logging"
20 "github.com/rs/zerolog"20 "github.com/rs/zerolog"
21 )21 )
@@ -31,11 +31,20 @@ type BrowserSession struct {
31 Logger zerolog.Logger31 Logger zerolog.Logger
32 LogCleanup func()32 LogCleanup func()
33 33
34- End func(context.Context) error34+ Persist func(context.Context) error
35+ End func(context.Context) error
35 }36 }
36 37
37-func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*BrowserSession, context.Context, error) {38+func StartBrowserSession(
39+ ctx context.Context,
40+ cfg *config.Config,
41+ sessionRepo repository.SessionRepository,
42+ deferPersist bool,
43+) (*BrowserSession, context.Context, error) {
38 log := corelogging.FromContext(ctx)44 log := corelogging.FromContext(ctx)
45+ if sessionRepo == nil {
46+ return nil, ctx, errors.New("session repository is nil")
47+ }
39 48
40 lockDir := cfg.Logging.LogDir49 lockDir := cfg.Logging.LogDir
41 if lockDir == "" {50 if lockDir == "" {
@@ -44,58 +53,112 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*
44 }53 }
45 }54 }
46 55
47- sessionRepo := sqlite.NewSessionRepository(db)
48 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()56 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
49 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)57 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
58+ cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
50 59
51- // If a previous browser process died without cleanup, it may have left sessions "active".60+ now := time.Now()
52- // Use per-session lock files to distinguish truly running sessions from stale ones.61+ session := &entity.Session{
53- if lockDir != "" {62+ ID: entity.SessionID(corelogging.GenerateSessionID()),
54- if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil {63+ Type: entity.SessionTypeBrowser,
55- log.Warn().Err(err).Msg("failed to mark stale sessions ended")64+ StartedAt: now.UTC(),
56- }65+ }
66+ if err := session.Validate(); err != nil {
67+ return nil, ctx, err
57 }68 }
58 69
59- now := time.Now()70+ logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
60- out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{71+ Level: cfg.Logging.Level,
61- Type: entity.SessionTypeBrowser,72+ Format: cfg.Logging.Format,
62- Now: now,73+ TimeFormat: corelogging.ConsoleTimeFormat,
63- LogConfig: port.SessionLogConfig{74+ LogDir: cfg.Logging.LogDir,
64- Level: cfg.Logging.Level,75+ WriteToStderr: true,
65- Format: cfg.Logging.Format,76+ EnableFileLog: cfg.Logging.EnableFileLog,
66- TimeFormat: "15:04:05",
67- LogDir: cfg.Logging.LogDir,
68- WriteToStderr: true,
69- EnableFileLog: cfg.Logging.EnableFileLog,
70- },
71 })77 })
72 if err != nil {78 if err != nil {
73- return nil, ctx, err79+ log.Warn().Err(err).Msg("failed to create session logger")
74 }80 }
75 81
76- lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID)82+ sessionCtx := corelogging.WithContext(context.Background(), logger)
77- if lockErr != nil {
78- // Don’t fail startup if locking fails; it only affects stale-session cleanup.
79- out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
80- }
81 83
82- sessionCtx := corelogging.WithContext(context.Background(), out.Logger)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+ }
103+
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+ }
83 126
84 endFn := func(endCtx context.Context) error {127 endFn := func(endCtx context.Context) error {
85- endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now())128+ _ = persistFn(endCtx)
86- if lockFile != nil {129+ endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
87- _ = unlockAndClose(lockFile)130+
88- _ = os.Remove(lockPath)131+ lockMu.Lock()
132+ lf := lockFile
133+ lp := lockPath
134+ lockMu.Unlock()
135+
136+ if lf != nil {
137+ _ = unlockAndClose(lf)
138+ _ = os.Remove(lp)
89 }139 }
90 return endErr140 return endErr
91 }141 }
92 142
93- return &BrowserSession{143+ browserSession := &BrowserSession{
94- Session: out.Session,144+ Session: session,
95- Logger: out.Logger,145+ Logger: logger,
96- LogCleanup: out.LogCleanup,146+ LogCleanup: cleanup,
97 End: endFn,147 End: endFn,
98- }, sessionCtx, nil148+ }
149+
150+ if deferPersist {
151+ browserSession.Persist = persistFn
152+ } else {
153+ if err := persistFn(ctx); err != nil {
154+ if cleanup != nil {
155+ cleanup()
156+ }
157+ return nil, ctx, err
158+ }
159+ }
160+
161+ return browserSession, sessionCtx, nil
99 }162 }
100 163
101 func endStaleActiveBrowserSessions(164 func endStaleActiveBrowserSessions(
@@ -199,3 +262,49 @@ func unlockAndClose(f *os.File) error {
199 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)262 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
200 return f.Close()263 return f.Close()
201 }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 +141 -39
@@ -15,6 +15,17 @@ import (
15 "github.com/rs/zerolog"15 "github.com/rs/zerolog"
16 )16 )
17 17
18+// WebKitStackInput holds the input for BuildWebKitStack.
19+type WebKitStackInput struct {
20+ Ctx context.Context
21+ Config *config.Config
22+ DataDir string
23+ CacheDir string
24+ ThemeManager *theme.Manager
25+ ColorResolver port.ColorSchemeResolver
26+ Logger zerolog.Logger
27+}
28+
18 type WebKitStack struct {29 type WebKitStack struct {
19 Context *webkit.WebKitContext30 Context *webkit.WebKitContext
20 Settings *webkit.SettingsManager31 Settings *webkit.SettingsManager
@@ -24,50 +35,64 @@ type WebKitStack struct {
24 FilterManager *filtering.Manager35 FilterManager *filtering.Manager
25 }36 }
26 37
27-func BuildWebKitStack(38+func BuildWebKitStack(input WebKitStackInput) WebKitStack {
28- ctx context.Context,39+ ctx := input.Ctx
29- cfg *config.Config,40+ cfg := input.Config
30- dataDir string,41+ dataDir := input.DataDir
31- cacheDir string,42+ cacheDir := input.CacheDir
32- themeManager *theme.Manager,43+ themeManager := input.ThemeManager
33- logger zerolog.Logger,44+ colorResolver := input.ColorResolver
34-) WebKitStack {45+ logger := input.Logger
35- // CRITICAL: Configure rendering environment BEFORE WebKit/GTK initialization.46+ // Detect hardware for performance profile scaling
36- // Environment variables must be set before GTK/WebKit/GStreamer initializes.47+ hwSurveyor := env.NewHardwareSurveyor()
37- renderEnv := env.NewManager()48+ hwInfo := hwSurveyor.Survey(ctx)
38- gpuVendor := renderEnv.DetectGPUVendor(ctx)49+ logger.Info().
39- renderSettings := port.RenderingEnvSettings{50+ Int("cpu_cores", hwInfo.CPUCores).
40- // GStreamer settings51+ Int("cpu_threads", hwInfo.CPUThreads).
41- ForceVSync: cfg.Media.ForceVSync,52+ Uint64("ram_mb", hwInfo.TotalRAM/(1024*1024)).
42- GLRenderingMode: string(cfg.Media.GLRenderingMode),53+ Str("gpu_vendor", string(hwInfo.GPUVendor)).
43- GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel,54+ Uint64("vram_mb", hwInfo.VRAM/(1024*1024)).
44- VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB,55+ Msg("hardware survey completed")
45- QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec,
46 56
47- // WebKit compositor settings57+ // Resolve performance profile to get actual settings
48- DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer,58+ perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo)
49- ForceCompositingMode: cfg.Rendering.ForceCompositingMode,59+ logger.Info().
50- DisableCompositingMode: cfg.Rendering.DisableCompositingMode,60+ Str("profile", string(cfg.Performance.Profile)).
61+ Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads).
62+ Int("skia_gpu_threads", perfSettings.SkiaGPUPaintingThreads).
63+ Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount).
64+ Msg("resolved performance profile")
51 65
52- // GTK/GSK settings66+ // Configure rendering environment (must happen before WebKit/GTK init)
53- GSKRenderer: string(cfg.Rendering.GSKRenderer),67+ configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)
54- DisableMipmaps: cfg.Rendering.DisableMipmaps,
55- PreferGL: cfg.Rendering.PreferGL,
56 68
57- // Debug settings69+ // Build WebKitContext options with memory pressure settings
58- ShowFPS: cfg.Rendering.ShowFPS,70+ wkOpts := port.WebKitContextOptions{
59- SampleMemory: cfg.Rendering.SampleMemory,71+ DataDir: dataDir,
60- DebugFrames: cfg.Rendering.DebugFrames,72+ CacheDir: cacheDir,
61 }73 }
62- if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil {74+
63- logger.Warn().Err(err).Msg("failed to apply rendering environment")75+ // Configure web process memory pressure if any setting is configured
76+ if hasWebProcessMemoryConfig(&perfSettings) {
77+ wkOpts.WebProcessMemory = &port.MemoryPressureConfig{
78+ MemoryLimitMB: perfSettings.WebProcessMemoryLimitMB,
79+ PollIntervalSec: perfSettings.WebProcessMemoryPollIntervalSec,
80+ ConservativeThreshold: perfSettings.WebProcessMemoryConservativeThreshold,
81+ StrictThreshold: perfSettings.WebProcessMemoryStrictThreshold,
82+ }
83+ }
84+
85+ // Configure network process memory pressure if any setting is configured
86+ if hasNetworkProcessMemoryConfig(&perfSettings) {
87+ wkOpts.NetworkProcessMemory = &port.MemoryPressureConfig{
88+ MemoryLimitMB: perfSettings.NetworkProcessMemoryLimitMB,
89+ PollIntervalSec: perfSettings.NetworkProcessMemoryPollIntervalSec,
90+ ConservativeThreshold: perfSettings.NetworkProcessMemoryConservativeThreshold,
91+ StrictThreshold: perfSettings.NetworkProcessMemoryStrictThreshold,
92+ }
64 }93 }
65- logger.Info().
66- Str("gpu", string(gpuVendor)).
67- Interface("vars", renderEnv.GetAppliedVars()).
68- Msg("rendering environment configured")
69 94
70- wkCtx, err := webkit.NewWebKitContext(ctx, dataDir, cacheDir)95+ wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)
71 if err != nil {96 if err != nil {
72 logger.Fatal().Err(err).Msg("failed to initialize WebKit context")97 logger.Fatal().Err(err).Msg("failed to initialize WebKit context")
73 }98 }
@@ -93,7 +118,7 @@ func BuildWebKitStack(
93 schemeHandler.RegisterWithContext(wkCtx)118 schemeHandler.RegisterWithContext(wkCtx)
94 119
95 settings := webkit.NewSettingsManager(ctx, cfg)120 settings := webkit.NewSettingsManager(ctx, cfg)
96- injector := webkit.NewContentInjector(themeManager.PrefersDark())121+ injector := webkit.NewContentInjector(colorResolver)
97 122
98 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)123 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
99 themeCSSText := themeManager.GetWebUIThemeCSS()124 themeCSSText := themeManager.GetWebUIThemeCSS()
@@ -103,12 +128,26 @@ func BuildWebKitStack(
103 128
104 messageRouter := webkit.NewMessageRouter(ctx)129 messageRouter := webkit.NewMessageRouter(ctx)
105 poolCfg := webkit.DefaultPoolConfig()130 poolCfg := webkit.DefaultPoolConfig()
131+ // Override prewarm count from resolved profile settings
132+ if perfSettings.WebViewPoolPrewarmCount > 0 {
133+ poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount
134+ }
106 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)135 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
136+ // Ensure prewarmed WebViews pick up the theme background color.
137+ bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA()
138+ pool.SetBackgroundColor(bgR, bgG, bgB, bgA)
107 139
108 if filterManager != nil {140 if filterManager != nil {
109 pool.SetFilterApplier(filterManager)141 pool.SetFilterApplier(filterManager)
110 }142 }
111 143
144+ // Pre-create ONE WebView synchronously for instant first navigation.
145+ // This is the heaviest operation but happens before window is shown,
146+ // so users perceive it as part of the normal "loading" phase.
147+ if err := pool.PrewarmFirst(ctx); err != nil {
148+ logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
149+ }
150+
112 return WebKitStack{151 return WebKitStack{
113 Context: wkCtx,152 Context: wkCtx,
114 Settings: settings,153 Settings: settings,
@@ -118,3 +157,66 @@ func BuildWebKitStack(
118 FilterManager: filterManager,157 FilterManager: filterManager,
119 }158 }
120 }159 }
160+
161+// hasWebProcessMemoryConfig returns true if any web process memory setting is configured.
162+func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {
163+ return p.WebProcessMemoryLimitMB > 0 ||
164+ p.WebProcessMemoryPollIntervalSec > 0 ||
165+ p.WebProcessMemoryConservativeThreshold > 0 ||
166+ p.WebProcessMemoryStrictThreshold > 0
167+}
168+
169+// hasNetworkProcessMemoryConfig returns true if any network process memory setting is configured.
170+func hasNetworkProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {
171+ return p.NetworkProcessMemoryLimitMB > 0 ||
172+ p.NetworkProcessMemoryPollIntervalSec > 0 ||
173+ p.NetworkProcessMemoryConservativeThreshold > 0 ||
174+ p.NetworkProcessMemoryStrictThreshold > 0
175+}
176+
177+// configureRenderingEnvironment sets up the rendering environment before WebKit/GTK initialization.
178+// Environment variables must be set before GTK/WebKit/GStreamer initializes.
179+func configureRenderingEnvironment(
180+ ctx context.Context,
181+ cfg *config.Config,
182+ perfSettings *config.ResolvedPerformanceSettings,
183+ logger zerolog.Logger,
184+) {
185+ renderEnv := env.NewManager()
186+ gpuVendor := renderEnv.DetectGPUVendor(ctx)
187+ renderSettings := port.RenderingEnvSettings{
188+ // GStreamer settings
189+ ForceVSync: cfg.Media.ForceVSync,
190+ GLRenderingMode: string(cfg.Media.GLRenderingMode),
191+ GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel,
192+ VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB,
193+ QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec,
194+
195+ // WebKit compositor settings
196+ DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer,
197+ ForceCompositingMode: cfg.Rendering.ForceCompositingMode,
198+ DisableCompositingMode: cfg.Rendering.DisableCompositingMode,
199+
200+ // GTK/GSK settings
201+ GSKRenderer: string(cfg.Rendering.GSKRenderer),
202+ DisableMipmaps: cfg.Rendering.DisableMipmaps,
203+ PreferGL: cfg.Rendering.PreferGL,
204+
205+ // Debug settings
206+ ShowFPS: cfg.Rendering.ShowFPS,
207+ SampleMemory: cfg.Rendering.SampleMemory,
208+ DebugFrames: cfg.Rendering.DebugFrames,
209+
210+ // Skia rendering thread settings (from resolved profile)
211+ SkiaCPUPaintingThreads: perfSettings.SkiaCPUPaintingThreads,
212+ SkiaGPUPaintingThreads: perfSettings.SkiaGPUPaintingThreads,
213+ SkiaEnableCPURendering: perfSettings.SkiaEnableCPURendering,
214+ }
215+ if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil {
216+ logger.Warn().Err(err).Msg("failed to apply rendering environment")
217+ }
218+ logger.Info().
219+ Str("gpu", string(gpuVendor)).
220+ Interface("vars", renderEnv.GetAppliedVars()).
221+ Msg("rendering environment configured")
222+}
M internal/cli/app.go +10 -0
@@ -31,6 +31,9 @@ type App struct {
31 // Use cases31 // Use cases
32 SearchHistoryUC *usecase.SearchHistoryUseCase32 SearchHistoryUC *usecase.SearchHistoryUseCase
33 SessionUC *usecase.ManageSessionUseCase33 SessionUC *usecase.ManageSessionUseCase
34+ ListSessionsUC *usecase.ListSessionsUseCase
35+ RestoreUC *usecase.RestoreSessionUseCase
36+ DeleteSessionUC *usecase.DeleteSessionUseCase
34 37
35 // Services38 // Services
36 FaviconService *favicon.Service39 FaviconService *favicon.Service
@@ -105,9 +108,13 @@ func NewApp() (*App, error) {
105 108
106 // Create repositories109 // Create repositories
107 historyRepo := sqlite.NewHistoryRepository(db)110 historyRepo := sqlite.NewHistoryRepository(db)
111+ sessionStateRepo := sqlite.NewSessionStateRepository(db)
108 112
109 // Create use cases113 // Create use cases
110 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)114 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
115+ listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo, cfg.Logging.LogDir)
116+ restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo)
117+ deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo, cfg.Logging.LogDir)
111 118
112 // Create favicon service for CLI (path resolution for dmenu/fuzzel)119 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
113 faviconCacheDir, _ := config.GetFaviconCacheDir()120 faviconCacheDir, _ := config.GetFaviconCacheDir()
@@ -120,6 +127,9 @@ func NewApp() (*App, error) {
120 History: historyRepo,127 History: historyRepo,
121 SearchHistoryUC: searchHistoryUC,128 SearchHistoryUC: searchHistoryUC,
122 SessionUC: sessionUC,129 SessionUC: sessionUC,
130+ ListSessionsUC: listSessionsUC,
131+ RestoreUC: restoreUC,
132+ DeleteSessionUC: deleteSessionUC,
123 FaviconService: faviconService,133 FaviconService: faviconService,
124 ctx: ctx,134 ctx: ctx,
125 logCleanup: logCleanup,135 logCleanup: logCleanup,
M internal/cli/cmd/about.go +40 -0
@@ -1,11 +1,16 @@
1 package cmd1 package cmd
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 6
6 "github.com/spf13/cobra"7 "github.com/spf13/cobra"
7 8
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/cli"
8 "github.com/bnema/dumber/internal/cli/styles"11 "github.com/bnema/dumber/internal/cli/styles"
12+ "github.com/bnema/dumber/internal/infrastructure/updater"
13+ "github.com/bnema/dumber/internal/logging"
9 )14 )
10 15
11 var aboutCmd = &cobra.Command{16 var aboutCmd = &cobra.Command{
@@ -29,5 +34,40 @@ func runAbout(_ *cobra.Command, _ []string) error {
29 output := renderer.Render(app.BuildInfo)34 output := renderer.Render(app.BuildInfo)
30 35
31 fmt.Println(output)36 fmt.Println(output)
37+
38+ // Check for updates
39+ if err := checkForUpdates(app); err != nil {
40+ // Log but don't fail - update check is optional
41+ ctx := logging.WithContext(context.Background(), logging.NewFromConfigValues("warn", "text"))
42+ log := logging.FromContext(ctx)
43+ log.Debug().Err(err).Msg("update check failed")
44+ }
45+
46+ return nil
47+}
48+
49+func checkForUpdates(app *cli.App) error {
50+ ctx := context.Background()
51+
52+ // Create update checker
53+ checker := updater.NewGitHubChecker()
54+ applier, err := updater.NewApplierFromXDG()
55+ if err != nil {
56+ return fmt.Errorf("failed to create applier: %w", err)
57+ }
58+
59+ checkUC := usecase.NewCheckUpdateUseCase(checker, applier, app.BuildInfo)
60+
61+ result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{})
62+ if err != nil {
63+ return err
64+ }
65+
66+ if result.UpdateAvailable {
67+ fmt.Println()
68+ fmt.Printf(" Update available: %s -> %s\n", result.CurrentVersion, result.LatestVersion)
69+ fmt.Printf(" %s\n", result.ReleaseURL)
70+ }
71+
32 return nil72 return nil
33 }73 }
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 +30 -7
@@ -11,16 +11,20 @@ import (
11 tea "github.com/charmbracelet/bubbletea"11 tea "github.com/charmbracelet/bubbletea"
12 "github.com/spf13/cobra"12 "github.com/spf13/cobra"
13 13
14+ "github.com/bnema/dumber/assets"
14 "github.com/bnema/dumber/internal/cli/model"15 "github.com/bnema/dumber/internal/cli/model"
16+ "github.com/bnema/dumber/internal/domain/entity"
15 domainurl "github.com/bnema/dumber/internal/domain/url"17 domainurl "github.com/bnema/dumber/internal/domain/url"
16 "github.com/bnema/dumber/internal/infrastructure/config"18 "github.com/bnema/dumber/internal/infrastructure/config"
19+ "github.com/bnema/dumber/internal/infrastructure/favicon"
17 "github.com/bnema/dumber/internal/logging"20 "github.com/bnema/dumber/internal/logging"
18 )21 )
19 22
20 var (23 var (
21 dmenuInteractive bool24 dmenuInteractive bool
22 dmenuSelect bool25 dmenuSelect bool
23- dmenuMax int26+ dmenuDays int
27+ dmenuMostVisited bool
24 )28 )
25 29
26 var dmenuCmd = &cobra.Command{30 var dmenuCmd = &cobra.Command{
@@ -41,7 +45,8 @@ func init() {
41 45
42 dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")46 dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")
43 dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin")47 dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin")
44- dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)")48+ dmenuCmd.Flags().IntVar(&dmenuDays, "days", 0, "number of days of history to show (default from config)")
49+ dmenuCmd.Flags().BoolVarP(&dmenuMostVisited, "most-visited", "m", false, "sort by visit count instead of recency")
45 }50 }
46 51
47 func runDmenu(_ *cobra.Command, _ []string) error {52 func runDmenu(_ *cobra.Command, _ []string) error {
@@ -69,9 +74,14 @@ func getDmenuConfig() config.DmenuConfig {
69 app := GetApp()74 app := GetApp()
70 cfg := app.Config.Dmenu75 cfg := app.Config.Dmenu
71 76
72- // Override max if explicitly set via CLI flag77+ // Override days if explicitly set via CLI flag
73- if dmenuMax > 0 {78+ if dmenuDays > 0 {
74- cfg.MaxHistoryItems = dmenuMax79+ cfg.MaxHistoryDays = dmenuDays
80+ }
81+
82+ // Override sort_by_visit_count if --most-visited flag is set
83+ if dmenuMostVisited {
84+ cfg.SortByVisitCount = true
75 }85 }
76 86
77 return cfg87 return cfg
@@ -87,6 +97,13 @@ func getFaviconPath(rawURL string) string {
87 return ""97 return ""
88 }98 }
89 99
100+ const normalizedSize = 32
101+
102+ // Handle internal dumb:// URLs - use embedded app logo
103+ if favicon.IsInternalURL(rawURL) {
104+ return app.FaviconService.EnsureInternalFaviconPNG(assets.LogoPNG32, normalizedSize)
105+ }
106+
90 domain := domainurl.ExtractDomain(rawURL)107 domain := domainurl.ExtractDomain(rawURL)
91 if domain == "" {108 if domain == "" {
92 return ""109 return ""
@@ -94,7 +111,6 @@ func getFaviconPath(rawURL string) string {
94 111
95 ctx := app.Ctx()112 ctx := app.Ctx()
96 log := logging.FromContext(ctx)113 log := logging.FromContext(ctx)
97- const normalizedSize = 32
98 114
99 // Check if sized PNG already exists115 // Check if sized PNG already exists
100 if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) {116 if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) {
@@ -123,7 +139,14 @@ func runDmenuPipe() error {
123 ctx := context.Background()139 ctx := context.Background()
124 cfg := getDmenuConfig()140 cfg := getDmenuConfig()
125 141
126- entries, err := app.SearchHistoryUC.GetRecent(ctx, cfg.MaxHistoryItems, 0)142+ var entries []*entity.HistoryEntry
143+ var err error
144+
145+ if cfg.SortByVisitCount {
146+ entries, err = app.SearchHistoryUC.GetMostVisited(ctx, cfg.MaxHistoryDays)
147+ } else {
148+ entries, err = app.SearchHistoryUC.GetRecentSince(ctx, cfg.MaxHistoryDays)
149+ }
127 if err != nil {150 if err != nil {
128 return fmt.Errorf("get history: %w", err)151 return fmt.Errorf("get history: %w", err)
129 }152 }
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/purge.go +34 -5
@@ -24,11 +24,12 @@ var purgeCmd = &cobra.Command{
24 24
25 This can remove:25 This can remove:
26 - Config directory26 - Config directory
27- - Data directory27+ - Data directory (includes database)
28 - State directory28 - State directory
29 - Cache directory29 - Cache directory
30 - Content filter caches30 - Content filter caches
31 - Desktop integration files31 - Desktop integration files
32+ - Inactive browser sessions
32 33
33 Use --force to remove everything without prompting.`,34 Use --force to remove everything without prompting.`,
34 RunE: runPurge,35 RunE: runPurge,
@@ -54,20 +55,48 @@ func runPurge(_ *cobra.Command, _ []string) error {
54 return runPurgeForce(app, purgeUC)55 return runPurgeForce(app, purgeUC)
55 }56 }
56 57
57- m := model.NewPurgeModel(app.Theme, purgeUC)58+ // Build session config from app dependencies
59+ cfg := model.PurgeModelConfig{
60+ ListSessionsUC: app.ListSessionsUC,
61+ DeleteSessionUC: app.DeleteSessionUC,
62+ }
63+
64+ m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg)
58 p := tea.NewProgram(m, tea.WithAltScreen())65 p := tea.NewProgram(m, tea.WithAltScreen())
59 _, err := p.Run()66 _, err := p.Run()
60 return err67 return err
61 }68 }
62 69
63 func runPurgeForce(app *cli.App, purgeUC *usecase.PurgeDataUseCase) error {70 func runPurgeForce(app *cli.App, purgeUC *usecase.PurgeDataUseCase) error {
64- out, err := purgeUC.PurgeAll(app.Ctx())71+ ctx := app.Ctx()
72+ theme := app.Theme
73+
74+ // First, delete all inactive sessions (before we delete the database)
75+ if app.ListSessionsUC != nil && app.DeleteSessionUC != nil {
76+ output, err := app.ListSessionsUC.GetPurgeableSessions(ctx)
77+ if err == nil && output != nil {
78+ for _, s := range output.Sessions {
79+ sessionID := s.Info.Session.ID
80+ if delErr := app.DeleteSessionUC.Execute(ctx, usecase.DeleteSessionInput{
81+ SessionID: sessionID,
82+ CurrentSessionID: "", // CLI has no current session
83+ }); delErr != nil {
84+ fmt.Printf("%s Session %s: %v\n", theme.ErrorStyle.Render(styles.IconX), sessionID, delErr)
85+ } else {
86+ fmt.Printf("%s Session %s\n", theme.SuccessStyle.Render(styles.IconCheck), sessionID)
87+ }
88+ }
89+ }
90+ }
91+
92+ // Then purge all directories
93+ out, err := purgeUC.PurgeAll(ctx)
65 if out != nil {94 if out != nil {
66 for _, r := range out.Results {95 for _, r := range out.Results {
67 if r.Success {96 if r.Success {
68- fmt.Printf("%s %s\n", app.Theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path)97+ fmt.Printf("%s %s\n", theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path)
69 } else {98 } else {
70- fmt.Printf("%s %s: %v\n", app.Theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error)99+ fmt.Printf("%s %s: %v\n", theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error)
71 }100 }
72 }101 }
73 }102 }
M internal/cli/cmd/root.go +21 -4
@@ -16,11 +16,28 @@ var (
16 buildInfo build.Info16 buildInfo build.Info
17 rootCmd = &cobra.Command{17 rootCmd = &cobra.Command{
18 Use: "dumber",18 Use: "dumber",
19- Short: "A minimal web browser",19+ Short: "A fully unfeatured unbloated browser for tiling WMs",
20- Long: `Dumber is a minimal, keyboard-driven web browser built with GTK4 and WebKitGTK.`,20+ Long: `Dumber - a dumb browser that works like your favorite terminal multiplexer.
21+
22+A fully unfeatured unbloated browser for tiling WMs, built with GTK4 and WebKitGTK.
23+
24+Features:
25+ - Wayland native (Sway, Hyprland, River, Niri, etc.)
26+ - Tabs and workspaces with split or stacked panes
27+ - Keyboard-driven workflow inspired by Zellij
28+ - GPU rendering with automatic VA-API/VDPAU detection
29+ - Built-in ad blocking (UBlock-based network + cosmetic filtering)
30+ - Launcher integration (rofi/fuzzel) with favicons
31+ - Search shortcuts via bangs (!g, !gi, etc.)
32+ - Session management with auto-save and restore
33+
34+Use 'dumber browse' to launch the graphical browser, or explore the
35+subcommands for CLI-based operations like history search and session
36+management.`,
21 PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {37 PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
22- // Skip initialization for help commands38+ // Skip initialization for commands that don't need app context
23- if cmd.Name() == "help" || cmd.Name() == "completion" {39+ switch cmd.Name() {
40+ case "help", "completion", "gen-docs":
24 return nil41 return nil
25 }42 }
26 43
A internal/cli/cmd/sessions.go +308 -0
@@ -0,0 +1,308 @@
1+package cmd
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "strings"
8+ "text/tabwriter"
9+
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/spf13/cobra"
12+
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/cli/model"
15+ "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/infrastructure/desktop"
17+)
18+
19+const defaultSessionsLimit = 20
20+
21+var (
22+ sessionsJSON bool
23+ sessionsLimit int
24+)
25+
26+var sessionsCmd = &cobra.Command{
27+ Use: "sessions",
28+ Short: "Manage browser sessions",
29+ Long: `View, restore, and manage browser sessions.
30+
31+Sessions are automatically saved when the browser runs. You can list
32+past sessions and restore them to continue where you left off.
33+
34+Run without arguments to open the interactive session browser.`,
35+ RunE: runSessions,
36+}
37+
38+func init() {
39+ rootCmd.AddCommand(sessionsCmd)
40+}
41+
42+func runSessions(_ *cobra.Command, _ []string) error {
43+ app := GetApp()
44+ if app == nil {
45+ return fmt.Errorf("app not initialized")
46+ }
47+
48+ if app.ListSessionsUC == nil {
49+ return fmt.Errorf("session management not available")
50+ }
51+
52+ // Get current session ID (empty if not running as browser)
53+ var currentSessionID entity.SessionID
54+ if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
55+ currentSessionID = active.ID
56+ }
57+
58+ // Run interactive TUI
59+ m := model.NewSessionsModel(app.Ctx(), app.Theme, model.SessionsModelConfig{
60+ ListSessionsUC: app.ListSessionsUC,
61+ RestoreUC: app.RestoreUC,
62+ DeleteSessionUC: app.DeleteSessionUC,
63+ CurrentSession: currentSessionID,
64+ MaxExitedSessions: app.Config.Session.MaxExitedSessions,
65+ })
66+
67+ p := tea.NewProgram(m, tea.WithAltScreen())
68+ _, err := p.Run()
69+ return err
70+}
71+
72+// sessions list
73+var sessionsListCmd = &cobra.Command{
74+ Use: "list",
75+ Short: "List saved sessions",
76+ Long: `List all saved browser sessions with their tab/pane counts.
77+
78+Sessions are marked as:
79+ ● current - the currently running session
80+ ○ active - another running dumber instance
81+ (blank) - exited session available for restoration`,
82+ RunE: runSessionsList,
83+}
84+
85+func init() {
86+ sessionsCmd.AddCommand(sessionsListCmd)
87+ sessionsListCmd.Flags().BoolVar(&sessionsJSON, "json", false, "output as JSON")
88+ sessionsListCmd.Flags().IntVar(&sessionsLimit, "limit", defaultSessionsLimit, "maximum sessions to show")
89+}
90+
91+func runSessionsList(_ *cobra.Command, _ []string) error {
92+ app := GetApp()
93+ if app == nil {
94+ return fmt.Errorf("app not initialized")
95+ }
96+
97+ if app.ListSessionsUC == nil {
98+ return fmt.Errorf("session management not available")
99+ }
100+
101+ // Get current session ID (empty if not running as browser)
102+ var currentSessionID entity.SessionID
103+ if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
104+ currentSessionID = active.ID
105+ }
106+
107+ output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit)
108+ if err != nil {
109+ return fmt.Errorf("list sessions: %w", err)
110+ }
111+
112+ if sessionsJSON {
113+ return outputSessionsJSON(output.Sessions)
114+ }
115+
116+ return outputSessionsTable(output.Sessions)
117+}
118+
119+func outputSessionsJSON(sessions []entity.SessionInfo) error {
120+ enc := json.NewEncoder(os.Stdout)
121+ enc.SetIndent("", " ")
122+ return enc.Encode(sessions)
123+}
124+
125+func outputSessionsTable(sessions []entity.SessionInfo) error {
126+ if len(sessions) == 0 {
127+ fmt.Println("No saved sessions found.")
128+ return nil
129+ }
130+
131+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
132+ _, _ = fmt.Fprintln(w, "STATUS\tSESSION ID\tTABS\tPANES\tLAST UPDATED")
133+
134+ for _, info := range sessions {
135+ status := " "
136+ switch {
137+ case info.IsCurrent:
138+ status = "●"
139+ case info.IsActive:
140+ status = "○"
141+ }
142+
143+ relTime := usecase.GetRelativeTime(info.UpdatedAt)
144+ _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n",
145+ status,
146+ info.Session.ID,
147+ info.TabCount,
148+ info.PaneCount,
149+ relTime,
150+ )
151+ }
152+
153+ return w.Flush()
154+}
155+
156+// sessions restore <id>
157+var sessionsRestoreCmd = &cobra.Command{
158+ Use: "restore <session-id>",
159+ Short: "Restore a saved session",
160+ Long: `Restore a previously saved browser session.
161+
162+This launches a new browser window with all tabs and panes from the
163+saved session. The session ID can be found using 'dumber sessions list'.
164+
165+You can use a short suffix of the session ID as long as it's unique.
166+
167+Example:
168+ dumber sessions restore 20251224_143022_abc1
169+ dumber sessions restore abc1`,
170+ Args: cobra.ExactArgs(1),
171+ RunE: runSessionsRestore,
172+}
173+
174+func init() {
175+ sessionsCmd.AddCommand(sessionsRestoreCmd)
176+}
177+
178+func runSessionsRestore(_ *cobra.Command, args []string) error {
179+ app := GetApp()
180+ if app == nil {
181+ return fmt.Errorf("app not initialized")
182+ }
183+
184+ if app.RestoreUC == nil || app.ListSessionsUC == nil {
185+ return fmt.Errorf("session restoration not available")
186+ }
187+
188+ // Find session by ID or suffix
189+ sessionInfo, err := findSessionByIDOrSuffix(args[0])
190+ if err != nil {
191+ return err
192+ }
193+
194+ sessionID := sessionInfo.Session.ID
195+
196+ // Validate the session has restorable state
197+ _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID})
198+ if err != nil {
199+ return fmt.Errorf("restore session: %w", err)
200+ }
201+
202+ // Spawn a new dumber instance with the session
203+ spawner := desktop.NewSessionSpawner(app.Ctx())
204+ if err := spawner.SpawnWithSession(sessionID); err != nil {
205+ return fmt.Errorf("spawn browser: %w", err)
206+ }
207+
208+ fmt.Printf("Restoring session %s...\n", sessionID)
209+ return nil
210+}
211+
212+// sessions delete <id>
213+var sessionsDeleteCmd = &cobra.Command{
214+ Use: "delete <session-id>",
215+ Short: "Delete a saved session",
216+ Long: `Delete a saved browser session and its state.
217+
218+This permanently removes the session snapshot. Active sessions cannot
219+be deleted - close the browser first.
220+
221+You can use a short suffix of the session ID as long as it's unique.
222+
223+Example:
224+ dumber sessions delete 20251224_143022_abc1
225+ dumber sessions delete abc1`,
226+ Args: cobra.ExactArgs(1),
227+ RunE: runSessionsDelete,
228+}
229+
230+func init() {
231+ sessionsCmd.AddCommand(sessionsDeleteCmd)
232+}
233+
234+func runSessionsDelete(_ *cobra.Command, args []string) error {
235+ app := GetApp()
236+ if app == nil {
237+ return fmt.Errorf("app not initialized")
238+ }
239+
240+ if app.DeleteSessionUC == nil || app.ListSessionsUC == nil {
241+ return fmt.Errorf("session management not available")
242+ }
243+
244+ // Find session by ID or suffix
245+ sessionInfo, err := findSessionByIDOrSuffix(args[0])
246+ if err != nil {
247+ return err
248+ }
249+
250+ // Get current session ID
251+ var currentSessionID entity.SessionID
252+ if active, activeErr := app.SessionUC.GetActiveSession(app.Ctx()); activeErr == nil && active != nil {
253+ currentSessionID = active.ID
254+ }
255+
256+ // Delete using use case (handles validation internally)
257+ if err := app.DeleteSessionUC.Execute(app.Ctx(), usecase.DeleteSessionInput{
258+ SessionID: sessionInfo.Session.ID,
259+ CurrentSessionID: currentSessionID,
260+ }); err != nil {
261+ return fmt.Errorf("delete session: %w", err)
262+ }
263+
264+ fmt.Printf("Session %s deleted.\n", sessionInfo.Session.ID)
265+ return nil
266+}
267+
268+// findSessionByIDOrSuffix finds a session by exact ID or unique suffix.
269+// Users typically identify sessions by the last few characters (e.g., "dee5").
270+func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {
271+ app := GetApp()
272+ if app == nil || app.ListSessionsUC == nil {
273+ return nil, fmt.Errorf("app not initialized")
274+ }
275+
276+ // Get current session ID (empty if not running as browser)
277+ var currentSessionID entity.SessionID
278+ if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
279+ currentSessionID = active.ID
280+ }
281+
282+ // List all sessions
283+ output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, defaultSessionsLimit*5)
284+ if err != nil {
285+ return nil, fmt.Errorf("list sessions: %w", err)
286+ }
287+
288+ // Find matching sessions
289+ var matches []entity.SessionInfo
290+ for _, info := range output.Sessions {
291+ if string(info.Session.ID) == idOrSuffix {
292+ // Exact match - return immediately
293+ return &info, nil
294+ }
295+ if strings.HasSuffix(string(info.Session.ID), idOrSuffix) {
296+ matches = append(matches, info)
297+ }
298+ }
299+
300+ switch len(matches) {
301+ case 0:
302+ return nil, fmt.Errorf("no session found matching '%s'", idOrSuffix)
303+ case 1:
304+ return &matches[0], nil
305+ default:
306+ return nil, fmt.Errorf("ambiguous session ID '%s' matches %d sessions - be more specific", idOrSuffix, len(matches))
307+ }
308+}
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+}
M internal/cli/model/purge.go +242 -69
@@ -14,23 +14,57 @@ import (
14 14
15 // PurgeModel wraps styles.PurgeModel for standalone CLI use.15 // PurgeModel wraps styles.PurgeModel for standalone CLI use.
16 type PurgeModel struct {16 type PurgeModel struct {
17- selector styles.PurgeModel17+ selector styles.PurgeModel
18- purgeUC *usecase.PurgeDataUseCase18+ purgeUC *usecase.PurgeDataUseCase
19+ listSessionsUC *usecase.ListSessionsUseCase
20+ deleteSessionUC *usecase.DeleteSessionUseCase
19 21
20- loading bool22+ loading bool
21- purging bool23+ loadingSessions bool
22- done bool24+ purging bool
25+ done bool
23 26
24- results *usecase.PurgeOutput27+ // Track loading state for parallel loading
25- info string28+ targetsLoaded bool
26- err error29+ sessionsLoaded bool
30+ targets []entity.PurgeTarget
31+ sessions []entity.SessionPurgeItem
32+ sessionsSize int64
33+
34+ results *usecase.PurgeOutput
35+ sessionResults []sessionPurgeResult
36+ info string
37+ err error
27 38
28 theme *styles.Theme39 theme *styles.Theme
40+ ctx context.Context
41+}
42+
43+// sessionPurgeResult tracks the result of deleting a session.
44+type sessionPurgeResult struct {
45+ SessionID entity.SessionID
46+ Success bool
47+ Error error
48+}
49+
50+// PurgeModelConfig contains optional dependencies for session purging.
51+type PurgeModelConfig struct {
52+ ListSessionsUC *usecase.ListSessionsUseCase
53+ DeleteSessionUC *usecase.DeleteSessionUseCase
29 }54 }
30 55
31 // NewPurgeModel creates a new purge command model.56 // NewPurgeModel creates a new purge command model.
32-func NewPurgeModel(theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase) PurgeModel {57+func NewPurgeModel(ctx context.Context, theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase, cfg PurgeModelConfig) PurgeModel {
33- return PurgeModel{theme: theme, purgeUC: purgeUC, loading: true}58+ m := PurgeModel{
59+ theme: theme,
60+ purgeUC: purgeUC,
61+ listSessionsUC: cfg.ListSessionsUC,
62+ deleteSessionUC: cfg.DeleteSessionUC,
63+ loading: true,
64+ loadingSessions: cfg.ListSessionsUC != nil,
65+ ctx: ctx,
66+ }
67+ return m
34 }68 }
35 69
36 type purgeTargetsLoadedMsg struct {70 type purgeTargetsLoadedMsg struct {
@@ -38,14 +72,25 @@ type purgeTargetsLoadedMsg struct {
38 err error72 err error
39 }73 }
40 74
75+type purgeSessionsLoadedMsg struct {
76+ sessions []entity.SessionPurgeItem
77+ size int64
78+ err error
79+}
80+
41 type purgeCompleteMsg struct {81 type purgeCompleteMsg struct {
42- out *usecase.PurgeOutput82+ targetResults []entity.PurgeResult
43- err error83+ sessionResults []sessionPurgeResult
84+ err error
44 }85 }
45 86
46 // Init implements tea.Model.87 // Init implements tea.Model.
47 func (m PurgeModel) Init() tea.Cmd {88 func (m PurgeModel) Init() tea.Cmd {
48- return m.loadTargets()89+ cmds := []tea.Cmd{m.loadTargets()}
90+ if m.listSessionsUC != nil {
91+ cmds = append(cmds, m.loadSessions())
92+ }
93+ return tea.Batch(cmds...)
49 }94 }
50 95
51 func (m PurgeModel) loadTargets() tea.Cmd {96 func (m PurgeModel) loadTargets() tea.Cmd {
@@ -55,68 +100,172 @@ func (m PurgeModel) loadTargets() tea.Cmd {
55 }100 }
56 }101 }
57 102
103+func (m PurgeModel) loadSessions() tea.Cmd {
104+ return func() tea.Msg {
105+ output, err := m.listSessionsUC.GetPurgeableSessions(context.Background())
106+ if err != nil {
107+ return purgeSessionsLoadedMsg{err: err}
108+ }
109+ return purgeSessionsLoadedMsg{
110+ sessions: output.Sessions,
111+ size: output.TotalSize,
112+ }
113+ }
114+}
115+
58 // Update implements tea.Model.116 // Update implements tea.Model.
59 func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {117 func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
60 switch msg := msg.(type) {118 switch msg := msg.(type) {
61 case purgeTargetsLoadedMsg:119 case purgeTargetsLoadedMsg:
62- m.loading = false120+ return m.handleTargetsLoaded(msg), nil
63- if msg.err != nil {121+ case purgeSessionsLoadedMsg:
64- m.err = msg.err122+ return m.handleSessionsLoaded(msg), nil
65- m.done = true
66- return m, nil
67- }
68-
69- m.selector = styles.NewPurge(m.theme, msg.targets)
70- return m, nil
71-
72 case purgeCompleteMsg:123 case purgeCompleteMsg:
73- m.purging = false124+ return m.handlePurgeComplete(msg), nil
74- m.done = true
75- m.results = msg.out
76- if msg.err != nil {
77- m.err = msg.err
78- }
79- return m, nil
80-
81 case tea.KeyMsg:125 case tea.KeyMsg:
82 if m.done {126 if m.done {
83 return m, tea.Quit127 return m, tea.Quit
84 }128 }
85- if m.loading || m.purging {129+ if m.isBlocked() {
86 return m, nil130 return m, nil
87 }131 }
132+ // Fall through to updateSelector
88 }133 }
89 134
90- if m.loading || m.purging {135+ if m.isBlocked() {
91 return m, nil136 return m, nil
92 }137 }
93 138
139+ return m.updateSelector(msg)
140+}
141+
142+func (m PurgeModel) handleTargetsLoaded(msg purgeTargetsLoadedMsg) PurgeModel {
143+ if msg.err != nil {
144+ m.err = msg.err
145+ m.done = true
146+ m.loading = false
147+ return m
148+ }
149+ m.targetsLoaded = true
150+ m.targets = msg.targets
151+ return m.checkLoadingComplete()
152+}
153+
154+func (m PurgeModel) handleSessionsLoaded(msg purgeSessionsLoadedMsg) PurgeModel {
155+ if msg.err != nil {
156+ // Session loading failure is non-fatal, continue without sessions
157+ m.sessionsLoaded = true
158+ m.loadingSessions = false
159+ return m.checkLoadingComplete()
160+ }
161+ m.sessionsLoaded = true
162+ m.sessions = msg.sessions
163+ m.sessionsSize = msg.size
164+ return m.checkLoadingComplete()
165+}
166+
167+func (m PurgeModel) handlePurgeComplete(msg purgeCompleteMsg) PurgeModel {
168+ m.purging = false
169+ m.done = true
170+ if msg.targetResults != nil {
171+ m.results = &usecase.PurgeOutput{Results: msg.targetResults}
172+ for _, r := range msg.targetResults {
173+ if r.Success {
174+ m.results.SuccessCount++
175+ } else {
176+ m.results.FailureCount++
177+ }
178+ }
179+ }
180+ m.sessionResults = msg.sessionResults
181+ if msg.err != nil {
182+ m.err = msg.err
183+ }
184+ return m
185+}
186+
187+func (m PurgeModel) isBlocked() bool {
188+ return m.loading || m.loadingSessions || m.purging
189+}
190+
191+func (m PurgeModel) updateSelector(msg tea.Msg) (tea.Model, tea.Cmd) {
94 selector, cmd := m.selector.Update(msg)192 selector, cmd := m.selector.Update(msg)
95 m.selector = selector193 m.selector = selector
96 194
97- if m.selector.Done() {195+ if !m.selector.Done() {
98- if m.selector.Canceled {196+ return m, cmd
99- return m, tea.Quit197+ }
100- }198+
101- if m.selector.Confirmed {199+ if m.selector.Canceled {
102- targetTypes := m.selector.SelectedTypes()200+ return m, tea.Quit
103- if len(targetTypes) == 0 {201+ }
104- m.done = true202+
105- m.info = "Nothing selected"203+ if m.selector.Confirmed {
106- return m, nil204+ targetTypes := m.selector.SelectedTypes()
107- }205+ sessionIDs := m.selector.SelectedSessionIDs()
108- m.purging = true206+ if len(targetTypes) == 0 && len(sessionIDs) == 0 {
109- return m, m.performPurge(targetTypes)207+ m.done = true
208+ m.info = "Nothing selected"
209+ return m, nil
110 }210 }
211+ m.purging = true
212+ return m, m.performPurge(targetTypes, sessionIDs)
111 }213 }
112 214
113 return m, cmd215 return m, cmd
114 }216 }
115 217
116-func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd {218+func (m PurgeModel) checkLoadingComplete() PurgeModel {
219+ // If sessions aren't being loaded, only wait for targets
220+ needSessions := m.listSessionsUC != nil
221+ if m.targetsLoaded && (!needSessions || m.sessionsLoaded) {
222+ m.loading = false
223+ m.loadingSessions = false
224+ m.selector = styles.NewPurgeWithSessions(m.theme, m.targets, m.sessions, m.sessionsSize)
225+ }
226+ return m
227+}
228+
229+func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType, sessionIDs []entity.SessionID) tea.Cmd {
117 return func() tea.Msg {230 return func() tea.Msg {
118- out, err := m.purgeUC.Execute(context.Background(), usecase.PurgeInput{TargetTypes: targetTypes})231+ var targetResults []entity.PurgeResult
119- return purgeCompleteMsg{out: out, err: err}232+ var sessionResults []sessionPurgeResult
233+ var lastErr error
234+
235+ // Delete sessions first (if any selected and Data not selected)
236+ if m.deleteSessionUC != nil {
237+ for _, sid := range sessionIDs {
238+ err := m.deleteSessionUC.Execute(m.ctx, usecase.DeleteSessionInput{
239+ SessionID: sid,
240+ CurrentSessionID: "", // CLI has no current session
241+ })
242+ sessionResults = append(sessionResults, sessionPurgeResult{
243+ SessionID: sid,
244+ Success: err == nil,
245+ Error: err,
246+ })
247+ if err != nil {
248+ lastErr = err
249+ }
250+ }
251+ }
252+
253+ // Then delete target directories
254+ if len(targetTypes) > 0 {
255+ out, err := m.purgeUC.Execute(m.ctx, usecase.PurgeInput{TargetTypes: targetTypes})
256+ if out != nil {
257+ targetResults = out.Results
258+ }
259+ if err != nil {
260+ lastErr = err
261+ }
262+ }
263+
264+ return purgeCompleteMsg{
265+ targetResults: targetResults,
266+ sessionResults: sessionResults,
267+ err: lastErr,
268+ }
120 }269 }
121 }270 }
122 271
@@ -124,7 +273,7 @@ func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd {
124 func (m PurgeModel) View() string {273 func (m PurgeModel) View() string {
125 t := m.theme274 t := m.theme
126 275
127- if m.loading {276+ if m.loading || m.loadingSessions {
128 spinner := styles.NewLoading(t, "Scanning purge targets...")277 spinner := styles.NewLoading(t, "Scanning purge targets...")
129 return t.Box.Render(spinner.View())278 return t.Box.Render(spinner.View())
130 }279 }
@@ -145,7 +294,7 @@ func (m PurgeModel) View() string {
145 return t.Box.Render(content)294 return t.Box.Render(content)
146 }295 }
147 296
148- if m.err != nil && m.results == nil {297+ if m.err != nil && m.results == nil && len(m.sessionResults) == 0 {
149 content := lipgloss.JoinVertical(298 content := lipgloss.JoinVertical(
150 lipgloss.Left,299 lipgloss.Left,
151 t.ErrorStyle.Render("Error: "+m.err.Error()),300 t.ErrorStyle.Render("Error: "+m.err.Error()),
@@ -155,28 +304,52 @@ func (m PurgeModel) View() string {
155 return t.Box.Render(content)304 return t.Box.Render(content)
156 }305 }
157 306
158- contentLines := []string{t.Title.Render("Purge complete")}307+ return m.renderResults()
159- if m.results != nil {308+ }
160- for _, r := range m.results.Results {309+
161- if r.Success {310+ return m.selector.View()
162- contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path))311+}
163- } else {312+
164- contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error))313+func (m PurgeModel) renderResults() string {
165- }314+ t := m.theme
315+ contentLines := []string{t.Title.Render("Purge complete")}
316+
317+ successCount := 0
318+ failureCount := 0
319+
320+ // Target results
321+ if m.results != nil {
322+ for _, r := range m.results.Results {
323+ if r.Success {
324+ contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path))
325+ successCount++
326+ } else {
327+ contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error))
328+ failureCount++
166 }329 }
167- contentLines = append(
168- contentLines,
169- "",
170- t.Subtle.Render(
171- fmt.Sprintf("%d succeeded, %d failed", m.results.SuccessCount, m.results.FailureCount),
172- ),
173- )
174 }330 }
175- contentLines = append(contentLines, "", t.Subtle.Render("Press any key to exit"))
176- return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...))
177 }331 }
178 332
179- return m.selector.View()333+ // Session results
334+ for _, r := range m.sessionResults {
335+ if r.Success {
336+ contentLines = append(contentLines, fmt.Sprintf("%s Session %s", t.SuccessStyle.Render(styles.IconCheck), r.SessionID))
337+ successCount++
338+ } else {
339+ contentLines = append(contentLines, fmt.Sprintf("%s Session %s: %v", t.ErrorStyle.Render(styles.IconX), r.SessionID, r.Error))
340+ failureCount++
341+ }
342+ }
343+
344+ contentLines = append(
345+ contentLines,
346+ "",
347+ t.Subtle.Render(fmt.Sprintf("%d succeeded, %d failed", successCount, failureCount)),
348+ "",
349+ t.Subtle.Render("Press any key to exit"),
350+ )
351+
352+ return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...))
180 }353 }
181 354
182 var _ tea.Model = (*PurgeModel)(nil)355 var _ tea.Model = (*PurgeModel)(nil)
A internal/cli/model/sessions.go +642 -0
@@ -0,0 +1,642 @@
1+// Package model provides Bubble Tea models for CLI commands.
2+package model
3+
4+import (
5+ "context"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/charmbracelet/bubbles/help"
10+ "github.com/charmbracelet/bubbles/key"
11+ tea "github.com/charmbracelet/bubbletea"
12+ "github.com/charmbracelet/lipgloss"
13+
14+ "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/cli/styles"
16+ "github.com/bnema/dumber/internal/domain/entity"
17+ "github.com/bnema/dumber/internal/infrastructure/desktop"
18+ "github.com/bnema/dumber/internal/logging"
19+)
20+
21+// SessionsModel is the Bubble Tea model for interactive session browser.
22+type SessionsModel struct {
23+ // UI components
24+ help help.Model
25+ keys sessionsKeyMap
26+ confirm *styles.ConfirmModel
27+
28+ // State
29+ sessions []entity.SessionInfo
30+ selectedIdx int
31+ expandedIdx int // -1 means none expanded
32+ width int
33+ height int
34+ err error
35+ statusMessage string
36+ currentSession entity.SessionID
37+
38+ // Config
39+ maxListedSessions int
40+
41+ // Dependencies
42+ ctx context.Context
43+ listSessionsUC *usecase.ListSessionsUseCase
44+ restoreUC *usecase.RestoreSessionUseCase
45+ deleteSessionUC *usecase.DeleteSessionUseCase
46+ theme *styles.Theme
47+}
48+
49+// sessionsKeyMap defines keybindings for the sessions browser.
50+type sessionsKeyMap struct {
51+ Up key.Binding
52+ Down key.Binding
53+ Expand key.Binding
54+ Restore key.Binding
55+ Delete key.Binding
56+ Refresh key.Binding
57+ Help key.Binding
58+ Quit key.Binding
59+}
60+
61+// ShortHelp returns keybindings for the short help view.
62+func (k sessionsKeyMap) ShortHelp() []key.Binding {
63+ return []key.Binding{k.Up, k.Down, k.Expand, k.Restore, k.Delete, k.Quit}
64+}
65+
66+// FullHelp returns keybindings for the full help view.
67+func (k sessionsKeyMap) FullHelp() [][]key.Binding {
68+ return [][]key.Binding{
69+ {k.Up, k.Down, k.Expand},
70+ {k.Restore, k.Delete, k.Refresh},
71+ {k.Help, k.Quit},
72+ }
73+}
74+
75+func defaultSessionsKeyMap() sessionsKeyMap {
76+ return sessionsKeyMap{
77+ Up: key.NewBinding(
78+ key.WithKeys("k", "up"),
79+ key.WithHelp("↑/k", "up"),
80+ ),
81+ Down: key.NewBinding(
82+ key.WithKeys("j", "down"),
83+ key.WithHelp("↓/j", "down"),
84+ ),
85+ Expand: key.NewBinding(
86+ key.WithKeys("enter", "tab"),
87+ key.WithHelp("enter", "expand/collapse"),
88+ ),
89+ Restore: key.NewBinding(
90+ key.WithKeys("r"),
91+ key.WithHelp("r", "restore"),
92+ ),
93+ Delete: key.NewBinding(
94+ key.WithKeys("x", "d"),
95+ key.WithHelp("x", "delete"),
96+ ),
97+ Refresh: key.NewBinding(
98+ key.WithKeys("R"),
99+ key.WithHelp("R", "refresh"),
100+ ),
101+ Help: key.NewBinding(
102+ key.WithKeys("?"),
103+ key.WithHelp("?", "help"),
104+ ),
105+ Quit: key.NewBinding(
106+ key.WithKeys("q", "esc", "ctrl+c"),
107+ key.WithHelp("q", "quit"),
108+ ),
109+ }
110+}
111+
112+// SessionsModelConfig holds configuration for the sessions model.
113+type SessionsModelConfig struct {
114+ ListSessionsUC *usecase.ListSessionsUseCase
115+ RestoreUC *usecase.RestoreSessionUseCase
116+ DeleteSessionUC *usecase.DeleteSessionUseCase
117+ CurrentSession entity.SessionID
118+ MaxExitedSessions int
119+}
120+
121+// NewSessionsModel creates a new sessions browser model.
122+func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel {
123+ maxListed := cfg.MaxExitedSessions
124+ if maxListed <= 0 {
125+ maxListed = 50 // fallback default
126+ }
127+
128+ return SessionsModel{
129+ help: help.New(),
130+ keys: defaultSessionsKeyMap(),
131+ selectedIdx: 0,
132+ expandedIdx: -1,
133+ width: 80,
134+ height: 24,
135+ maxListedSessions: maxListed,
136+ ctx: ctx,
137+ listSessionsUC: cfg.ListSessionsUC,
138+ restoreUC: cfg.RestoreUC,
139+ deleteSessionUC: cfg.DeleteSessionUC,
140+ currentSession: cfg.CurrentSession,
141+ theme: theme,
142+ }
143+}
144+
145+// Init implements tea.Model.
146+func (m SessionsModel) Init() tea.Cmd {
147+ return m.loadSessions
148+}
149+
150+// sessionsLoadedMsg is sent when sessions are loaded.
151+type sessionsLoadedMsg struct {
152+ sessions []entity.SessionInfo
153+ err error
154+}
155+
156+// sessionDeletedMsg is sent when a session is deleted.
157+type sessionDeletedMsg struct {
158+ sessionID entity.SessionID
159+ err error
160+}
161+
162+// sessionRestoredMsg is sent when a session restoration is triggered.
163+type sessionRestoredMsg struct {
164+ sessionID entity.SessionID
165+ err error
166+}
167+
168+func (m SessionsModel) loadSessions() tea.Msg {
169+ log := logging.FromContext(m.ctx)
170+ log.Debug().Msg("loading sessions")
171+
172+ if m.listSessionsUC == nil {
173+ return sessionsLoadedMsg{err: fmt.Errorf("session management not available")}
174+ }
175+
176+ output, err := m.listSessionsUC.Execute(m.ctx, m.currentSession, m.maxListedSessions)
177+ if err != nil {
178+ log.Error().Err(err).Msg("failed to load sessions")
179+ return sessionsLoadedMsg{err: err}
180+ }
181+
182+ log.Debug().Int("count", len(output.Sessions)).Msg("loaded sessions")
183+ return sessionsLoadedMsg{sessions: output.Sessions}
184+}
185+
186+// Update implements tea.Model.
187+func (m SessionsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
188+ // Handle confirm modal
189+ if m.confirm != nil {
190+ return m.handleConfirmModal(msg)
191+ }
192+
193+ switch msg := msg.(type) {
194+ case tea.WindowSizeMsg:
195+ m.width = msg.Width
196+ m.height = msg.Height
197+ m.help.Width = msg.Width
198+ return m, nil
199+
200+ case tea.KeyMsg:
201+ return m.handleKeyMsg(msg)
202+
203+ case sessionsLoadedMsg:
204+ if msg.err != nil {
205+ m.err = msg.err
206+ } else {
207+ m.sessions = msg.sessions
208+ m.err = nil
209+ }
210+ return m, nil
211+
212+ case sessionDeletedMsg:
213+ if msg.err != nil {
214+ m.statusMessage = fmt.Sprintf("Error: %v", msg.err)
215+ } else {
216+ m.statusMessage = fmt.Sprintf("Session %s deleted", msg.sessionID)
217+ }
218+ return m, m.loadSessions
219+
220+ case sessionRestoredMsg:
221+ if msg.err != nil {
222+ m.statusMessage = fmt.Sprintf("Error: %v", msg.err)
223+ } else {
224+ m.statusMessage = fmt.Sprintf("Restoring session %s...", msg.sessionID)
225+ return m, tea.Quit
226+ }
227+ return m, nil
228+ }
229+
230+ return m, nil
231+}
232+
233+func (m SessionsModel) handleConfirmModal(msg tea.Msg) (tea.Model, tea.Cmd) {
234+ confirm, cmd := m.confirm.Update(msg)
235+ m.confirm = &confirm
236+ if m.confirm.Done() {
237+ if m.confirm.Result() {
238+ // User confirmed deletion
239+ if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
240+ session := m.sessions[m.selectedIdx]
241+ cmd = m.deleteSession(session.Session.ID)
242+ }
243+ }
244+ m.confirm = nil
245+ return m, cmd
246+ }
247+ return m, cmd
248+}
249+
250+func (m SessionsModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
251+ switch {
252+ case key.Matches(msg, m.keys.Quit):
253+ return m, tea.Quit
254+
255+ case key.Matches(msg, m.keys.Up):
256+ if m.selectedIdx > 0 {
257+ m.selectedIdx--
258+ }
259+ return m, nil
260+
261+ case key.Matches(msg, m.keys.Down):
262+ if m.selectedIdx < len(m.sessions)-1 {
263+ m.selectedIdx++
264+ }
265+ return m, nil
266+
267+ case key.Matches(msg, m.keys.Expand):
268+ if m.expandedIdx == m.selectedIdx {
269+ m.expandedIdx = -1 // collapse
270+ } else {
271+ m.expandedIdx = m.selectedIdx // expand
272+ }
273+ return m, nil
274+
275+ case key.Matches(msg, m.keys.Restore):
276+ if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
277+ session := m.sessions[m.selectedIdx]
278+ if session.IsCurrent {
279+ m.statusMessage = "Cannot restore current session"
280+ return m, nil
281+ }
282+ return m, m.restoreSession(session.Session.ID)
283+ }
284+ return m, nil
285+
286+ case key.Matches(msg, m.keys.Delete):
287+ if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
288+ session := m.sessions[m.selectedIdx]
289+ if session.IsCurrent || session.IsActive {
290+ m.statusMessage = "Cannot delete active session"
291+ return m, nil
292+ }
293+ confirm := styles.NewConfirm(m.theme, fmt.Sprintf("Delete session %s?", session.Session.ID))
294+ m.confirm = &confirm
295+ }
296+ return m, nil
297+
298+ case key.Matches(msg, m.keys.Refresh):
299+ return m, m.loadSessions
300+
301+ case key.Matches(msg, m.keys.Help):
302+ m.help.ShowAll = !m.help.ShowAll
303+ return m, nil
304+ }
305+
306+ return m, nil
307+}
308+
309+func (m SessionsModel) deleteSession(sessionID entity.SessionID) tea.Cmd {
310+ return func() tea.Msg {
311+ if m.deleteSessionUC == nil {
312+ return sessionDeletedMsg{sessionID: sessionID, err: fmt.Errorf("delete session use case not available")}
313+ }
314+
315+ err := m.deleteSessionUC.Execute(m.ctx, usecase.DeleteSessionInput{
316+ SessionID: sessionID,
317+ CurrentSessionID: m.currentSession,
318+ })
319+ return sessionDeletedMsg{sessionID: sessionID, err: err}
320+ }
321+}
322+
323+func (m SessionsModel) restoreSession(sessionID entity.SessionID) tea.Cmd {
324+ return func() tea.Msg {
325+ log := logging.FromContext(m.ctx)
326+ log.Info().Str("session_id", string(sessionID)).Msg("restoring session")
327+
328+ if m.restoreUC == nil {
329+ return sessionRestoredMsg{sessionID: sessionID, err: fmt.Errorf("restore use case not available")}
330+ }
331+
332+ // Validate the session has restorable state
333+ _, err := m.restoreUC.Execute(m.ctx, usecase.RestoreInput{SessionID: sessionID})
334+ if err != nil {
335+ return sessionRestoredMsg{sessionID: sessionID, err: err}
336+ }
337+
338+ // Spawn a new dumber instance
339+ spawner := desktop.NewSessionSpawner(m.ctx)
340+ if err := spawner.SpawnWithSession(sessionID); err != nil {
341+ return sessionRestoredMsg{sessionID: sessionID, err: err}
342+ }
343+
344+ return sessionRestoredMsg{sessionID: sessionID}
345+ }
346+}
347+
348+// View implements tea.Model.
349+func (m SessionsModel) View() string {
350+ // Handle confirm modal
351+ if m.confirm != nil {
352+ return m.confirm.View()
353+ }
354+
355+ t := m.theme
356+ var b strings.Builder
357+
358+ // Header
359+ header := m.renderHeader()
360+ b.WriteString(header)
361+ b.WriteString("\n\n")
362+
363+ // Error display
364+ if m.err != nil {
365+ b.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
366+ b.WriteString("\n\n")
367+ }
368+
369+ // Status message
370+ if m.statusMessage != "" {
371+ b.WriteString(t.Subtle.Render(m.statusMessage))
372+ b.WriteString("\n\n")
373+ }
374+
375+ // Sessions list
376+ if len(m.sessions) == 0 {
377+ b.WriteString(t.Subtle.Render(" No saved sessions found."))
378+ b.WriteString("\n")
379+ } else {
380+ b.WriteString(m.renderSessionsList())
381+ }
382+
383+ b.WriteString("\n")
384+
385+ // Help
386+ helpView := m.help.View(m.keys)
387+ b.WriteString(helpView)
388+
389+ return b.String()
390+}
391+
392+func (m SessionsModel) renderHeader() string {
393+ t := m.theme
394+
395+ iconStyle := lipgloss.NewStyle().Foreground(t.Accent)
396+ titleStyle := t.Title.MarginLeft(1)
397+
398+ icon := iconStyle.Render(styles.IconSession)
399+ title := titleStyle.Render("Sessions")
400+
401+ // Count stats
402+ var activeCount, exitedCount int
403+ for _, s := range m.sessions {
404+ if s.IsCurrent || s.IsActive {
405+ activeCount++
406+ } else {
407+ exitedCount++
408+ }
409+ }
410+
411+ stats := t.Subtle.Render(fmt.Sprintf(" %s %d active %s %d exited",
412+ styles.IconPlay, activeCount,
413+ styles.IconStop, exitedCount,
414+ ))
415+
416+ return icon + title + stats
417+}
418+
419+func (m SessionsModel) renderSessionsList() string {
420+ var b strings.Builder
421+
422+ for i, info := range m.sessions {
423+ isSelected := i == m.selectedIdx
424+ isExpanded := i == m.expandedIdx
425+
426+ b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))
427+ b.WriteString("\n")
428+
429+ // Render expanded details
430+ if isExpanded && info.State != nil {
431+ b.WriteString(m.renderSessionDetails(info))
432+ }
433+ }
434+
435+ return b.String()
436+}
437+
438+func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
439+ t := m.theme
440+
441+ // Cursor
442+ cursor := " "
443+ if isSelected {
444+ cursor = t.Highlight.Render(styles.IconCursor + " ")
445+ }
446+
447+ // Status icon
448+ var statusIcon string
449+ var statusStyle lipgloss.Style
450+ switch {
451+ case info.IsCurrent:
452+ statusIcon = styles.IconPlay
453+ statusStyle = lipgloss.NewStyle().Foreground(t.Accent)
454+ case info.IsActive:
455+ statusIcon = styles.IconPlay
456+ statusStyle = lipgloss.NewStyle().Foreground(t.Warning)
457+ default:
458+ statusIcon = styles.IconStop
459+ statusStyle = t.Subtle
460+ }
461+
462+ // Session ID
463+ idStyle := t.Normal
464+ if isSelected {
465+ idStyle = t.Highlight
466+ }
467+
468+ // Label for current/active
469+ label := ""
470+ if info.IsCurrent {
471+ label = t.Badge.Render("current")
472+ } else if info.IsActive {
473+ label = t.BadgeMuted.Render("active")
474+ }
475+
476+ // Expand indicator
477+ expandIcon := styles.IconExpand
478+ if isExpanded {
479+ expandIcon = styles.IconCollapse
480+ }
481+ expandStyle := t.Subtle
482+
483+ // Tab/pane counts
484+ counts := t.Subtle.Render(fmt.Sprintf("%s %d %s %d",
485+ styles.IconTab, info.TabCount,
486+ styles.IconPane, info.PaneCount,
487+ ))
488+
489+ // Time
490+ relTime := usecase.GetRelativeTime(info.UpdatedAt)
491+ timeStr := t.Subtle.Render(fmt.Sprintf("%s %s", styles.IconClock, relTime))
492+
493+ // Build row
494+ row := fmt.Sprintf("%s%s %s %s %s %s %s",
495+ cursor,
496+ statusStyle.Render(statusIcon),
497+ idStyle.Render(string(info.Session.ID)),
498+ label,
499+ expandStyle.Render(expandIcon),
500+ counts,
501+ timeStr,
502+ )
503+
504+ return row
505+}
506+
507+func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
508+ t := m.theme
509+ var b strings.Builder
510+
511+ if info.State == nil {
512+ b.WriteString(t.Subtle.Render(" No state data available\n"))
513+ return b.String()
514+ }
515+
516+ // Tree prefix styles
517+ treeStyle := lipgloss.NewStyle().Foreground(t.Border)
518+ leafStyle := lipgloss.NewStyle().Foreground(t.Muted)
519+
520+ // Render tabs as tree
521+ for tabIdx, tab := range info.State.Tabs {
522+ isLastTab := tabIdx == len(info.State.Tabs)-1
523+
524+ // Tab branch
525+ branch := "├── "
526+ if isLastTab {
527+ branch = "└── "
528+ }
529+
530+ tabIcon := styles.IconTab
531+ if tab.IsPinned {
532+ tabIcon = styles.IconCheckboxChecked
533+ }
534+
535+ tabName := tab.Name
536+ if tabName == "" {
537+ tabName = "Tab"
538+ }
539+
540+ b.WriteString(fmt.Sprintf(" %s%s %s\n",
541+ treeStyle.Render(branch),
542+ leafStyle.Render(tabIcon),
543+ t.Normal.Render(tabName),
544+ ))
545+
546+ // Render panes under this tab
547+ m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)
548+ }
549+
550+ b.WriteString("\n")
551+ return b.String()
552+}
553+
554+func (m SessionsModel) renderPaneTree(
555+ b *strings.Builder,
556+ ws *entity.WorkspaceSnapshot,
557+ isLastTab bool,
558+ t *styles.Theme,
559+ treeStyle, leafStyle lipgloss.Style,
560+) {
561+ if ws == nil || ws.Root == nil {
562+ return
563+ }
564+
565+ // Determine prefix for children
566+ childPrefix := " │ "
567+ if isLastTab {
568+ childPrefix = " "
569+ }
570+
571+ m.renderPaneNode(b, ws.Root, childPrefix, true, t, treeStyle, leafStyle)
572+}
573+
574+func (m SessionsModel) renderPaneNode(
575+ b *strings.Builder,
576+ node *entity.PaneNodeSnapshot,
577+ prefix string,
578+ isLast bool,
579+ t *styles.Theme,
580+ treeStyle, leafStyle lipgloss.Style,
581+) {
582+ if node == nil {
583+ return
584+ }
585+
586+ branch := "├── "
587+ if isLast {
588+ branch = "└── "
589+ }
590+
591+ if node.Pane != nil {
592+ // Leaf node (actual pane)
593+ pane := node.Pane
594+ title := pane.Title
595+ if title == "" {
596+ title = pane.URI
597+ }
598+ // Truncate long titles
599+ const maxTitleLen = 50
600+ if len(title) > maxTitleLen {
601+ title = title[:maxTitleLen-3] + "..."
602+ }
603+
604+ fmt.Fprintf(b, "%s%s%s %s\n",
605+ prefix,
606+ treeStyle.Render(branch),
607+ leafStyle.Render(styles.IconPane),
608+ t.Subtle.Render(title),
609+ )
610+ } else if len(node.Children) > 0 {
611+ // Container node (split or stacked)
612+ splitIcon := styles.IconSessionStack
613+ splitType := "split"
614+ if node.IsStacked {
615+ splitType = "stacked"
616+ } else if node.SplitRatio > 0 {
617+ splitType = fmt.Sprintf("split %.0f%%", node.SplitRatio*100)
618+ }
619+
620+ fmt.Fprintf(b, "%s%s%s %s\n",
621+ prefix,
622+ treeStyle.Render(branch),
623+ leafStyle.Render(splitIcon),
624+ t.Subtle.Render(splitType),
625+ )
626+
627+ // Child prefix
628+ childPrefix := prefix + "│ "
629+ if isLast {
630+ childPrefix = prefix + " "
631+ }
632+
633+ // Render all children
634+ for i, child := range node.Children {
635+ isLastChild := i == len(node.Children)-1
636+ m.renderPaneNode(b, child, childPrefix, isLastChild, t, treeStyle, leafStyle)
637+ }
638+ }
639+}
640+
641+// Ensure interface compliance at compile time.
642+var _ tea.Model = (*SessionsModel)(nil)
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 +18 -0
@@ -40,4 +40,22 @@ const (
40 40
41 // UI41 // UI
42 IconCursor = "\uf054" // chevron-right42 IconCursor = "\uf054" // chevron-right
43+
44+ // Sessions / browser
45+ IconSession = "\uf2d2" // window
46+ IconSessionStack = "\uf24d" // clone/stack
47+ IconTab = "\uf0ce" // table
48+ IconPane = "\uf0db" // columns
49+ IconTree = "\uf1bb" // tree
50+ IconClock = "\uf017" // clock
51+ IconPlay = "\uf04b" // play (running)
52+ IconStop = "\uf04d" // stop (exited)
53+ IconRestore = "\uf0e2" // rotate-left (restore)
54+ IconExpand = "\uf065" // expand
55+ IconCollapse = "\uf066" // compress
56+
57+ // Update / download
58+ IconDownload = "\uf019" // download
59+ IconUpdate = "\uf021" // refresh/sync
60+ IconRocket = "\uf135" // rocket (upgrade)
43 )61 )
M internal/cli/styles/purge.go +224 -34
@@ -17,13 +17,19 @@ type PurgeItem struct {
17 Selected bool17 Selected bool
18 }18 }
19 19
20-// PurgeModel is the multi-select purge modal.20+// PurgeModel is the multi-select purge modal with simplified sessions support.
21+// Sessions are handled as a single "purge all" option instead of individual selection.
21 type PurgeModel struct {22 type PurgeModel struct {
22- Items []PurgeItem23+ Items []PurgeItem
23- Cursor int24+ Sessions []entity.SessionPurgeItem
24- Confirmed bool25+ SessionsSize int64 // Total size of all session snapshots
25- Canceled bool26+ Cursor int // Flat cursor index across all rows
26- theme *Theme27+ Confirmed bool
28+ Canceled bool
29+ theme *Theme
30+
31+ // allSessionsSelected indicates if all sessions should be purged
32+ allSessionsSelected bool
27 }33 }
28 34
29 // PurgeKeyMap defines keybindings for purge modal.35 // PurgeKeyMap defines keybindings for purge modal.
@@ -48,6 +54,34 @@ func DefaultPurgeKeyMap() PurgeKeyMap {
48 }54 }
49 }55 }
50 56
57+// rowType indicates what kind of row we're dealing with
58+type rowType int
59+
60+const (
61+ rowTypeItem rowType = iota
62+ rowTypeSessions
63+)
64+
65+// row represents a single row in the flat list
66+type row struct {
67+ Type rowType
68+ ItemIndex int // index into Items array (only for rowTypeItem)
69+ Exists bool // whether this row is selectable
70+}
71+
72+// buildRows creates a flat list of rows in display order
73+func (m PurgeModel) buildRows() []row {
74+ var rows []row
75+ for i, it := range m.Items {
76+ rows = append(rows, row{Type: rowTypeItem, ItemIndex: i, Exists: it.Exists})
77+ // Insert sessions row right after Data
78+ if it.Type == entity.PurgeTargetData && m.hasSessionsItem() {
79+ rows = append(rows, row{Type: rowTypeSessions, Exists: m.SessionsEnabled()})
80+ }
81+ }
82+ return rows
83+}
84+
51 // NewPurge creates a new purge modal with the given targets.85 // NewPurge creates a new purge modal with the given targets.
52 func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {86 func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {
53 items := make([]PurgeItem, 0, len(targets))87 items := make([]PurgeItem, 0, len(targets))
@@ -60,6 +94,14 @@ func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {
60 return m94 return m
61 }95 }
62 96
97+// NewPurgeWithSessions creates a new purge modal with targets and sessions.
98+func NewPurgeWithSessions(theme *Theme, targets []entity.PurgeTarget, sessions []entity.SessionPurgeItem, sessionsSize int64) PurgeModel {
99+ m := NewPurge(theme, targets)
100+ m.Sessions = sessions
101+ m.SessionsSize = sessionsSize
102+ return m
103+}
104+
63 // Init implements tea.Model.105 // Init implements tea.Model.
64 func (m PurgeModel) Init() tea.Cmd {106 func (m PurgeModel) Init() tea.Cmd {
65 _ = m107 _ = m
@@ -91,24 +133,63 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
91 return m, nil133 return m, nil
92 }134 }
93 135
136+// isDataSelected returns true if the Data target is selected.
137+func (m PurgeModel) isDataSelected() bool {
138+ for _, it := range m.Items {
139+ if it.Type == entity.PurgeTargetData && it.Selected {
140+ return true
141+ }
142+ }
143+ return false
144+}
145+
146+// SessionsEnabled returns true if sessions can be selected.
147+// Sessions are disabled when Data is selected (database will be removed).
148+func (m PurgeModel) SessionsEnabled() bool {
149+ return !m.isDataSelected()
150+}
151+
152+// hasSessionsItem returns true if there are sessions available to purge.
153+func (m PurgeModel) hasSessionsItem() bool {
154+ return len(m.Sessions) > 0
155+}
156+
94 func (m *PurgeModel) moveCursor(delta int) {157 func (m *PurgeModel) moveCursor(delta int) {
95- if len(m.Items) == 0 {158+ rows := m.buildRows()
159+ if len(rows) == 0 {
160+ return
161+ }
162+
163+ // Build list of selectable row indices
164+ var selectableIndices []int
165+ for i, r := range rows {
166+ if r.Exists {
167+ selectableIndices = append(selectableIndices, i)
168+ }
169+ }
170+
171+ if len(selectableIndices) == 0 {
96 return172 return
97 }173 }
98 174
99- idx := m.Cursor175+ // Find current position in selectable list
100- for i := 0; i < len(m.Items); i++ {176+ currentPos := 0
101- idx = (idx + delta + len(m.Items)) % len(m.Items)177+ for i, idx := range selectableIndices {
102- if m.Items[idx].Exists {178+ if idx == m.Cursor {
103- m.Cursor = idx179+ currentPos = i
104- return180+ break
105 }181 }
106 }182 }
183+
184+ // Move
185+ newPos := (currentPos + delta + len(selectableIndices)) % len(selectableIndices)
186+ m.Cursor = selectableIndices[newPos]
107 }187 }
108 188
109 func (m PurgeModel) firstSelectableIndex() int {189 func (m PurgeModel) firstSelectableIndex() int {
110- for i, it := range m.Items {190+ rows := m.buildRows()
111- if it.Exists {191+ for i, r := range rows {
192+ if r.Exists {
112 return i193 return i
113 }194 }
114 }195 }
@@ -116,16 +197,26 @@ func (m PurgeModel) firstSelectableIndex() int {
116 }197 }
117 198
118 func (m *PurgeModel) toggleCurrent() {199 func (m *PurgeModel) toggleCurrent() {
119- if m.Cursor < 0 || m.Cursor >= len(m.Items) {200+ rows := m.buildRows()
201+ if m.Cursor < 0 || m.Cursor >= len(rows) {
120 return202 return
121 }203 }
122- if !m.Items[m.Cursor].Exists {204+
205+ r := rows[m.Cursor]
206+ if !r.Exists {
123 return207 return
124 }208 }
125- m.Items[m.Cursor].Selected = !m.Items[m.Cursor].Selected209+
210+ switch r.Type {
211+ case rowTypeItem:
212+ m.Items[r.ItemIndex].Selected = !m.Items[r.ItemIndex].Selected
213+ case rowTypeSessions:
214+ m.allSessionsSelected = !m.allSessionsSelected
215+ }
126 }216 }
127 217
128 func (m *PurgeModel) toggleAll() {218 func (m *PurgeModel) toggleAll() {
219+ // Check if any selectable item is unselected
129 anyUnselected := false220 anyUnselected := false
130 for _, it := range m.Items {221 for _, it := range m.Items {
131 if it.Exists && !it.Selected {222 if it.Exists && !it.Selected {
@@ -133,13 +224,27 @@ func (m *PurgeModel) toggleAll() {
133 break224 break
134 }225 }
135 }226 }
227+ if m.hasSessionsItem() && m.SessionsEnabled() && !m.allSessionsSelected {
228+ anyUnselected = true
229+ }
136 230
231+ // Set all to the opposite state
137 for i := range m.Items {232 for i := range m.Items {
138- if !m.Items[i].Exists {233+ if m.Items[i].Exists {
139- continue234+ m.Items[i].Selected = anyUnselected
140 }235 }
141- m.Items[i].Selected = anyUnselected
142 }236 }
237+ if m.hasSessionsItem() && m.SessionsEnabled() {
238+ m.allSessionsSelected = anyUnselected
239+ }
240+}
241+
242+// ToggleAllSessions toggles the selection state of all sessions.
243+func (m *PurgeModel) ToggleAllSessions() {
244+ if !m.hasSessionsItem() || !m.SessionsEnabled() {
245+ return
246+ }
247+ m.allSessionsSelected = !m.allSessionsSelected
143 }248 }
144 249
145 // View implements tea.Model.250 // View implements tea.Model.
@@ -149,12 +254,20 @@ func (m PurgeModel) View() string {
149 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))254 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))
150 subtitle := t.Subtle.Render("Select items to remove")255 subtitle := t.Subtle.Render("Select items to remove")
151 256
152- var rows []string257+ // Build rows and render
153- for i, it := range m.Items {258+ rows := m.buildRows()
154- rows = append(rows, m.renderRow(i, it))259+ var itemRows []string
260+ for i, r := range rows {
261+ isCursor := i == m.Cursor
262+ switch r.Type {
263+ case rowTypeItem:
264+ itemRows = append(itemRows, m.renderItemRow(r.ItemIndex, m.Items[r.ItemIndex], isCursor))
265+ case rowTypeSessions:
266+ itemRows = append(itemRows, m.renderSessionsItem(isCursor))
267+ }
155 }268 }
156 269
157- list := lipgloss.JoinVertical(lipgloss.Left, rows...)270+ itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...)
158 271
159 selectedCount := m.SelectedCount()272 selectedCount := m.SelectedCount()
160 size := m.SelectedSize()273 size := m.SelectedSize()
@@ -172,28 +285,30 @@ func (m PurgeModel) View() string {
172 summary = t.Subtle.Render("0 selected")285 summary = t.Subtle.Render("0 selected")
173 }286 }
174 287
175- help := t.Subtle.Render("↑/↓ or j/k to move • space to toggle • a toggle all • enter confirm • esc cancel")288+ helpText := "↑/↓ j/k move • space toggle • a all • enter • esc"
289+ help := t.Subtle.Render(helpText)
176 290
177- content := lipgloss.JoinVertical(291+ contentParts := []string{
178- lipgloss.Left,
179 header,292 header,
180 subtitle,293 subtitle,
181 "",294 "",
182- list,295+ itemsList,
183 "",296 "",
184 summary,297 summary,
185 "",298 "",
186 help,299 help,
187- )300+ }
301+
302+ content := lipgloss.JoinVertical(lipgloss.Left, contentParts...)
188 303
189 return t.Box.Render(content)304 return t.Box.Render(content)
190 }305 }
191 306
192-func (m PurgeModel) renderRow(i int, it PurgeItem) string {307+func (m PurgeModel) renderItemRow(_ int, it PurgeItem, isCursor bool) string {
193 t := m.theme308 t := m.theme
194 309
195 cursor := " "310 cursor := " "
196- if i == m.Cursor {311+ if isCursor && it.Exists {
197 cursor = IconCursor + " "312 cursor = IconCursor + " "
198 }313 }
199 314
@@ -242,6 +357,51 @@ func (m PurgeModel) renderRow(i int, it PurgeItem) string {
242 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)357 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)
243 }358 }
244 359
360+// renderSessionsItem renders the single "Sessions" item.
361+func (m PurgeModel) renderSessionsItem(isCursor bool) string {
362+ t := m.theme
363+ enabled := m.SessionsEnabled()
364+
365+ cursor := " "
366+ if isCursor && enabled {
367+ cursor = IconCursor + " "
368+ }
369+
370+ checkbox := IconCheckboxEmpty
371+ checkboxStyle := t.Subtle
372+ cursorStyle := t.Subtle
373+ labelStyle := t.Subtle
374+ detailStyle := t.Subtle
375+
376+ if enabled {
377+ if m.allSessionsSelected {
378+ checkbox = IconCheckboxChecked
379+ }
380+ checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent)
381+ cursorStyle = lipgloss.NewStyle().Foreground(t.Accent)
382+ labelStyle = t.Normal
383+ }
384+
385+ // Format: Sessions (5 inactive, 12.3 MB) or (included with Data)
386+ label := fmt.Sprintf("%s Sessions", IconSession)
387+ var detail string
388+ if enabled {
389+ detail = fmt.Sprintf("(%d inactive, %s)", len(m.Sessions), formatSize(m.SessionsSize))
390+ } else {
391+ detail = fmt.Sprintf("(%d inactive, included with Data)", len(m.Sessions))
392+ }
393+
394+ const labelPadWidth = 12
395+ return lipgloss.JoinHorizontal(
396+ lipgloss.Left,
397+ cursorStyle.Render(cursor),
398+ checkboxStyle.Render(checkbox),
399+ " ",
400+ labelStyle.Render(padRight(label, labelPadWidth)),
401+ detailStyle.Render(detail),
402+ )
403+}
404+
245 func purgeLabel(t entity.PurgeTargetType) string {405 func purgeLabel(t entity.PurgeTargetType) string {
246 switch t {406 switch t {
247 case entity.PurgeTargetConfig:407 case entity.PurgeTargetConfig:
@@ -309,7 +469,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType {
309 return out469 return out
310 }470 }
311 471
312-// SelectedCount returns the number of selected, existing items.472+// SelectedCount returns the number of selected items (targets + sessions as 1 if selected).
313 func (m PurgeModel) SelectedCount() int {473 func (m PurgeModel) SelectedCount() int {
314 count := 0474 count := 0
315 for _, it := range m.Items {475 for _, it := range m.Items {
@@ -317,10 +477,15 @@ func (m PurgeModel) SelectedCount() int {
317 count++477 count++
318 }478 }
319 }479 }
480+ // Count sessions as 1 item if selected (and enabled)
481+ if m.hasSessionsItem() && m.SessionsEnabled() && m.allSessionsSelected {
482+ count++
483+ }
320 return count484 return count
321 }485 }
322 486
323-// SelectedSize returns the total size of selected, existing items.487+// SelectedSize returns the total size of selected items.
488+// When Data is selected, includes SessionsSize since DB will be removed.
324 func (m PurgeModel) SelectedSize() int64 {489 func (m PurgeModel) SelectedSize() int64 {
325 var total int64490 var total int64
326 for _, it := range m.Items {491 for _, it := range m.Items {
@@ -328,5 +493,30 @@ func (m PurgeModel) SelectedSize() int64 {
328 total += it.Size493 total += it.Size
329 }494 }
330 }495 }
496+ // If Data is selected, sessions are implicitly included
497+ if m.isDataSelected() {
498+ total += m.SessionsSize
499+ } else if m.allSessionsSelected {
500+ // Sessions are explicitly selected
501+ total += m.SessionsSize
502+ }
331 return total503 return total
332 }504 }
505+
506+// SelectedSessionIDs returns the IDs of all sessions if sessions are selected.
507+// Returns nil if Data is selected (sessions are deleted with the database)
508+// or if sessions are not selected.
509+func (m PurgeModel) SelectedSessionIDs() []entity.SessionID {
510+ if m.isDataSelected() {
511+ return nil
512+ }
513+ if !m.allSessionsSelected {
514+ return nil
515+ }
516+
517+ ids := make([]entity.SessionID, 0, len(m.Sessions))
518+ for _, s := range m.Sessions {
519+ ids = append(ids, s.Info.Session.ID)
520+ }
521+ return ids
522+}
A internal/cli/styles/purge_test.go +247 -0
@@ -0,0 +1,247 @@
1+package styles_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/cli/styles"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func testTheme() *styles.Theme {
15+ return styles.NewTheme(config.DefaultConfig())
16+}
17+
18+func TestPurgeModel_SessionsDisabledWhenDataSelected(t *testing.T) {
19+ theme := testTheme()
20+
21+ targets := []entity.PurgeTarget{
22+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
23+ {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 1000},
24+ }
25+
26+ endedAt := time.Now().Add(-time.Hour)
27+ sessions := []entity.SessionPurgeItem{
28+ {
29+ Info: entity.SessionInfo{
30+ Session: &entity.Session{
31+ ID: "session1",
32+ Type: entity.SessionTypeBrowser,
33+ StartedAt: time.Now().Add(-2 * time.Hour),
34+ EndedAt: &endedAt,
35+ },
36+ TabCount: 2,
37+ PaneCount: 3,
38+ },
39+ Selected: true,
40+ },
41+ }
42+
43+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
44+
45+ // Initially Data is selected (default for existing items)
46+ assert.False(t, m.SessionsEnabled(), "sessions should be disabled when Data is selected")
47+
48+ // Deselect Data
49+ m.Items[1].Selected = false
50+ assert.True(t, m.SessionsEnabled(), "sessions should be enabled when Data is not selected")
51+}
52+
53+func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T) {
54+ theme := testTheme()
55+
56+ targets := []entity.PurgeTarget{
57+ {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 1000},
58+ }
59+
60+ endedAt := time.Now().Add(-time.Hour)
61+ sessions := []entity.SessionPurgeItem{
62+ {
63+ Info: entity.SessionInfo{
64+ Session: &entity.Session{
65+ ID: "session1",
66+ Type: entity.SessionTypeBrowser,
67+ StartedAt: time.Now().Add(-2 * time.Hour),
68+ EndedAt: &endedAt,
69+ },
70+ },
71+ },
72+ }
73+
74+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
75+
76+ // Data is selected, so sessions should return nil
77+ ids := m.SelectedSessionIDs()
78+ assert.Nil(t, ids, "should return nil when Data is selected")
79+
80+ // Deselect Data and select all sessions
81+ m.Items[0].Selected = false
82+ m.ToggleAllSessions() // Now allSessionsSelected = true
83+ ids = m.SelectedSessionIDs()
84+ require.Len(t, ids, 1)
85+ assert.Equal(t, entity.SessionID("session1"), ids[0])
86+}
87+
88+func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) {
89+ theme := testTheme()
90+
91+ targets := []entity.PurgeTarget{
92+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
93+ {Type: entity.PurgeTargetCache, Path: "/cache", Exists: true, Size: 200},
94+ }
95+
96+ endedAt := time.Now().Add(-time.Hour)
97+ sessions := []entity.SessionPurgeItem{
98+ {
99+ Info: entity.SessionInfo{
100+ Session: &entity.Session{
101+ ID: "session1",
102+ Type: entity.SessionTypeBrowser,
103+ StartedAt: time.Now().Add(-2 * time.Hour),
104+ EndedAt: &endedAt,
105+ },
106+ },
107+ },
108+ {
109+ Info: entity.SessionInfo{
110+ Session: &entity.Session{
111+ ID: "session2",
112+ Type: entity.SessionTypeBrowser,
113+ StartedAt: time.Now().Add(-3 * time.Hour),
114+ EndedAt: &endedAt,
115+ },
116+ },
117+ },
118+ }
119+
120+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
121+
122+ // 2 targets selected, sessions not selected = 2
123+ assert.Equal(t, 2, m.SelectedCount())
124+
125+ // Select all sessions (as a single item)
126+ m.ToggleAllSessions()
127+ // 2 targets + 1 sessions item = 3
128+ assert.Equal(t, 3, m.SelectedCount())
129+}
130+
131+func TestPurgeModel_ToggleAllSessions(t *testing.T) {
132+ theme := testTheme()
133+
134+ targets := []entity.PurgeTarget{
135+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
136+ }
137+
138+ endedAt := time.Now().Add(-time.Hour)
139+ sessions := []entity.SessionPurgeItem{
140+ {
141+ Info: entity.SessionInfo{
142+ Session: &entity.Session{
143+ ID: "session1",
144+ Type: entity.SessionTypeBrowser,
145+ StartedAt: time.Now().Add(-2 * time.Hour),
146+ EndedAt: &endedAt,
147+ },
148+ },
149+ },
150+ {
151+ Info: entity.SessionInfo{
152+ Session: &entity.Session{
153+ ID: "session2",
154+ Type: entity.SessionTypeBrowser,
155+ StartedAt: time.Now().Add(-3 * time.Hour),
156+ EndedAt: &endedAt,
157+ },
158+ },
159+ },
160+ }
161+
162+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
163+
164+ // Initially sessions are not selected
165+ ids := m.SelectedSessionIDs()
166+ assert.Nil(t, ids, "sessions should not be selected initially")
167+
168+ // Toggle to select all
169+ m.ToggleAllSessions()
170+ ids = m.SelectedSessionIDs()
171+ require.Len(t, ids, 2, "all sessions should be selected after toggle")
172+
173+ // Toggle again to deselect all
174+ m.ToggleAllSessions()
175+ ids = m.SelectedSessionIDs()
176+ assert.Nil(t, ids, "sessions should be deselected after second toggle")
177+}
178+
179+func TestPurgeModel_SelectedSize_IncludesAllSessionsWhenSelected(t *testing.T) {
180+ theme := testTheme()
181+
182+ targets := []entity.PurgeTarget{
183+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
184+ }
185+
186+ endedAt := time.Now().Add(-time.Hour)
187+ sessions := []entity.SessionPurgeItem{
188+ {
189+ Info: entity.SessionInfo{
190+ Session: &entity.Session{
191+ ID: "session1",
192+ Type: entity.SessionTypeBrowser,
193+ StartedAt: time.Now().Add(-2 * time.Hour),
194+ EndedAt: &endedAt,
195+ },
196+ },
197+ },
198+ {
199+ Info: entity.SessionInfo{
200+ Session: &entity.Session{
201+ ID: "session2",
202+ Type: entity.SessionTypeBrowser,
203+ StartedAt: time.Now().Add(-3 * time.Hour),
204+ EndedAt: &endedAt,
205+ },
206+ },
207+ },
208+ }
209+
210+ // Total sessions size is 1000
211+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000)
212+
213+ // Only config selected = 100
214+ assert.Equal(t, int64(100), m.SelectedSize())
215+
216+ // Select all sessions
217+ m.ToggleAllSessions()
218+ // 100 (config) + 1000 (all sessions) = 1100
219+ assert.Equal(t, int64(1100), m.SelectedSize())
220+}
221+
222+func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *testing.T) {
223+ theme := testTheme()
224+
225+ targets := []entity.PurgeTarget{
226+ {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 100},
227+ }
228+
229+ endedAt := time.Now().Add(-time.Hour)
230+ sessions := []entity.SessionPurgeItem{
231+ {
232+ Info: entity.SessionInfo{
233+ Session: &entity.Session{
234+ ID: "session1",
235+ Type: entity.SessionTypeBrowser,
236+ StartedAt: time.Now().Add(-2 * time.Hour),
237+ EndedAt: &endedAt,
238+ },
239+ },
240+ },
241+ }
242+
243+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000)
244+
245+ // 100 (data) + 1000 (all sessions because DB is being deleted) = 1100
246+ assert.Equal(t, int64(1100), m.SelectedSize())
247+}
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/purge.go +6 -0
@@ -30,3 +30,9 @@ type PurgeResult struct {
30 Success bool30 Success bool
31 Error error31 Error error
32 }32 }
33+
34+// SessionPurgeItem represents an inactive session in the purge selection UI.
35+type SessionPurgeItem struct {
36+ Info SessionInfo
37+ Selected bool
38+}
A internal/domain/entity/session_state.go +302 -0
@@ -0,0 +1,302 @@
1+package entity
2+
3+import "time"
4+
5+// SessionStateVersion is the current schema version for session state.
6+// Increment when making breaking changes to the serialization format.
7+const SessionStateVersion = 1
8+
9+// SessionState represents a complete snapshot of a browser session.
10+// This is serialized to JSON and stored in the database.
11+type SessionState struct {
12+ Version int `json:"version"`
13+ SessionID SessionID `json:"session_id"`
14+ Tabs []TabSnapshot `json:"tabs"`
15+ ActiveTabIndex int `json:"active_tab_index"`
16+ SavedAt time.Time `json:"saved_at"`
17+}
18+
19+// TabSnapshot captures the state of a single tab.
20+type TabSnapshot struct {
21+ ID TabID `json:"id"`
22+ Name string `json:"name"`
23+ Position int `json:"position"`
24+ IsPinned bool `json:"is_pinned"`
25+ Workspace WorkspaceSnapshot `json:"workspace"`
26+}
27+
28+// WorkspaceSnapshot captures the pane tree layout.
29+type WorkspaceSnapshot struct {
30+ ID WorkspaceID `json:"id"`
31+ Root *PaneNodeSnapshot `json:"root"`
32+ ActivePaneID PaneID `json:"active_pane_id"`
33+}
34+
35+// PaneNodeSnapshot captures a node in the pane tree.
36+type PaneNodeSnapshot struct {
37+ ID string `json:"id"`
38+ Pane *PaneSnapshot `json:"pane,omitempty"` // Non-nil for leaf nodes
39+ Children []*PaneNodeSnapshot `json:"children,omitempty"` // Non-nil for containers
40+ SplitDir SplitDirection `json:"split_dir"`
41+ SplitRatio float64 `json:"split_ratio"`
42+ IsStacked bool `json:"is_stacked"`
43+ ActiveStackIndex int `json:"active_stack_index"`
44+}
45+
46+// PaneSnapshot captures the essential state of a pane.
47+type PaneSnapshot struct {
48+ ID PaneID `json:"id"`
49+ URI string `json:"uri"`
50+ Title string `json:"title"`
51+ ZoomFactor float64 `json:"zoom_factor"`
52+}
53+
54+// SnapshotFromTabList creates a SessionState from a live TabList.
55+func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState {
56+ if tabs == nil {
57+ return &SessionState{
58+ Version: SessionStateVersion,
59+ SessionID: sessionID,
60+ Tabs: []TabSnapshot{},
61+ SavedAt: time.Now(),
62+ }
63+ }
64+
65+ snapTabs := make([]TabSnapshot, 0, len(tabs.Tabs))
66+ activeTabIndex := 0
67+
68+ for i, tab := range tabs.Tabs {
69+ if tab.ID == tabs.ActiveTabID {
70+ activeTabIndex = i
71+ }
72+ snapTabs = append(snapTabs, snapshotTab(tab))
73+ }
74+
75+ return &SessionState{
76+ Version: SessionStateVersion,
77+ SessionID: sessionID,
78+ Tabs: snapTabs,
79+ ActiveTabIndex: activeTabIndex,
80+ SavedAt: time.Now(),
81+ }
82+}
83+
84+func snapshotTab(tab *Tab) TabSnapshot {
85+ return TabSnapshot{
86+ ID: tab.ID,
87+ Name: tab.Name,
88+ Position: tab.Position,
89+ IsPinned: tab.IsPinned,
90+ Workspace: snapshotWorkspace(tab.Workspace),
91+ }
92+}
93+
94+func snapshotWorkspace(ws *Workspace) WorkspaceSnapshot {
95+ if ws == nil {
96+ return WorkspaceSnapshot{}
97+ }
98+ return WorkspaceSnapshot{
99+ ID: ws.ID,
100+ Root: snapshotPaneNode(ws.Root),
101+ ActivePaneID: ws.ActivePaneID,
102+ }
103+}
104+
105+func snapshotPaneNode(node *PaneNode) *PaneNodeSnapshot {
106+ if node == nil {
107+ return nil
108+ }
109+
110+ snapshot := &PaneNodeSnapshot{
111+ ID: node.ID,
112+ SplitDir: node.SplitDir,
113+ SplitRatio: node.SplitRatio,
114+ IsStacked: node.IsStacked,
115+ ActiveStackIndex: node.ActiveStackIndex,
116+ }
117+
118+ if node.Pane != nil {
119+ snapshot.Pane = &PaneSnapshot{
120+ ID: node.Pane.ID,
121+ URI: node.Pane.URI,
122+ Title: node.Pane.Title,
123+ ZoomFactor: node.Pane.ZoomFactor,
124+ }
125+ }
126+
127+ if len(node.Children) > 0 {
128+ snapshot.Children = make([]*PaneNodeSnapshot, 0, len(node.Children))
129+ for _, child := range node.Children {
130+ snapshot.Children = append(snapshot.Children, snapshotPaneNode(child))
131+ }
132+ }
133+
134+ return snapshot
135+}
136+
137+// SessionInfo provides summary information for the session manager UI.
138+type SessionInfo struct {
139+ Session *Session
140+ State *SessionState
141+ TabCount int
142+ PaneCount int
143+ IsActive bool // Has active lock file
144+ IsCurrent bool // Is the current session
145+ UpdatedAt time.Time // When the state was last saved
146+}
147+
148+// CountPanes returns the total number of panes in the session state.
149+func (s *SessionState) CountPanes() int {
150+ count := 0
151+ for _, tab := range s.Tabs {
152+ count += countPanesInNode(tab.Workspace.Root)
153+ }
154+ return count
155+}
156+
157+func countPanesInNode(node *PaneNodeSnapshot) int {
158+ if node == nil {
159+ return 0
160+ }
161+ if node.Pane != nil {
162+ return 1
163+ }
164+ count := 0
165+ for _, child := range node.Children {
166+ count += countPanesInNode(child)
167+ }
168+ return count
169+}
170+
171+// IDGenerator is a function that generates unique IDs.
172+type IDGenerator func() string
173+
174+// TabListFromSnapshot reconstructs a TabList from a SessionState snapshot.
175+// Generates new IDs for all entities using the provided generator.
176+// This is the inverse of SnapshotFromTabList.
177+func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
178+ if state == nil {
179+ return NewTabList()
180+ }
181+
182+ tabs := NewTabList()
183+
184+ for i, tabSnap := range state.Tabs {
185+ tab := tabFromSnapshot(&tabSnap, idGen)
186+ if tab == nil {
187+ continue
188+ }
189+ tabs.Tabs = append(tabs.Tabs, tab)
190+ tab.Position = len(tabs.Tabs) - 1
191+
192+ if i == state.ActiveTabIndex {
193+ tabs.ActiveTabID = tab.ID
194+ }
195+ }
196+
197+ // Ensure we have an active tab
198+ if tabs.ActiveTabID == "" && len(tabs.Tabs) > 0 {
199+ tabs.ActiveTabID = tabs.Tabs[0].ID
200+ }
201+
202+ return tabs
203+}
204+
205+func tabFromSnapshot(snap *TabSnapshot, idGen IDGenerator) *Tab {
206+ if snap == nil {
207+ return nil
208+ }
209+
210+ ws := workspaceFromSnapshot(&snap.Workspace, idGen)
211+ if ws == nil {
212+ return nil
213+ }
214+
215+ return &Tab{
216+ ID: TabID(idGen()),
217+ Name: snap.Name,
218+ Workspace: ws,
219+ Position: snap.Position,
220+ IsPinned: snap.IsPinned,
221+ CreatedAt: time.Now(),
222+ }
223+}
224+
225+func workspaceFromSnapshot(snap *WorkspaceSnapshot, idGen IDGenerator) *Workspace {
226+ if snap == nil {
227+ return nil
228+ }
229+
230+ root := paneNodeFromSnapshot(snap.Root, nil, idGen)
231+ if root == nil {
232+ return nil
233+ }
234+
235+ // Find the active pane ID in the restored tree
236+ // We need to map the snapshot's ActivePaneID to the new pane ID
237+ var activePaneID PaneID
238+ root.Walk(func(node *PaneNode) bool {
239+ if node.Pane != nil {
240+ // Use the first pane as active if we can't find the original
241+ if activePaneID == "" {
242+ activePaneID = node.Pane.ID
243+ }
244+ }
245+ return true
246+ })
247+
248+ return &Workspace{
249+ ID: WorkspaceID(idGen()),
250+ Root: root,
251+ ActivePaneID: activePaneID,
252+ CreatedAt: time.Now(),
253+ }
254+}
255+
256+func paneNodeFromSnapshot(snap *PaneNodeSnapshot, parent *PaneNode, idGen IDGenerator) *PaneNode {
257+ if snap == nil {
258+ return nil
259+ }
260+
261+ node := &PaneNode{
262+ ID: idGen(),
263+ Parent: parent,
264+ SplitDir: snap.SplitDir,
265+ SplitRatio: snap.SplitRatio,
266+ IsStacked: snap.IsStacked,
267+ ActiveStackIndex: snap.ActiveStackIndex,
268+ }
269+
270+ // Restore pane if this is a leaf node
271+ if snap.Pane != nil {
272+ node.Pane = paneFromSnapshot(snap.Pane, idGen)
273+ }
274+
275+ // Restore children recursively
276+ if len(snap.Children) > 0 {
277+ node.Children = make([]*PaneNode, 0, len(snap.Children))
278+ for _, childSnap := range snap.Children {
279+ child := paneNodeFromSnapshot(childSnap, node, idGen)
280+ if child != nil {
281+ node.Children = append(node.Children, child)
282+ }
283+ }
284+ }
285+
286+ return node
287+}
288+
289+func paneFromSnapshot(snap *PaneSnapshot, idGen IDGenerator) *Pane {
290+ if snap == nil {
291+ return nil
292+ }
293+
294+ return &Pane{
295+ ID: PaneID(idGen()),
296+ URI: snap.URI,
297+ Title: snap.Title,
298+ ZoomFactor: snap.ZoomFactor,
299+ WindowType: WindowMain,
300+ CreatedAt: time.Now(),
301+ }
302+}
A internal/domain/entity/session_state_test.go +430 -0
@@ -0,0 +1,430 @@
1+package entity_test
2+
3+import (
4+ "sync/atomic"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// mockIDGenerator creates a simple ID generator for testing.
14+func mockIDGenerator() entity.IDGenerator {
15+ var counter uint64
16+ return func() string {
17+ id := atomic.AddUint64(&counter, 1)
18+ return string(rune('a'-1+id)) + "_new"
19+ }
20+}
21+
22+func TestTabListFromSnapshot_EmptyState(t *testing.T) {
23+ idGen := mockIDGenerator()
24+
25+ // Nil state
26+ tabs := entity.TabListFromSnapshot(nil, idGen)
27+ require.NotNil(t, tabs)
28+ assert.Empty(t, tabs.Tabs)
29+
30+ // Empty state
31+ state := &entity.SessionState{
32+ Version: 1,
33+ Tabs: []entity.TabSnapshot{},
34+ }
35+ tabs = entity.TabListFromSnapshot(state, idGen)
36+ require.NotNil(t, tabs)
37+ assert.Empty(t, tabs.Tabs)
38+}
39+
40+func TestTabListFromSnapshot_SingleTab(t *testing.T) {
41+ idGen := mockIDGenerator()
42+
43+ state := &entity.SessionState{
44+ Version: 1,
45+ SessionID: "test_session",
46+ ActiveTabIndex: 0,
47+ Tabs: []entity.TabSnapshot{
48+ {
49+ ID: "old_tab_1",
50+ Name: "Test Tab",
51+ Position: 0,
52+ IsPinned: true,
53+ Workspace: entity.WorkspaceSnapshot{
54+ ID: "old_ws_1",
55+ ActivePaneID: "old_pane_1",
56+ Root: &entity.PaneNodeSnapshot{
57+ ID: "old_node_1",
58+ Pane: &entity.PaneSnapshot{
59+ ID: "old_pane_1",
60+ URI: "https://example.com",
61+ Title: "Example",
62+ ZoomFactor: 1.25,
63+ },
64+ },
65+ },
66+ },
67+ },
68+ SavedAt: time.Now(),
69+ }
70+
71+ tabs := entity.TabListFromSnapshot(state, idGen)
72+
73+ require.NotNil(t, tabs)
74+ require.Len(t, tabs.Tabs, 1)
75+
76+ tab := tabs.Tabs[0]
77+ assert.NotEqual(t, entity.TabID("old_tab_1"), tab.ID, "should generate new ID")
78+ assert.Equal(t, "Test Tab", tab.Name)
79+ assert.True(t, tab.IsPinned)
80+
81+ require.NotNil(t, tab.Workspace)
82+ require.NotNil(t, tab.Workspace.Root)
83+ require.NotNil(t, tab.Workspace.Root.Pane)
84+
85+ pane := tab.Workspace.Root.Pane
86+ assert.NotEqual(t, entity.PaneID("old_pane_1"), pane.ID, "should generate new pane ID")
87+ assert.Equal(t, "https://example.com", pane.URI)
88+ assert.Equal(t, "Example", pane.Title)
89+ assert.InDelta(t, 1.25, pane.ZoomFactor, 0.001)
90+
91+ // Active tab should be set
92+ assert.Equal(t, tab.ID, tabs.ActiveTabID)
93+}
94+
95+func TestTabListFromSnapshot_MultipleTabs(t *testing.T) {
96+ idGen := mockIDGenerator()
97+
98+ state := &entity.SessionState{
99+ Version: 1,
100+ ActiveTabIndex: 1, // Second tab is active
101+ Tabs: []entity.TabSnapshot{
102+ {
103+ ID: "tab_1",
104+ Name: "First",
105+ Workspace: entity.WorkspaceSnapshot{
106+ Root: &entity.PaneNodeSnapshot{
107+ Pane: &entity.PaneSnapshot{
108+ ID: "pane_1",
109+ URI: "https://first.com",
110+ },
111+ },
112+ },
113+ },
114+ {
115+ ID: "tab_2",
116+ Name: "Second",
117+ Workspace: entity.WorkspaceSnapshot{
118+ Root: &entity.PaneNodeSnapshot{
119+ Pane: &entity.PaneSnapshot{
120+ ID: "pane_2",
121+ URI: "https://second.com",
122+ },
123+ },
124+ },
125+ },
126+ },
127+ }
128+
129+ tabs := entity.TabListFromSnapshot(state, idGen)
130+
131+ require.Len(t, tabs.Tabs, 2)
132+ assert.Equal(t, "First", tabs.Tabs[0].Name)
133+ assert.Equal(t, "Second", tabs.Tabs[1].Name)
134+
135+ // Active tab should be the second one
136+ assert.Equal(t, tabs.Tabs[1].ID, tabs.ActiveTabID)
137+}
138+
139+func TestTabListFromSnapshot_SplitPanes(t *testing.T) {
140+ idGen := mockIDGenerator()
141+
142+ // Horizontal split with two panes
143+ state := &entity.SessionState{
144+ Version: 1,
145+ Tabs: []entity.TabSnapshot{
146+ {
147+ ID: "tab_1",
148+ Name: "Split Tab",
149+ Workspace: entity.WorkspaceSnapshot{
150+ Root: &entity.PaneNodeSnapshot{
151+ ID: "container",
152+ SplitDir: entity.SplitHorizontal,
153+ SplitRatio: 0.5,
154+ Children: []*entity.PaneNodeSnapshot{
155+ {
156+ ID: "left_node",
157+ Pane: &entity.PaneSnapshot{
158+ ID: "left_pane",
159+ URI: "https://left.com",
160+ },
161+ },
162+ {
163+ ID: "right_node",
164+ Pane: &entity.PaneSnapshot{
165+ ID: "right_pane",
166+ URI: "https://right.com",
167+ },
168+ },
169+ },
170+ },
171+ },
172+ },
173+ },
174+ }
175+
176+ tabs := entity.TabListFromSnapshot(state, idGen)
177+
178+ require.Len(t, tabs.Tabs, 1)
179+ ws := tabs.Tabs[0].Workspace
180+ require.NotNil(t, ws.Root)
181+
182+ // Root should be a container with children
183+ assert.Nil(t, ws.Root.Pane)
184+ assert.Equal(t, entity.SplitHorizontal, ws.Root.SplitDir)
185+ assert.InDelta(t, 0.5, ws.Root.SplitRatio, 0.001)
186+ require.Len(t, ws.Root.Children, 2)
187+
188+ // Check children
189+ leftPane := ws.Root.Children[0].Pane
190+ rightPane := ws.Root.Children[1].Pane
191+ require.NotNil(t, leftPane)
192+ require.NotNil(t, rightPane)
193+ assert.Equal(t, "https://left.com", leftPane.URI)
194+ assert.Equal(t, "https://right.com", rightPane.URI)
195+}
196+
197+func TestTabListFromSnapshot_StackedPanes(t *testing.T) {
198+ idGen := mockIDGenerator()
199+
200+ state := &entity.SessionState{
201+ Version: 1,
202+ Tabs: []entity.TabSnapshot{
203+ {
204+ ID: "tab_1",
205+ Workspace: entity.WorkspaceSnapshot{
206+ Root: &entity.PaneNodeSnapshot{
207+ ID: "stack",
208+ IsStacked: true,
209+ ActiveStackIndex: 1,
210+ Children: []*entity.PaneNodeSnapshot{
211+ {Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://a.com"}},
212+ {Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://b.com"}},
213+ {Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://c.com"}},
214+ },
215+ },
216+ },
217+ },
218+ },
219+ }
220+
221+ tabs := entity.TabListFromSnapshot(state, idGen)
222+
223+ ws := tabs.Tabs[0].Workspace
224+ require.NotNil(t, ws.Root)
225+ assert.True(t, ws.Root.IsStacked)
226+ assert.Equal(t, 1, ws.Root.ActiveStackIndex)
227+ require.Len(t, ws.Root.Children, 3)
228+}
229+
230+func TestTabList_ReplaceFrom(t *testing.T) {
231+ // Create original list
232+ original := entity.NewTabList()
233+ pane1 := entity.NewPane("pane1")
234+ pane1.URI = "https://old.com"
235+ tab1 := entity.NewTab("tab1", "ws1", pane1)
236+ original.Add(tab1)
237+ original.SetActive("tab1")
238+
239+ // Create new list
240+ newList := entity.NewTabList()
241+ pane2 := entity.NewPane("pane2")
242+ pane2.URI = "https://new1.com"
243+ pane3 := entity.NewPane("pane3")
244+ pane3.URI = "https://new2.com"
245+ tab2 := entity.NewTab("tab2", "ws2", pane2)
246+ tab3 := entity.NewTab("tab3", "ws3", pane3)
247+ newList.Add(tab2)
248+ newList.Add(tab3)
249+ newList.SetActive("tab3")
250+
251+ // Keep reference to original
252+ ref := original
253+
254+ // Replace contents
255+ original.ReplaceFrom(newList)
256+
257+ // Verify the reference still points to the modified list
258+ assert.Same(t, original, ref)
259+ assert.Len(t, ref.Tabs, 2)
260+ assert.Equal(t, entity.TabID("tab3"), ref.ActiveTabID)
261+ assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI)
262+}
263+
264+func TestTabList_ReplaceFrom_Nil(t *testing.T) {
265+ original := entity.NewTabList()
266+ pane := entity.NewPane("pane1")
267+ tab := entity.NewTab("tab1", "ws1", pane)
268+ original.Add(tab)
269+
270+ original.ReplaceFrom(nil)
271+
272+ assert.Empty(t, original.Tabs)
273+ assert.Empty(t, original.ActiveTabID)
274+}
275+
276+func TestSnapshotRoundTrip(t *testing.T) {
277+ // Create a complex tab structure
278+ idGen := mockIDGenerator()
279+
280+ pane1 := entity.NewPane("p1")
281+ pane1.URI = "https://google.com"
282+ pane1.Title = "Google"
283+ pane1.ZoomFactor = 1.5
284+
285+ pane2 := entity.NewPane("p2")
286+ pane2.URI = "https://github.com"
287+ pane2.Title = "GitHub"
288+
289+ tab1 := entity.NewTab("t1", "ws1", pane1)
290+ tab1.Name = "Search"
291+ tab1.IsPinned = true
292+
293+ tab2 := entity.NewTab("t2", "ws2", pane2)
294+ tab2.Name = "Code"
295+
296+ original := entity.NewTabList()
297+ original.Add(tab1)
298+ original.Add(tab2)
299+ original.SetActive("t2")
300+
301+ // Snapshot
302+ sessionID := entity.SessionID("test_session")
303+ state := entity.SnapshotFromTabList(sessionID, original)
304+
305+ require.NotNil(t, state)
306+ assert.Equal(t, sessionID, state.SessionID)
307+ assert.Len(t, state.Tabs, 2)
308+ assert.Equal(t, 1, state.ActiveTabIndex) // Second tab active
309+
310+ // Restore
311+ restored := entity.TabListFromSnapshot(state, idGen)
312+
313+ require.NotNil(t, restored)
314+ require.Len(t, restored.Tabs, 2)
315+
316+ // Check first tab
317+ restoredTab1 := restored.Tabs[0]
318+ assert.Equal(t, "Search", restoredTab1.Name)
319+ assert.True(t, restoredTab1.IsPinned)
320+ require.NotNil(t, restoredTab1.Workspace.Root.Pane)
321+ assert.Equal(t, "https://google.com", restoredTab1.Workspace.Root.Pane.URI)
322+ assert.Equal(t, "Google", restoredTab1.Workspace.Root.Pane.Title)
323+ assert.InDelta(t, 1.5, restoredTab1.Workspace.Root.Pane.ZoomFactor, 0.001)
324+
325+ // Check second tab
326+ restoredTab2 := restored.Tabs[1]
327+ assert.Equal(t, "Code", restoredTab2.Name)
328+ assert.False(t, restoredTab2.IsPinned)
329+ assert.Equal(t, "https://github.com", restoredTab2.Workspace.Root.Pane.URI)
330+
331+ // Active tab should be second
332+ assert.Equal(t, restoredTab2.ID, restored.ActiveTabID)
333+}
334+
335+func TestFindPaneAcrossTabs(t *testing.T) {
336+ // Create tabs with panes
337+ pane1 := entity.NewPane("pane_in_tab1")
338+ pane1.URI = "https://tab1.com"
339+ tab1 := entity.NewTab("t1", "ws1", pane1)
340+
341+ pane2 := entity.NewPane("pane_in_tab2")
342+ pane2.URI = "https://tab2.com"
343+ tab2 := entity.NewTab("t2", "ws2", pane2)
344+
345+ tabs := entity.NewTabList()
346+ tabs.Add(tab1)
347+ tabs.Add(tab2)
348+
349+ // Helper function to find pane across all tabs (mirrors App.updatePaneURIInAllTabs logic)
350+ findPaneInTabs := func(tabs *entity.TabList, paneID entity.PaneID) *entity.Pane {
351+ for _, tab := range tabs.Tabs {
352+ if tab.Workspace == nil {
353+ continue
354+ }
355+ paneNode := tab.Workspace.FindPane(paneID)
356+ if paneNode != nil && paneNode.Pane != nil {
357+ return paneNode.Pane
358+ }
359+ }
360+ return nil
361+ }
362+
363+ // Find pane in first tab
364+ found := findPaneInTabs(tabs, "pane_in_tab1")
365+ require.NotNil(t, found)
366+ assert.Equal(t, "https://tab1.com", found.URI)
367+
368+ // Find pane in second tab
369+ found = findPaneInTabs(tabs, "pane_in_tab2")
370+ require.NotNil(t, found)
371+ assert.Equal(t, "https://tab2.com", found.URI)
372+
373+ // Update URI and verify
374+ found.URI = "https://updated.com"
375+ foundAgain := findPaneInTabs(tabs, "pane_in_tab2")
376+ assert.Equal(t, "https://updated.com", foundAgain.URI)
377+
378+ // Non-existent pane
379+ notFound := findPaneInTabs(tabs, "non_existent")
380+ assert.Nil(t, notFound)
381+}
382+
383+func TestFindPaneInNestedStructure(t *testing.T) {
384+ // Create a tab with split panes
385+ pane1 := entity.NewPane("left_pane")
386+ pane1.URI = "https://left.com"
387+
388+ pane2 := entity.NewPane("right_pane")
389+ pane2.URI = "https://right.com"
390+
391+ // Build a split structure manually
392+ tab := entity.NewTab("t1", "ws1", pane1)
393+
394+ // Add second pane as sibling (simulating a horizontal split)
395+ rightNode := &entity.PaneNode{
396+ ID: "right_node",
397+ Pane: pane2,
398+ }
399+
400+ // Create container node
401+ containerNode := &entity.PaneNode{
402+ ID: "container",
403+ SplitDir: entity.SplitHorizontal,
404+ Children: []*entity.PaneNode{
405+ tab.Workspace.Root, // left pane
406+ rightNode, // right pane
407+ },
408+ }
409+ tab.Workspace.Root = containerNode
410+
411+ tabs := entity.NewTabList()
412+ tabs.Add(tab)
413+
414+ // Find left pane
415+ leftNode := tab.Workspace.FindPane("left_pane")
416+ require.NotNil(t, leftNode)
417+ assert.Equal(t, "https://left.com", leftNode.Pane.URI)
418+
419+ // Find right pane
420+ rightNodeFound := tab.Workspace.FindPane("right_pane")
421+ require.NotNil(t, rightNodeFound)
422+ assert.Equal(t, "https://right.com", rightNodeFound.Pane.URI)
423+
424+ // Update URI on nested pane
425+ rightNodeFound.Pane.URI = "https://right-updated.com"
426+
427+ // Verify update persisted
428+ rightNodeAgain := tab.Workspace.FindPane("right_pane")
429+ assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI)
430+}
M internal/domain/entity/tab.go +23 -4
@@ -79,11 +79,16 @@ func (tl *TabList) Remove(id TabID) bool {
79 tl.Tabs[j].Position = j79 tl.Tabs[j].Position = j
80 }80 }
81 // Update active tab if needed81 // Update active tab if needed
82- if tl.ActiveTabID == id && len(tl.Tabs) > 0 {82+ if tl.ActiveTabID == id {
83- if i < len(tl.Tabs) {83+ if len(tl.Tabs) > 0 {
84- tl.ActiveTabID = tl.Tabs[i].ID84+ if i < len(tl.Tabs) {
85+ tl.ActiveTabID = tl.Tabs[i].ID
86+ } else {
87+ tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID
88+ }
85 } else {89 } else {
86- tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID90+ tl.ActiveTabID = ""
91+ tl.PreviousActiveTabID = ""
87 }92 }
88 }93 }
89 return true94 return true
@@ -155,3 +160,17 @@ func (tl *TabList) Move(id TabID, newPos int) bool {
155 }160 }
156 return true161 return true
157 }162 }
163+
164+// ReplaceFrom replaces this TabList's contents with those from another TabList.
165+// This modifies in-place so existing references to this TabList remain valid.
166+func (tl *TabList) ReplaceFrom(other *TabList) {
167+ if other == nil {
168+ tl.Tabs = make([]*Tab, 0)
169+ tl.ActiveTabID = ""
170+ tl.PreviousActiveTabID = ""
171+ return
172+ }
173+ tl.Tabs = other.Tabs
174+ tl.ActiveTabID = other.ActiveTabID
175+ tl.PreviousActiveTabID = other.PreviousActiveTabID
176+}
A internal/domain/entity/update.go +60 -0
@@ -0,0 +1,60 @@
1+// Package entity defines domain entities for dumber.
2+package entity
3+
4+import "time"
5+
6+// UpdateInfo holds information about an available update.
7+type UpdateInfo struct {
8+ // CurrentVersion is the version of the running binary.
9+ CurrentVersion string
10+ // LatestVersion is the latest available version from GitHub.
11+ LatestVersion string
12+ // IsNewer is true if LatestVersion is newer than CurrentVersion.
13+ IsNewer bool
14+ // ReleaseURL is the URL to the GitHub release page.
15+ ReleaseURL string
16+ // DownloadURL is the direct download URL for the binary archive.
17+ DownloadURL string
18+ // PublishedAt is when the release was published.
19+ PublishedAt time.Time
20+ // ReleaseNotes contains the release changelog (optional).
21+ ReleaseNotes string
22+}
23+
24+// UpdateStatus represents the current state of the update process.
25+type UpdateStatus int
26+
27+const (
28+ // UpdateStatusUnknown means update status hasn't been checked yet.
29+ UpdateStatusUnknown UpdateStatus = iota
30+ // UpdateStatusUpToDate means the current version is the latest.
31+ UpdateStatusUpToDate
32+ // UpdateStatusAvailable means a newer version is available.
33+ UpdateStatusAvailable
34+ // UpdateStatusDownloading means the update is being downloaded.
35+ UpdateStatusDownloading
36+ // UpdateStatusReady means the update is downloaded and staged for exit.
37+ UpdateStatusReady
38+ // UpdateStatusFailed means the update check or download failed.
39+ UpdateStatusFailed
40+)
41+
42+// String returns a human-readable string for the update status.
43+func (s UpdateStatus) String() string {
44+ switch s {
45+ case UpdateStatusUnknown:
46+ return "unknown"
47+ case UpdateStatusUpToDate:
48+ return "up-to-date"
49+ case UpdateStatusAvailable:
50+ return "available"
51+ case UpdateStatusDownloading:
52+ return "downloading"
53+ case UpdateStatusReady:
54+ return "ready"
55+ case UpdateStatusFailed:
56+ return "failed"
57+ default:
58+ return "unknown"
59+ }
60+}
M internal/domain/repository/history.go +14 -0
@@ -21,6 +21,20 @@ type HistoryRepository interface {
21 // GetRecent retrieves recent history entries with pagination.21 // GetRecent retrieves recent history entries with pagination.
22 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)22 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
23 23
24+ // GetRecentSince retrieves history entries visited within the last N days.
25+ // days must be > 0.
26+ GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
27+
28+ // GetMostVisited retrieves history entries sorted by visit count within the last N days.
29+ // days must be > 0.
30+ GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
31+
32+ // GetAllRecentHistory retrieves all history entries sorted by recency.
33+ GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error)
34+
35+ // GetAllMostVisited retrieves all history entries sorted by visit count.
36+ GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error)
37+
24 // IncrementVisitCount increments the visit count for a URL.38 // IncrementVisitCount increments the visit count for a URL.
25 IncrementVisitCount(ctx context.Context, url string) error39 IncrementVisitCount(ctx context.Context, url string) error
26 40
M internal/domain/repository/mocks/mocks.go +827 -47
@@ -2197,6 +2197,130 @@ func (_c *MockHistoryRepository_FindByURL_Call) RunAndReturn(run func(ctx contex
2197 return _c2197 return _c
2198 }2198 }
2199 2199
2200+// GetAllMostVisited provides a mock function for the type MockHistoryRepository
2201+func (_mock *MockHistoryRepository) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
2202+ ret := _mock.Called(ctx)
2203+
2204+ if len(ret) == 0 {
2205+ panic("no return value specified for GetAllMostVisited")
2206+ }
2207+
2208+ var r0 []*entity.HistoryEntry
2209+ var r1 error
2210+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.HistoryEntry, error)); ok {
2211+ return returnFunc(ctx)
2212+ }
2213+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.HistoryEntry); ok {
2214+ r0 = returnFunc(ctx)
2215+ } else {
2216+ if ret.Get(0) != nil {
2217+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2218+ }
2219+ }
2220+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2221+ r1 = returnFunc(ctx)
2222+ } else {
2223+ r1 = ret.Error(1)
2224+ }
2225+ return r0, r1
2226+}
2227+
2228+// MockHistoryRepository_GetAllMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllMostVisited'
2229+type MockHistoryRepository_GetAllMostVisited_Call struct {
2230+ *mock.Call
2231+}
2232+
2233+// GetAllMostVisited is a helper method to define mock.On call
2234+// - ctx context.Context
2235+func (_e *MockHistoryRepository_Expecter) GetAllMostVisited(ctx interface{}) *MockHistoryRepository_GetAllMostVisited_Call {
2236+ return &MockHistoryRepository_GetAllMostVisited_Call{Call: _e.mock.On("GetAllMostVisited", ctx)}
2237+}
2238+
2239+func (_c *MockHistoryRepository_GetAllMostVisited_Call) Run(run func(ctx context.Context)) *MockHistoryRepository_GetAllMostVisited_Call {
2240+ _c.Call.Run(func(args mock.Arguments) {
2241+ var arg0 context.Context
2242+ if args[0] != nil {
2243+ arg0 = args[0].(context.Context)
2244+ }
2245+ run(
2246+ arg0,
2247+ )
2248+ })
2249+ return _c
2250+}
2251+
2252+func (_c *MockHistoryRepository_GetAllMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetAllMostVisited_Call {
2253+ _c.Call.Return(historyEntrys, err)
2254+ return _c
2255+}
2256+
2257+func (_c *MockHistoryRepository_GetAllMostVisited_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetAllMostVisited_Call {
2258+ _c.Call.Return(run)
2259+ return _c
2260+}
2261+
2262+// GetAllRecentHistory provides a mock function for the type MockHistoryRepository
2263+func (_mock *MockHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
2264+ ret := _mock.Called(ctx)
2265+
2266+ if len(ret) == 0 {
2267+ panic("no return value specified for GetAllRecentHistory")
2268+ }
2269+
2270+ var r0 []*entity.HistoryEntry
2271+ var r1 error
2272+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.HistoryEntry, error)); ok {
2273+ return returnFunc(ctx)
2274+ }
2275+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.HistoryEntry); ok {
2276+ r0 = returnFunc(ctx)
2277+ } else {
2278+ if ret.Get(0) != nil {
2279+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2280+ }
2281+ }
2282+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2283+ r1 = returnFunc(ctx)
2284+ } else {
2285+ r1 = ret.Error(1)
2286+ }
2287+ return r0, r1
2288+}
2289+
2290+// MockHistoryRepository_GetAllRecentHistory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllRecentHistory'
2291+type MockHistoryRepository_GetAllRecentHistory_Call struct {
2292+ *mock.Call
2293+}
2294+
2295+// GetAllRecentHistory is a helper method to define mock.On call
2296+// - ctx context.Context
2297+func (_e *MockHistoryRepository_Expecter) GetAllRecentHistory(ctx interface{}) *MockHistoryRepository_GetAllRecentHistory_Call {
2298+ return &MockHistoryRepository_GetAllRecentHistory_Call{Call: _e.mock.On("GetAllRecentHistory", ctx)}
2299+}
2300+
2301+func (_c *MockHistoryRepository_GetAllRecentHistory_Call) Run(run func(ctx context.Context)) *MockHistoryRepository_GetAllRecentHistory_Call {
2302+ _c.Call.Run(func(args mock.Arguments) {
2303+ var arg0 context.Context
2304+ if args[0] != nil {
2305+ arg0 = args[0].(context.Context)
2306+ }
2307+ run(
2308+ arg0,
2309+ )
2310+ })
2311+ return _c
2312+}
2313+
2314+func (_c *MockHistoryRepository_GetAllRecentHistory_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetAllRecentHistory_Call {
2315+ _c.Call.Return(historyEntrys, err)
2316+ return _c
2317+}
2318+
2319+func (_c *MockHistoryRepository_GetAllRecentHistory_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetAllRecentHistory_Call {
2320+ _c.Call.Return(run)
2321+ return _c
2322+}
2323+
2200 // GetDailyVisitCount provides a mock function for the type MockHistoryRepository2324 // GetDailyVisitCount provides a mock function for the type MockHistoryRepository
2201 func (_mock *MockHistoryRepository) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {2325 func (_mock *MockHistoryRepository) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {
2202 ret := _mock.Called(ctx, daysAgo)2326 ret := _mock.Called(ctx, daysAgo)
@@ -2395,6 +2519,74 @@ func (_c *MockHistoryRepository_GetHourlyDistribution_Call) RunAndReturn(run fun
2395 return _c2519 return _c
2396 }2520 }
2397 2521
2522+// GetMostVisited provides a mock function for the type MockHistoryRepository
2523+func (_mock *MockHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2524+ ret := _mock.Called(ctx, days)
2525+
2526+ if len(ret) == 0 {
2527+ panic("no return value specified for GetMostVisited")
2528+ }
2529+
2530+ var r0 []*entity.HistoryEntry
2531+ var r1 error
2532+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok {
2533+ return returnFunc(ctx, days)
2534+ }
2535+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok {
2536+ r0 = returnFunc(ctx, days)
2537+ } else {
2538+ if ret.Get(0) != nil {
2539+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2540+ }
2541+ }
2542+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2543+ r1 = returnFunc(ctx, days)
2544+ } else {
2545+ r1 = ret.Error(1)
2546+ }
2547+ return r0, r1
2548+}
2549+
2550+// MockHistoryRepository_GetMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMostVisited'
2551+type MockHistoryRepository_GetMostVisited_Call struct {
2552+ *mock.Call
2553+}
2554+
2555+// GetMostVisited is a helper method to define mock.On call
2556+// - ctx context.Context
2557+// - days int
2558+func (_e *MockHistoryRepository_Expecter) GetMostVisited(ctx interface{}, days interface{}) *MockHistoryRepository_GetMostVisited_Call {
2559+ return &MockHistoryRepository_GetMostVisited_Call{Call: _e.mock.On("GetMostVisited", ctx, days)}
2560+}
2561+
2562+func (_c *MockHistoryRepository_GetMostVisited_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetMostVisited_Call {
2563+ _c.Call.Run(func(args mock.Arguments) {
2564+ var arg0 context.Context
2565+ if args[0] != nil {
2566+ arg0 = args[0].(context.Context)
2567+ }
2568+ var arg1 int
2569+ if args[1] != nil {
2570+ arg1 = args[1].(int)
2571+ }
2572+ run(
2573+ arg0,
2574+ arg1,
2575+ )
2576+ })
2577+ return _c
2578+}
2579+
2580+func (_c *MockHistoryRepository_GetMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetMostVisited_Call {
2581+ _c.Call.Return(historyEntrys, err)
2582+ return _c
2583+}
2584+
2585+func (_c *MockHistoryRepository_GetMostVisited_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetMostVisited_Call {
2586+ _c.Call.Return(run)
2587+ return _c
2588+}
2589+
2398 // GetRecent provides a mock function for the type MockHistoryRepository2590 // GetRecent provides a mock function for the type MockHistoryRepository
2399 func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {2591 func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {
2400 ret := _mock.Called(ctx, limit, offset)2592 ret := _mock.Called(ctx, limit, offset)
@@ -2469,6 +2661,74 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex
2469 return _c2661 return _c
2470 }2662 }
2471 2663
2664+// GetRecentSince provides a mock function for the type MockHistoryRepository
2665+func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
2666+ ret := _mock.Called(ctx, days)
2667+
2668+ if len(ret) == 0 {
2669+ panic("no return value specified for GetRecentSince")
2670+ }
2671+
2672+ var r0 []*entity.HistoryEntry
2673+ var r1 error
2674+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok {
2675+ return returnFunc(ctx, days)
2676+ }
2677+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok {
2678+ r0 = returnFunc(ctx, days)
2679+ } else {
2680+ if ret.Get(0) != nil {
2681+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2682+ }
2683+ }
2684+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
2685+ r1 = returnFunc(ctx, days)
2686+ } else {
2687+ r1 = ret.Error(1)
2688+ }
2689+ return r0, r1
2690+}
2691+
2692+// MockHistoryRepository_GetRecentSince_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentSince'
2693+type MockHistoryRepository_GetRecentSince_Call struct {
2694+ *mock.Call
2695+}
2696+
2697+// GetRecentSince is a helper method to define mock.On call
2698+// - ctx context.Context
2699+// - days int
2700+func (_e *MockHistoryRepository_Expecter) GetRecentSince(ctx interface{}, days interface{}) *MockHistoryRepository_GetRecentSince_Call {
2701+ return &MockHistoryRepository_GetRecentSince_Call{Call: _e.mock.On("GetRecentSince", ctx, days)}
2702+}
2703+
2704+func (_c *MockHistoryRepository_GetRecentSince_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetRecentSince_Call {
2705+ _c.Call.Run(func(args mock.Arguments) {
2706+ var arg0 context.Context
2707+ if args[0] != nil {
2708+ arg0 = args[0].(context.Context)
2709+ }
2710+ var arg1 int
2711+ if args[1] != nil {
2712+ arg1 = args[1].(int)
2713+ }
2714+ run(
2715+ arg0,
2716+ arg1,
2717+ )
2718+ })
2719+ return _c
2720+}
2721+
2722+func (_c *MockHistoryRepository_GetRecentSince_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentSince_Call {
2723+ _c.Call.Return(historyEntrys, err)
2724+ return _c
2725+}
2726+
2727+func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentSince_Call {
2728+ _c.Call.Return(run)
2729+ return _c
2730+}
2731+
2472 // GetStats provides a mock function for the type MockHistoryRepository2732 // GetStats provides a mock function for the type MockHistoryRepository
2473 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {2733 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
2474 ret := _mock.Called(ctx)2734 ret := _mock.Called(ctx)
@@ -2746,47 +3006,36 @@ func (_m *MockSessionRepository) EXPECT() *MockSessionRepository_Expecter {
2746 return &MockSessionRepository_Expecter{mock: &_m.Mock}3006 return &MockSessionRepository_Expecter{mock: &_m.Mock}
2747 }3007 }
2748 3008
2749-// FindByID provides a mock function for the type MockSessionRepository3009+// Delete provides a mock function for the type MockSessionRepository
2750-func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {3010+func (_mock *MockSessionRepository) Delete(ctx context.Context, id entity.SessionID) error {
2751 ret := _mock.Called(ctx, id)3011 ret := _mock.Called(ctx, id)
2752 3012
2753 if len(ret) == 0 {3013 if len(ret) == 0 {
2754- panic("no return value specified for FindByID")3014+ panic("no return value specified for Delete")
2755 }3015 }
2756 3016
2757- var r0 *entity.Session3017+ var r0 error
2758- var r1 error3018+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) error); ok {
2759- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) (*entity.Session, error)); ok {
2760- return returnFunc(ctx, id)
2761- }
2762- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) *entity.Session); ok {
2763 r0 = returnFunc(ctx, id)3019 r0 = returnFunc(ctx, id)
2764 } else {3020 } else {
2765- if ret.Get(0) != nil {3021+ r0 = ret.Error(0)
2766- r0 = ret.Get(0).(*entity.Session)
2767- }
2768- }
2769- if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID) error); ok {
2770- r1 = returnFunc(ctx, id)
2771- } else {
2772- r1 = ret.Error(1)
2773 }3022 }
2774- return r0, r13023+ return r0
2775 }3024 }
2776 3025
2777-// MockSessionRepository_FindByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByID'3026+// MockSessionRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
2778-type MockSessionRepository_FindByID_Call struct {3027+type MockSessionRepository_Delete_Call struct {
2779 *mock.Call3028 *mock.Call
2780 }3029 }
2781 3030
2782-// FindByID is a helper method to define mock.On call3031+// Delete is a helper method to define mock.On call
2783 // - ctx context.Context3032 // - ctx context.Context
2784 // - id entity.SessionID3033 // - id entity.SessionID
2785-func (_e *MockSessionRepository_Expecter) FindByID(ctx interface{}, id interface{}) *MockSessionRepository_FindByID_Call {3034+func (_e *MockSessionRepository_Expecter) Delete(ctx interface{}, id interface{}) *MockSessionRepository_Delete_Call {
2786- return &MockSessionRepository_FindByID_Call{Call: _e.mock.On("FindByID", ctx, id)}3035+ return &MockSessionRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, id)}
2787 }3036 }
2788 3037
2789-func (_c *MockSessionRepository_FindByID_Call) Run(run func(ctx context.Context, id entity.SessionID)) *MockSessionRepository_FindByID_Call {3038+func (_c *MockSessionRepository_Delete_Call) Run(run func(ctx context.Context, id entity.SessionID)) *MockSessionRepository_Delete_Call {
2790 _c.Call.Run(func(args mock.Arguments) {3039 _c.Call.Run(func(args mock.Arguments) {
2791 var arg0 context.Context3040 var arg0 context.Context
2792 if args[0] != nil {3041 if args[0] != nil {
@@ -2804,56 +3053,256 @@ func (_c *MockSessionRepository_FindByID_Call) Run(run func(ctx context.Context,
2804 return _c3053 return _c
2805 }3054 }
2806 3055
2807-func (_c *MockSessionRepository_FindByID_Call) Return(session *entity.Session, err error) *MockSessionRepository_FindByID_Call {3056+func (_c *MockSessionRepository_Delete_Call) Return(err error) *MockSessionRepository_Delete_Call {
2808- _c.Call.Return(session, err)3057+ _c.Call.Return(err)
2809 return _c3058 return _c
2810 }3059 }
2811 3060
2812-func (_c *MockSessionRepository_FindByID_Call) RunAndReturn(run func(ctx context.Context, id entity.SessionID) (*entity.Session, error)) *MockSessionRepository_FindByID_Call {3061+func (_c *MockSessionRepository_Delete_Call) RunAndReturn(run func(ctx context.Context, id entity.SessionID) error) *MockSessionRepository_Delete_Call {
2813 _c.Call.Return(run)3062 _c.Call.Return(run)
2814 return _c3063 return _c
2815 }3064 }
2816 3065
2817-// GetActive provides a mock function for the type MockSessionRepository3066+// DeleteExitedBefore provides a mock function for the type MockSessionRepository
2818-func (_mock *MockSessionRepository) GetActive(ctx context.Context) (*entity.Session, error) {3067+func (_mock *MockSessionRepository) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) {
2819- ret := _mock.Called(ctx)3068+ ret := _mock.Called(ctx, cutoff)
2820 3069
2821 if len(ret) == 0 {3070 if len(ret) == 0 {
2822- panic("no return value specified for GetActive")3071+ panic("no return value specified for DeleteExitedBefore")
2823 }3072 }
2824 3073
2825- var r0 *entity.Session3074+ var r0 int64
2826 var r1 error3075 var r1 error
2827- if returnFunc, ok := ret.Get(0).(func(context.Context) (*entity.Session, error)); ok {3076+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (int64, error)); ok {
2828- return returnFunc(ctx)3077+ return returnFunc(ctx, cutoff)
2829 }3078 }
2830- if returnFunc, ok := ret.Get(0).(func(context.Context) *entity.Session); ok {3079+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) int64); ok {
2831- r0 = returnFunc(ctx)3080+ r0 = returnFunc(ctx, cutoff)
2832 } else {3081 } else {
2833- if ret.Get(0) != nil {3082+ r0 = ret.Get(0).(int64)
2834- r0 = ret.Get(0).(*entity.Session)
2835- }
2836 }3083 }
2837- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {3084+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok {
2838- r1 = returnFunc(ctx)3085+ r1 = returnFunc(ctx, cutoff)
2839 } else {3086 } else {
2840 r1 = ret.Error(1)3087 r1 = ret.Error(1)
2841 }3088 }
2842 return r0, r13089 return r0, r1
2843 }3090 }
2844 3091
2845-// MockSessionRepository_GetActive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActive'3092+// MockSessionRepository_DeleteExitedBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteExitedBefore'
2846-type MockSessionRepository_GetActive_Call struct {3093+type MockSessionRepository_DeleteExitedBefore_Call struct {
2847 *mock.Call3094 *mock.Call
2848 }3095 }
2849 3096
2850-// GetActive is a helper method to define mock.On call3097+// DeleteExitedBefore is a helper method to define mock.On call
2851 // - ctx context.Context3098 // - ctx context.Context
2852-func (_e *MockSessionRepository_Expecter) GetActive(ctx interface{}) *MockSessionRepository_GetActive_Call {3099+// - cutoff time.Time
2853- return &MockSessionRepository_GetActive_Call{Call: _e.mock.On("GetActive", ctx)}3100+func (_e *MockSessionRepository_Expecter) DeleteExitedBefore(ctx interface{}, cutoff interface{}) *MockSessionRepository_DeleteExitedBefore_Call {
3101+ return &MockSessionRepository_DeleteExitedBefore_Call{Call: _e.mock.On("DeleteExitedBefore", ctx, cutoff)}
2854 }3102 }
2855 3103
2856-func (_c *MockSessionRepository_GetActive_Call) Run(run func(ctx context.Context)) *MockSessionRepository_GetActive_Call {3104+func (_c *MockSessionRepository_DeleteExitedBefore_Call) Run(run func(ctx context.Context, cutoff time.Time)) *MockSessionRepository_DeleteExitedBefore_Call {
3105+ _c.Call.Run(func(args mock.Arguments) {
3106+ var arg0 context.Context
3107+ if args[0] != nil {
3108+ arg0 = args[0].(context.Context)
3109+ }
3110+ var arg1 time.Time
3111+ if args[1] != nil {
3112+ arg1 = args[1].(time.Time)
3113+ }
3114+ run(
3115+ arg0,
3116+ arg1,
3117+ )
3118+ })
3119+ return _c
3120+}
3121+
3122+func (_c *MockSessionRepository_DeleteExitedBefore_Call) Return(n int64, err error) *MockSessionRepository_DeleteExitedBefore_Call {
3123+ _c.Call.Return(n, err)
3124+ return _c
3125+}
3126+
3127+func (_c *MockSessionRepository_DeleteExitedBefore_Call) RunAndReturn(run func(ctx context.Context, cutoff time.Time) (int64, error)) *MockSessionRepository_DeleteExitedBefore_Call {
3128+ _c.Call.Return(run)
3129+ return _c
3130+}
3131+
3132+// DeleteOldestExited provides a mock function for the type MockSessionRepository
3133+func (_mock *MockSessionRepository) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) {
3134+ ret := _mock.Called(ctx, keepCount)
3135+
3136+ if len(ret) == 0 {
3137+ panic("no return value specified for DeleteOldestExited")
3138+ }
3139+
3140+ var r0 int64
3141+ var r1 error
3142+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) (int64, error)); ok {
3143+ return returnFunc(ctx, keepCount)
3144+ }
3145+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) int64); ok {
3146+ r0 = returnFunc(ctx, keepCount)
3147+ } else {
3148+ r0 = ret.Get(0).(int64)
3149+ }
3150+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
3151+ r1 = returnFunc(ctx, keepCount)
3152+ } else {
3153+ r1 = ret.Error(1)
3154+ }
3155+ return r0, r1
3156+}
3157+
3158+// MockSessionRepository_DeleteOldestExited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteOldestExited'
3159+type MockSessionRepository_DeleteOldestExited_Call struct {
3160+ *mock.Call
3161+}
3162+
3163+// DeleteOldestExited is a helper method to define mock.On call
3164+// - ctx context.Context
3165+// - keepCount int
3166+func (_e *MockSessionRepository_Expecter) DeleteOldestExited(ctx interface{}, keepCount interface{}) *MockSessionRepository_DeleteOldestExited_Call {
3167+ return &MockSessionRepository_DeleteOldestExited_Call{Call: _e.mock.On("DeleteOldestExited", ctx, keepCount)}
3168+}
3169+
3170+func (_c *MockSessionRepository_DeleteOldestExited_Call) Run(run func(ctx context.Context, keepCount int)) *MockSessionRepository_DeleteOldestExited_Call {
3171+ _c.Call.Run(func(args mock.Arguments) {
3172+ var arg0 context.Context
3173+ if args[0] != nil {
3174+ arg0 = args[0].(context.Context)
3175+ }
3176+ var arg1 int
3177+ if args[1] != nil {
3178+ arg1 = args[1].(int)
3179+ }
3180+ run(
3181+ arg0,
3182+ arg1,
3183+ )
3184+ })
3185+ return _c
3186+}
3187+
3188+func (_c *MockSessionRepository_DeleteOldestExited_Call) Return(n int64, err error) *MockSessionRepository_DeleteOldestExited_Call {
3189+ _c.Call.Return(n, err)
3190+ return _c
3191+}
3192+
3193+func (_c *MockSessionRepository_DeleteOldestExited_Call) RunAndReturn(run func(ctx context.Context, keepCount int) (int64, error)) *MockSessionRepository_DeleteOldestExited_Call {
3194+ _c.Call.Return(run)
3195+ return _c
3196+}
3197+
3198+// FindByID provides a mock function for the type MockSessionRepository
3199+func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
3200+ ret := _mock.Called(ctx, id)
3201+
3202+ if len(ret) == 0 {
3203+ panic("no return value specified for FindByID")
3204+ }
3205+
3206+ var r0 *entity.Session
3207+ var r1 error
3208+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) (*entity.Session, error)); ok {
3209+ return returnFunc(ctx, id)
3210+ }
3211+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) *entity.Session); ok {
3212+ r0 = returnFunc(ctx, id)
3213+ } else {
3214+ if ret.Get(0) != nil {
3215+ r0 = ret.Get(0).(*entity.Session)
3216+ }
3217+ }
3218+ if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID) error); ok {
3219+ r1 = returnFunc(ctx, id)
3220+ } else {
3221+ r1 = ret.Error(1)
3222+ }
3223+ return r0, r1
3224+}
3225+
3226+// MockSessionRepository_FindByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByID'
3227+type MockSessionRepository_FindByID_Call struct {
3228+ *mock.Call
3229+}
3230+
3231+// FindByID is a helper method to define mock.On call
3232+// - ctx context.Context
3233+// - id entity.SessionID
3234+func (_e *MockSessionRepository_Expecter) FindByID(ctx interface{}, id interface{}) *MockSessionRepository_FindByID_Call {
3235+ return &MockSessionRepository_FindByID_Call{Call: _e.mock.On("FindByID", ctx, id)}
3236+}
3237+
3238+func (_c *MockSessionRepository_FindByID_Call) Run(run func(ctx context.Context, id entity.SessionID)) *MockSessionRepository_FindByID_Call {
3239+ _c.Call.Run(func(args mock.Arguments) {
3240+ var arg0 context.Context
3241+ if args[0] != nil {
3242+ arg0 = args[0].(context.Context)
3243+ }
3244+ var arg1 entity.SessionID
3245+ if args[1] != nil {
3246+ arg1 = args[1].(entity.SessionID)
3247+ }
3248+ run(
3249+ arg0,
3250+ arg1,
3251+ )
3252+ })
3253+ return _c
3254+}
3255+
3256+func (_c *MockSessionRepository_FindByID_Call) Return(session *entity.Session, err error) *MockSessionRepository_FindByID_Call {
3257+ _c.Call.Return(session, err)
3258+ return _c
3259+}
3260+
3261+func (_c *MockSessionRepository_FindByID_Call) RunAndReturn(run func(ctx context.Context, id entity.SessionID) (*entity.Session, error)) *MockSessionRepository_FindByID_Call {
3262+ _c.Call.Return(run)
3263+ return _c
3264+}
3265+
3266+// GetActive provides a mock function for the type MockSessionRepository
3267+func (_mock *MockSessionRepository) GetActive(ctx context.Context) (*entity.Session, error) {
3268+ ret := _mock.Called(ctx)
3269+
3270+ if len(ret) == 0 {
3271+ panic("no return value specified for GetActive")
3272+ }
3273+
3274+ var r0 *entity.Session
3275+ var r1 error
3276+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*entity.Session, error)); ok {
3277+ return returnFunc(ctx)
3278+ }
3279+ if returnFunc, ok := ret.Get(0).(func(context.Context) *entity.Session); ok {
3280+ r0 = returnFunc(ctx)
3281+ } else {
3282+ if ret.Get(0) != nil {
3283+ r0 = ret.Get(0).(*entity.Session)
3284+ }
3285+ }
3286+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
3287+ r1 = returnFunc(ctx)
3288+ } else {
3289+ r1 = ret.Error(1)
3290+ }
3291+ return r0, r1
3292+}
3293+
3294+// MockSessionRepository_GetActive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActive'
3295+type MockSessionRepository_GetActive_Call struct {
3296+ *mock.Call
3297+}
3298+
3299+// GetActive is a helper method to define mock.On call
3300+// - ctx context.Context
3301+func (_e *MockSessionRepository_Expecter) GetActive(ctx interface{}) *MockSessionRepository_GetActive_Call {
3302+ return &MockSessionRepository_GetActive_Call{Call: _e.mock.On("GetActive", ctx)}
3303+}
3304+
3305+func (_c *MockSessionRepository_GetActive_Call) Run(run func(ctx context.Context)) *MockSessionRepository_GetActive_Call {
2857 _c.Call.Run(func(args mock.Arguments) {3306 _c.Call.Run(func(args mock.Arguments) {
2858 var arg0 context.Context3307 var arg0 context.Context
2859 if args[0] != nil {3308 if args[0] != nil {
@@ -3064,6 +3513,337 @@ func (_c *MockSessionRepository_Save_Call) RunAndReturn(run func(ctx context.Con
3064 return _c3513 return _c
3065 }3514 }
3066 3515
3516+// NewMockSessionStateRepository creates a new instance of MockSessionStateRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
3517+// The first argument is typically a *testing.T value.
3518+func NewMockSessionStateRepository(t interface {
3519+ mock.TestingT
3520+ Cleanup(func())
3521+}) *MockSessionStateRepository {
3522+ mock := &MockSessionStateRepository{}
3523+ mock.Mock.Test(t)
3524+
3525+ t.Cleanup(func() { mock.AssertExpectations(t) })
3526+
3527+ return mock
3528+}
3529+
3530+// MockSessionStateRepository is an autogenerated mock type for the SessionStateRepository type
3531+type MockSessionStateRepository struct {
3532+ mock.Mock
3533+}
3534+
3535+type MockSessionStateRepository_Expecter struct {
3536+ mock *mock.Mock
3537+}
3538+
3539+func (_m *MockSessionStateRepository) EXPECT() *MockSessionStateRepository_Expecter {
3540+ return &MockSessionStateRepository_Expecter{mock: &_m.Mock}
3541+}
3542+
3543+// DeleteSnapshot provides a mock function for the type MockSessionStateRepository
3544+func (_mock *MockSessionStateRepository) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
3545+ ret := _mock.Called(ctx, sessionID)
3546+
3547+ if len(ret) == 0 {
3548+ panic("no return value specified for DeleteSnapshot")
3549+ }
3550+
3551+ var r0 error
3552+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) error); ok {
3553+ r0 = returnFunc(ctx, sessionID)
3554+ } else {
3555+ r0 = ret.Error(0)
3556+ }
3557+ return r0
3558+}
3559+
3560+// MockSessionStateRepository_DeleteSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSnapshot'
3561+type MockSessionStateRepository_DeleteSnapshot_Call struct {
3562+ *mock.Call
3563+}
3564+
3565+// DeleteSnapshot is a helper method to define mock.On call
3566+// - ctx context.Context
3567+// - sessionID entity.SessionID
3568+func (_e *MockSessionStateRepository_Expecter) DeleteSnapshot(ctx interface{}, sessionID interface{}) *MockSessionStateRepository_DeleteSnapshot_Call {
3569+ return &MockSessionStateRepository_DeleteSnapshot_Call{Call: _e.mock.On("DeleteSnapshot", ctx, sessionID)}
3570+}
3571+
3572+func (_c *MockSessionStateRepository_DeleteSnapshot_Call) Run(run func(ctx context.Context, sessionID entity.SessionID)) *MockSessionStateRepository_DeleteSnapshot_Call {
3573+ _c.Call.Run(func(args mock.Arguments) {
3574+ var arg0 context.Context
3575+ if args[0] != nil {
3576+ arg0 = args[0].(context.Context)
3577+ }
3578+ var arg1 entity.SessionID
3579+ if args[1] != nil {
3580+ arg1 = args[1].(entity.SessionID)
3581+ }
3582+ run(
3583+ arg0,
3584+ arg1,
3585+ )
3586+ })
3587+ return _c
3588+}
3589+
3590+func (_c *MockSessionStateRepository_DeleteSnapshot_Call) Return(err error) *MockSessionStateRepository_DeleteSnapshot_Call {
3591+ _c.Call.Return(err)
3592+ return _c
3593+}
3594+
3595+func (_c *MockSessionStateRepository_DeleteSnapshot_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID) error) *MockSessionStateRepository_DeleteSnapshot_Call {
3596+ _c.Call.Return(run)
3597+ return _c
3598+}
3599+
3600+// GetAllSnapshots provides a mock function for the type MockSessionStateRepository
3601+func (_mock *MockSessionStateRepository) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) {
3602+ ret := _mock.Called(ctx)
3603+
3604+ if len(ret) == 0 {
3605+ panic("no return value specified for GetAllSnapshots")
3606+ }
3607+
3608+ var r0 []*entity.SessionState
3609+ var r1 error
3610+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.SessionState, error)); ok {
3611+ return returnFunc(ctx)
3612+ }
3613+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.SessionState); ok {
3614+ r0 = returnFunc(ctx)
3615+ } else {
3616+ if ret.Get(0) != nil {
3617+ r0 = ret.Get(0).([]*entity.SessionState)
3618+ }
3619+ }
3620+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
3621+ r1 = returnFunc(ctx)
3622+ } else {
3623+ r1 = ret.Error(1)
3624+ }
3625+ return r0, r1
3626+}
3627+
3628+// MockSessionStateRepository_GetAllSnapshots_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllSnapshots'
3629+type MockSessionStateRepository_GetAllSnapshots_Call struct {
3630+ *mock.Call
3631+}
3632+
3633+// GetAllSnapshots is a helper method to define mock.On call
3634+// - ctx context.Context
3635+func (_e *MockSessionStateRepository_Expecter) GetAllSnapshots(ctx interface{}) *MockSessionStateRepository_GetAllSnapshots_Call {
3636+ return &MockSessionStateRepository_GetAllSnapshots_Call{Call: _e.mock.On("GetAllSnapshots", ctx)}
3637+}
3638+
3639+func (_c *MockSessionStateRepository_GetAllSnapshots_Call) Run(run func(ctx context.Context)) *MockSessionStateRepository_GetAllSnapshots_Call {
3640+ _c.Call.Run(func(args mock.Arguments) {
3641+ var arg0 context.Context
3642+ if args[0] != nil {
3643+ arg0 = args[0].(context.Context)
3644+ }
3645+ run(
3646+ arg0,
3647+ )
3648+ })
3649+ return _c
3650+}
3651+
3652+func (_c *MockSessionStateRepository_GetAllSnapshots_Call) Return(sessionStates []*entity.SessionState, err error) *MockSessionStateRepository_GetAllSnapshots_Call {
3653+ _c.Call.Return(sessionStates, err)
3654+ return _c
3655+}
3656+
3657+func (_c *MockSessionStateRepository_GetAllSnapshots_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.SessionState, error)) *MockSessionStateRepository_GetAllSnapshots_Call {
3658+ _c.Call.Return(run)
3659+ return _c
3660+}
3661+
3662+// GetSnapshot provides a mock function for the type MockSessionStateRepository
3663+func (_mock *MockSessionStateRepository) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) {
3664+ ret := _mock.Called(ctx, sessionID)
3665+
3666+ if len(ret) == 0 {
3667+ panic("no return value specified for GetSnapshot")
3668+ }
3669+
3670+ var r0 *entity.SessionState
3671+ var r1 error
3672+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) (*entity.SessionState, error)); ok {
3673+ return returnFunc(ctx, sessionID)
3674+ }
3675+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) *entity.SessionState); ok {
3676+ r0 = returnFunc(ctx, sessionID)
3677+ } else {
3678+ if ret.Get(0) != nil {
3679+ r0 = ret.Get(0).(*entity.SessionState)
3680+ }
3681+ }
3682+ if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID) error); ok {
3683+ r1 = returnFunc(ctx, sessionID)
3684+ } else {
3685+ r1 = ret.Error(1)
3686+ }
3687+ return r0, r1
3688+}
3689+
3690+// MockSessionStateRepository_GetSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSnapshot'
3691+type MockSessionStateRepository_GetSnapshot_Call struct {
3692+ *mock.Call
3693+}
3694+
3695+// GetSnapshot is a helper method to define mock.On call
3696+// - ctx context.Context
3697+// - sessionID entity.SessionID
3698+func (_e *MockSessionStateRepository_Expecter) GetSnapshot(ctx interface{}, sessionID interface{}) *MockSessionStateRepository_GetSnapshot_Call {
3699+ return &MockSessionStateRepository_GetSnapshot_Call{Call: _e.mock.On("GetSnapshot", ctx, sessionID)}
3700+}
3701+
3702+func (_c *MockSessionStateRepository_GetSnapshot_Call) Run(run func(ctx context.Context, sessionID entity.SessionID)) *MockSessionStateRepository_GetSnapshot_Call {
3703+ _c.Call.Run(func(args mock.Arguments) {
3704+ var arg0 context.Context
3705+ if args[0] != nil {
3706+ arg0 = args[0].(context.Context)
3707+ }
3708+ var arg1 entity.SessionID
3709+ if args[1] != nil {
3710+ arg1 = args[1].(entity.SessionID)
3711+ }
3712+ run(
3713+ arg0,
3714+ arg1,
3715+ )
3716+ })
3717+ return _c
3718+}
3719+
3720+func (_c *MockSessionStateRepository_GetSnapshot_Call) Return(sessionState *entity.SessionState, err error) *MockSessionStateRepository_GetSnapshot_Call {
3721+ _c.Call.Return(sessionState, err)
3722+ return _c
3723+}
3724+
3725+func (_c *MockSessionStateRepository_GetSnapshot_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error)) *MockSessionStateRepository_GetSnapshot_Call {
3726+ _c.Call.Return(run)
3727+ return _c
3728+}
3729+
3730+// GetTotalSnapshotsSize provides a mock function for the type MockSessionStateRepository
3731+func (_mock *MockSessionStateRepository) GetTotalSnapshotsSize(ctx context.Context) (int64, error) {
3732+ ret := _mock.Called(ctx)
3733+
3734+ if len(ret) == 0 {
3735+ panic("no return value specified for GetTotalSnapshotsSize")
3736+ }
3737+
3738+ var r0 int64
3739+ var r1 error
3740+ if returnFunc, ok := ret.Get(0).(func(context.Context) (int64, error)); ok {
3741+ return returnFunc(ctx)
3742+ }
3743+ if returnFunc, ok := ret.Get(0).(func(context.Context) int64); ok {
3744+ r0 = returnFunc(ctx)
3745+ } else {
3746+ r0 = ret.Get(0).(int64)
3747+ }
3748+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
3749+ r1 = returnFunc(ctx)
3750+ } else {
3751+ r1 = ret.Error(1)
3752+ }
3753+ return r0, r1
3754+}
3755+
3756+// MockSessionStateRepository_GetTotalSnapshotsSize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTotalSnapshotsSize'
3757+type MockSessionStateRepository_GetTotalSnapshotsSize_Call struct {
3758+ *mock.Call
3759+}
3760+
3761+// GetTotalSnapshotsSize is a helper method to define mock.On call
3762+// - ctx context.Context
3763+func (_e *MockSessionStateRepository_Expecter) GetTotalSnapshotsSize(ctx interface{}) *MockSessionStateRepository_GetTotalSnapshotsSize_Call {
3764+ return &MockSessionStateRepository_GetTotalSnapshotsSize_Call{Call: _e.mock.On("GetTotalSnapshotsSize", ctx)}
3765+}
3766+
3767+func (_c *MockSessionStateRepository_GetTotalSnapshotsSize_Call) Run(run func(ctx context.Context)) *MockSessionStateRepository_GetTotalSnapshotsSize_Call {
3768+ _c.Call.Run(func(args mock.Arguments) {
3769+ var arg0 context.Context
3770+ if args[0] != nil {
3771+ arg0 = args[0].(context.Context)
3772+ }
3773+ run(
3774+ arg0,
3775+ )
3776+ })
3777+ return _c
3778+}
3779+
3780+func (_c *MockSessionStateRepository_GetTotalSnapshotsSize_Call) Return(n int64, err error) *MockSessionStateRepository_GetTotalSnapshotsSize_Call {
3781+ _c.Call.Return(n, err)
3782+ return _c
3783+}
3784+
3785+func (_c *MockSessionStateRepository_GetTotalSnapshotsSize_Call) RunAndReturn(run func(ctx context.Context) (int64, error)) *MockSessionStateRepository_GetTotalSnapshotsSize_Call {
3786+ _c.Call.Return(run)
3787+ return _c
3788+}
3789+
3790+// SaveSnapshot provides a mock function for the type MockSessionStateRepository
3791+func (_mock *MockSessionStateRepository) SaveSnapshot(ctx context.Context, state *entity.SessionState) error {
3792+ ret := _mock.Called(ctx, state)
3793+
3794+ if len(ret) == 0 {
3795+ panic("no return value specified for SaveSnapshot")
3796+ }
3797+
3798+ var r0 error
3799+ if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.SessionState) error); ok {
3800+ r0 = returnFunc(ctx, state)
3801+ } else {
3802+ r0 = ret.Error(0)
3803+ }
3804+ return r0
3805+}
3806+
3807+// MockSessionStateRepository_SaveSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveSnapshot'
3808+type MockSessionStateRepository_SaveSnapshot_Call struct {
3809+ *mock.Call
3810+}
3811+
3812+// SaveSnapshot is a helper method to define mock.On call
3813+// - ctx context.Context
3814+// - state *entity.SessionState
3815+func (_e *MockSessionStateRepository_Expecter) SaveSnapshot(ctx interface{}, state interface{}) *MockSessionStateRepository_SaveSnapshot_Call {
3816+ return &MockSessionStateRepository_SaveSnapshot_Call{Call: _e.mock.On("SaveSnapshot", ctx, state)}
3817+}
3818+
3819+func (_c *MockSessionStateRepository_SaveSnapshot_Call) Run(run func(ctx context.Context, state *entity.SessionState)) *MockSessionStateRepository_SaveSnapshot_Call {
3820+ _c.Call.Run(func(args mock.Arguments) {
3821+ var arg0 context.Context
3822+ if args[0] != nil {
3823+ arg0 = args[0].(context.Context)
3824+ }
3825+ var arg1 *entity.SessionState
3826+ if args[1] != nil {
3827+ arg1 = args[1].(*entity.SessionState)
3828+ }
3829+ run(
3830+ arg0,
3831+ arg1,
3832+ )
3833+ })
3834+ return _c
3835+}
3836+
3837+func (_c *MockSessionStateRepository_SaveSnapshot_Call) Return(err error) *MockSessionStateRepository_SaveSnapshot_Call {
3838+ _c.Call.Return(err)
3839+ return _c
3840+}
3841+
3842+func (_c *MockSessionStateRepository_SaveSnapshot_Call) RunAndReturn(run func(ctx context.Context, state *entity.SessionState) error) *MockSessionStateRepository_SaveSnapshot_Call {
3843+ _c.Call.Return(run)
3844+ return _c
3845+}
3846+
3067 // NewMockZoomRepository creates a new instance of MockZoomRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.3847 // NewMockZoomRepository creates a new instance of MockZoomRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
3068 // The first argument is typically a *testing.T value.3848 // The first argument is typically a *testing.T value.
3069 func NewMockZoomRepository(t interface {3849 func NewMockZoomRepository(t interface {
M internal/domain/repository/session.go +11 -0
@@ -16,4 +16,15 @@ type SessionRepository interface {
16 GetActive(ctx context.Context) (*entity.Session, error)16 GetActive(ctx context.Context) (*entity.Session, error)
17 GetRecent(ctx context.Context, limit int) ([]*entity.Session, error)17 GetRecent(ctx context.Context, limit int) ([]*entity.Session, error)
18 MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error18 MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error
19+
20+ // Delete removes a session record.
21+ Delete(ctx context.Context, id entity.SessionID) error
22+
23+ // DeleteOldestExited deletes exited sessions beyond the keep limit.
24+ // Returns number of deleted sessions.
25+ DeleteOldestExited(ctx context.Context, keepCount int) (int64, error)
26+
27+ // DeleteExitedBefore deletes exited sessions older than the cutoff time.
28+ // Returns number of deleted sessions.
29+ DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error)
19 }30 }
A internal/domain/repository/session_state.go +26 -0
@@ -0,0 +1,26 @@
1+package repository
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// SessionStateRepository persists session state snapshots.
10+type SessionStateRepository interface {
11+ // SaveSnapshot saves or updates a session state snapshot.
12+ SaveSnapshot(ctx context.Context, state *entity.SessionState) error
13+
14+ // GetSnapshot returns the latest snapshot for a session.
15+ GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error)
16+
17+ // DeleteSnapshot removes a session's snapshot.
18+ DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error
19+
20+ // GetAllSnapshots returns all snapshots with summary info.
21+ // Used by ListSessionsUseCase.
22+ GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error)
23+
24+ // GetTotalSnapshotsSize returns the total size of all session snapshots in bytes.
25+ GetTotalSnapshotsSize(ctx context.Context) (int64, error)
26+}
M internal/domain/url/normalize.go +53 -0
@@ -4,11 +4,41 @@ package url
4 import (4 import (
5 "fmt"5 "fmt"
6 "net/url"6 "net/url"
7+ "os"
8+ "path/filepath"
7 "strings"9 "strings"
8 )10 )
9 11
12+// expandHome expands ~ prefix to user's home directory.
13+func expandHome(path string) string {
14+ if strings.HasPrefix(path, "~/") {
15+ if home, err := os.UserHomeDir(); err == nil {
16+ return filepath.Join(home, path[2:])
17+ }
18+ }
19+ return path
20+}
21+
22+// looksLikeFilePath returns true if the input looks like a filesystem path.
23+// This includes absolute paths (/path), relative paths (./path, ../path),
24+// and home-relative paths (~/path).
25+func looksLikeFilePath(input string) bool {
26+ switch {
27+ case strings.HasPrefix(input, "/"):
28+ return true
29+ case strings.HasPrefix(input, "./"):
30+ return true
31+ case strings.HasPrefix(input, "../"):
32+ return true
33+ case strings.HasPrefix(input, "~/"):
34+ return true
35+ }
36+ return false
37+}
38+
10 // Normalize adds https:// prefix if missing for URL-like inputs.39 // Normalize adds https:// prefix if missing for URL-like inputs.
11 // Returns the input unchanged if it already has a scheme or doesn't look like a URL.40 // Returns the input unchanged if it already has a scheme or doesn't look like a URL.
41+// If the input is an existing local file, returns a file:// URL.
12 func Normalize(input string) string {42 func Normalize(input string) string {
13 if input == "" {43 if input == "" {
14 return ""44 return ""
@@ -28,6 +58,29 @@ func Normalize(input string) string {
28 return input58 return input
29 }59 }
30 60
61+ // Check if input looks like a file path
62+ if looksLikeFilePath(input) {
63+ expanded := expandHome(input)
64+ absPath, err := filepath.Abs(expanded)
65+ if err == nil {
66+ if _, statErr := os.Stat(absPath); statErr == nil {
67+ // File exists, convert to file:// URL
68+ return "file://" + absPath
69+ }
70+ }
71+ // Path-like input but file doesn't exist - return unchanged
72+ // Don't treat as URL to avoid malformed URLs like https:///path
73+ return input
74+ }
75+
76+ // Check if input is an existing file in current directory (relative without ./ prefix)
77+ absPath, err := filepath.Abs(input)
78+ if err == nil {
79+ if _, statErr := os.Stat(absPath); statErr == nil {
80+ return "file://" + absPath
81+ }
82+ }
83+
31 // Looks like a URL (contains . and no spaces)84 // Looks like a URL (contains . and no spaces)
32 if strings.Contains(input, ".") && !strings.Contains(input, " ") {85 if strings.Contains(input, ".") && !strings.Contains(input, " ") {
33 return "https://" + input86 return "https://" + input
A internal/domain/url/normalize_test.go +270 -0
@@ -0,0 +1,270 @@
1+package url
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestNormalize(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ want string
14+ }{
15+ {
16+ name: "empty string",
17+ input: "",
18+ want: "",
19+ },
20+ {
21+ name: "http scheme unchanged",
22+ input: "http://example.com",
23+ want: "http://example.com",
24+ },
25+ {
26+ name: "https scheme unchanged",
27+ input: "https://example.com",
28+ want: "https://example.com",
29+ },
30+ {
31+ name: "file scheme unchanged",
32+ input: "file:///path/to/file.html",
33+ want: "file:///path/to/file.html",
34+ },
35+ {
36+ name: "dumb scheme unchanged",
37+ input: "dumb://home",
38+ want: "dumb://home",
39+ },
40+ {
41+ name: "about scheme unchanged",
42+ input: "about:blank",
43+ want: "about:blank",
44+ },
45+ {
46+ name: "domain gets https",
47+ input: "example.com",
48+ want: "https://example.com",
49+ },
50+ {
51+ name: "domain with path gets https",
52+ input: "example.com/path",
53+ want: "https://example.com/path",
54+ },
55+ {
56+ name: "search query unchanged",
57+ input: "hello world",
58+ want: "hello world",
59+ },
60+ {
61+ name: "single word unchanged",
62+ input: "hello",
63+ want: "hello",
64+ },
65+ }
66+
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ got := Normalize(tt.input)
70+ if got != tt.want {
71+ t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
72+ }
73+ })
74+ }
75+}
76+
77+func TestNormalize_LocalFiles(t *testing.T) {
78+ // Create a temporary file for testing
79+ tmpDir := t.TempDir()
80+ tmpFile := filepath.Join(tmpDir, "test.html")
81+ if err := os.WriteFile(tmpFile, []byte("<html></html>"), 0644); err != nil {
82+ t.Fatalf("failed to create temp file: %v", err)
83+ }
84+
85+ // Create a subdirectory with a file
86+ subDir := filepath.Join(tmpDir, "subdir")
87+ if err := os.Mkdir(subDir, 0755); err != nil {
88+ t.Fatalf("failed to create subdir: %v", err)
89+ }
90+ subFile := filepath.Join(subDir, "page.html")
91+ if err := os.WriteFile(subFile, []byte("<html></html>"), 0644); err != nil {
92+ t.Fatalf("failed to create subdir file: %v", err)
93+ }
94+
95+ tests := []struct {
96+ name string
97+ input string
98+ want string
99+ }{
100+ {
101+ name: "absolute path to existing file",
102+ input: tmpFile,
103+ want: "file://" + tmpFile,
104+ },
105+ {
106+ name: "absolute path to existing directory",
107+ input: tmpDir,
108+ want: "file://" + tmpDir,
109+ },
110+ {
111+ name: "non-existent absolute path returned unchanged",
112+ input: "/nonexistent/file.html",
113+ want: "/nonexistent/file.html",
114+ },
115+ {
116+ name: "non-existent relative path with dot prefix returned unchanged",
117+ input: "./nonexistent/file.html",
118+ want: "./nonexistent/file.html",
119+ },
120+ {
121+ name: "non-existent home path returned unchanged",
122+ input: "~/nonexistent/file.html",
123+ want: "~/nonexistent/file.html",
124+ },
125+ }
126+
127+ for _, tt := range tests {
128+ t.Run(tt.name, func(t *testing.T) {
129+ got := Normalize(tt.input)
130+ if got != tt.want {
131+ t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
132+ }
133+ })
134+ }
135+}
136+
137+func TestNormalize_RelativePaths(t *testing.T) {
138+ // Create a temp file in current directory
139+ tmpFile, err := os.CreateTemp(".", "test-*.html")
140+ if err != nil {
141+ t.Fatalf("failed to create temp file: %v", err)
142+ }
143+ tmpFile.Close()
144+ defer os.Remove(tmpFile.Name())
145+
146+ // Get absolute path for comparison
147+ absPath, err := filepath.Abs(tmpFile.Name())
148+ if err != nil {
149+ t.Fatalf("failed to get abs path: %v", err)
150+ }
151+
152+ t.Run("relative path to existing file", func(t *testing.T) {
153+ got := Normalize(tmpFile.Name())
154+ want := "file://" + absPath
155+ if got != want {
156+ t.Errorf("Normalize(%q) = %q, want %q", tmpFile.Name(), got, want)
157+ }
158+ })
159+}
160+
161+func TestExpandHome(t *testing.T) {
162+ home, err := os.UserHomeDir()
163+ if err != nil {
164+ t.Skip("cannot get home directory")
165+ }
166+
167+ tests := []struct {
168+ name string
169+ input string
170+ want string
171+ }{
172+ {
173+ name: "tilde expansion",
174+ input: "~/Documents",
175+ want: filepath.Join(home, "Documents"),
176+ },
177+ {
178+ name: "no tilde unchanged",
179+ input: "/absolute/path",
180+ want: "/absolute/path",
181+ },
182+ {
183+ name: "relative path unchanged",
184+ input: "./relative/path",
185+ want: "./relative/path",
186+ },
187+ {
188+ name: "tilde in middle unchanged",
189+ input: "/path/~/file",
190+ want: "/path/~/file",
191+ },
192+ }
193+
194+ for _, tt := range tests {
195+ t.Run(tt.name, func(t *testing.T) {
196+ got := expandHome(tt.input)
197+ if got != tt.want {
198+ t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want)
199+ }
200+ })
201+ }
202+}
203+
204+func TestLooksLikeURL(t *testing.T) {
205+ tests := []struct {
206+ name string
207+ input string
208+ want bool
209+ }{
210+ {
211+ name: "empty string",
212+ input: "",
213+ want: false,
214+ },
215+ {
216+ name: "http URL",
217+ input: "http://example.com",
218+ want: true,
219+ },
220+ {
221+ name: "https URL",
222+ input: "https://example.com",
223+ want: true,
224+ },
225+ {
226+ name: "file URL",
227+ input: "file:///path/to/file",
228+ want: true,
229+ },
230+ {
231+ name: "dumb URL",
232+ input: "dumb://home",
233+ want: true,
234+ },
235+ {
236+ name: "about URL",
237+ input: "about:blank",
238+ want: true,
239+ },
240+ {
241+ name: "domain",
242+ input: "example.com",
243+ want: true,
244+ },
245+ {
246+ name: "domain with path",
247+ input: "example.com/path",
248+ want: true,
249+ },
250+ {
251+ name: "search query",
252+ input: "hello world",
253+ want: false,
254+ },
255+ {
256+ name: "single word",
257+ input: "hello",
258+ want: false,
259+ },
260+ }
261+
262+ for _, tt := range tests {
263+ t.Run(tt.name, func(t *testing.T) {
264+ got := LooksLikeURL(tt.input)
265+ if got != tt.want {
266+ t.Errorf("LooksLikeURL(%q) = %v, want %v", tt.input, got, tt.want)
267+ }
268+ })
269+ }
270+}
A internal/infrastructure/cache/lru.go +109 -0
@@ -0,0 +1,109 @@
1+// Package cache provides cache implementations for the application layer.
2+package cache
3+
4+import (
5+ "container/list"
6+ "sync"
7+)
8+
9+// LRU is a thread-safe LRU (Least Recently Used) cache with a fixed capacity.
10+// It implements port.Cache[K, V].
11+//
12+// When the cache reaches capacity, the least recently accessed entry is evicted
13+// to make room for new entries. Both Get and Set operations mark an entry as
14+// recently used.
15+type LRU[K comparable, V any] struct {
16+ capacity int
17+ mu sync.RWMutex
18+ items map[K]*list.Element
19+ order *list.List // Front = most recent, Back = least recent
20+}
21+
22+// entry holds a key-value pair in the LRU cache.
23+type entry[K comparable, V any] struct {
24+ key K
25+ value V
26+}
27+
28+// NewLRU creates a new LRU cache with the given capacity.
29+// Capacity must be positive; if zero or negative, a capacity of 1 is used.
30+func NewLRU[K comparable, V any](capacity int) *LRU[K, V] {
31+ if capacity <= 0 {
32+ capacity = 1
33+ }
34+ return &LRU[K, V]{
35+ capacity: capacity,
36+ items: make(map[K]*list.Element),
37+ order: list.New(),
38+ }
39+}
40+
41+// Get retrieves a value by key and marks it as recently used.
42+// Returns the value and true if found, or the zero value and false if not found.
43+func (c *LRU[K, V]) Get(key K) (V, bool) {
44+ c.mu.Lock()
45+ defer c.mu.Unlock()
46+
47+ if elem, ok := c.items[key]; ok {
48+ c.order.MoveToFront(elem)
49+ return elem.Value.(*entry[K, V]).value, true
50+ }
51+ var zero V
52+ return zero, false
53+}
54+
55+// Set adds or updates a value in the cache.
56+// If the key already exists, its value is updated and it's marked as recently used.
57+// If the cache is at capacity, the least recently used entry is evicted.
58+func (c *LRU[K, V]) Set(key K, value V) {
59+ c.mu.Lock()
60+ defer c.mu.Unlock()
61+
62+ // Update existing entry
63+ if elem, ok := c.items[key]; ok {
64+ c.order.MoveToFront(elem)
65+ elem.Value.(*entry[K, V]).value = value
66+ return
67+ }
68+
69+ // Evict LRU entry if at capacity
70+ if c.order.Len() >= c.capacity {
71+ oldest := c.order.Back()
72+ if oldest != nil {
73+ c.order.Remove(oldest)
74+ delete(c.items, oldest.Value.(*entry[K, V]).key)
75+ }
76+ }
77+
78+ // Add new entry at front (most recent)
79+ elem := c.order.PushFront(&entry[K, V]{key: key, value: value})
80+ c.items[key] = elem
81+}
82+
83+// Remove deletes a key from the cache.
84+// If the key doesn't exist, this is a no-op.
85+func (c *LRU[K, V]) Remove(key K) {
86+ c.mu.Lock()
87+ defer c.mu.Unlock()
88+
89+ if elem, ok := c.items[key]; ok {
90+ c.order.Remove(elem)
91+ delete(c.items, key)
92+ }
93+}
94+
95+// Len returns the number of items currently in the cache.
96+func (c *LRU[K, V]) Len() int {
97+ c.mu.RLock()
98+ defer c.mu.RUnlock()
99+ return c.order.Len()
100+}
101+
102+// Clear removes all items from the cache.
103+func (c *LRU[K, V]) Clear() {
104+ c.mu.Lock()
105+ defer c.mu.Unlock()
106+
107+ c.items = make(map[K]*list.Element)
108+ c.order.Init()
109+}
A internal/infrastructure/cache/lru_test.go +212 -0
@@ -0,0 +1,212 @@
1+package cache
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestLRU_BasicOperations(t *testing.T) {
12+ cache := NewLRU[string, int](3)
13+
14+ // Test Set and Get
15+ cache.Set("a", 1)
16+ cache.Set("b", 2)
17+ cache.Set("c", 3)
18+
19+ val, ok := cache.Get("a")
20+ assert.True(t, ok)
21+ assert.Equal(t, 1, val)
22+
23+ val, ok = cache.Get("b")
24+ assert.True(t, ok)
25+ assert.Equal(t, 2, val)
26+
27+ // Test not found
28+ val, ok = cache.Get("notfound")
29+ assert.False(t, ok)
30+ assert.Equal(t, 0, val)
31+
32+ assert.Equal(t, 3, cache.Len())
33+}
34+
35+func TestLRU_Eviction(t *testing.T) {
36+ cache := NewLRU[string, int](2)
37+
38+ cache.Set("a", 1)
39+ cache.Set("b", 2)
40+ // Cache is now at capacity: [b, a] (b is most recent)
41+
42+ // Adding "c" should evict "a" (least recently used)
43+ cache.Set("c", 3)
44+
45+ _, ok := cache.Get("a")
46+ assert.False(t, ok, "a should have been evicted")
47+
48+ val, ok := cache.Get("b")
49+ assert.True(t, ok)
50+ assert.Equal(t, 2, val)
51+
52+ val, ok = cache.Get("c")
53+ assert.True(t, ok)
54+ assert.Equal(t, 3, val)
55+}
56+
57+func TestLRU_GetUpdatesRecency(t *testing.T) {
58+ cache := NewLRU[string, int](2)
59+
60+ cache.Set("a", 1)
61+ cache.Set("b", 2)
62+ // Order: [b, a]
63+
64+ // Access "a" to make it most recent
65+ cache.Get("a")
66+ // Order: [a, b]
67+
68+ // Adding "c" should now evict "b" (least recently used)
69+ cache.Set("c", 3)
70+
71+ val, ok := cache.Get("a")
72+ assert.True(t, ok, "a should still exist")
73+ assert.Equal(t, 1, val)
74+
75+ _, ok = cache.Get("b")
76+ assert.False(t, ok, "b should have been evicted")
77+}
78+
79+func TestLRU_UpdateExisting(t *testing.T) {
80+ cache := NewLRU[string, int](2)
81+
82+ cache.Set("a", 1)
83+ cache.Set("b", 2)
84+
85+ // Update "a" with new value
86+ cache.Set("a", 100)
87+
88+ val, ok := cache.Get("a")
89+ assert.True(t, ok)
90+ assert.Equal(t, 100, val)
91+
92+ // Cache should still have 2 items (no duplicate)
93+ assert.Equal(t, 2, cache.Len())
94+}
95+
96+func TestLRU_Remove(t *testing.T) {
97+ cache := NewLRU[string, int](3)
98+
99+ cache.Set("a", 1)
100+ cache.Set("b", 2)
101+ cache.Set("c", 3)
102+
103+ cache.Remove("b")
104+
105+ _, ok := cache.Get("b")
106+ assert.False(t, ok)
107+ assert.Equal(t, 2, cache.Len())
108+
109+ // Remove non-existent key should not panic
110+ cache.Remove("notfound")
111+ assert.Equal(t, 2, cache.Len())
112+}
113+
114+func TestLRU_Clear(t *testing.T) {
115+ cache := NewLRU[string, int](3)
116+
117+ cache.Set("a", 1)
118+ cache.Set("b", 2)
119+ cache.Set("c", 3)
120+
121+ cache.Clear()
122+
123+ assert.Equal(t, 0, cache.Len())
124+ _, ok := cache.Get("a")
125+ assert.False(t, ok)
126+}
127+
128+func TestLRU_ZeroCapacity(t *testing.T) {
129+ // Should default to capacity of 1
130+ cache := NewLRU[string, int](0)
131+
132+ cache.Set("a", 1)
133+ val, ok := cache.Get("a")
134+ assert.True(t, ok)
135+ assert.Equal(t, 1, val)
136+
137+ // Adding another should evict first
138+ cache.Set("b", 2)
139+ _, ok = cache.Get("a")
140+ assert.False(t, ok)
141+}
142+
143+func TestLRU_ConcurrentAccess(t *testing.T) {
144+ cache := NewLRU[int, int](100)
145+ var wg sync.WaitGroup
146+
147+ // Concurrent writes
148+ for i := 0; i < 100; i++ {
149+ wg.Add(1)
150+ go func(i int) {
151+ defer wg.Done()
152+ cache.Set(i, i*10)
153+ }(i)
154+ }
155+ wg.Wait()
156+
157+ // Concurrent reads
158+ for i := 0; i < 100; i++ {
159+ wg.Add(1)
160+ go func(i int) {
161+ defer wg.Done()
162+ cache.Get(i)
163+ }(i)
164+ }
165+ wg.Wait()
166+
167+ // Concurrent mixed operations
168+ for i := 0; i < 50; i++ {
169+ wg.Add(3)
170+ go func(i int) {
171+ defer wg.Done()
172+ cache.Set(i+100, i)
173+ }(i)
174+ go func(i int) {
175+ defer wg.Done()
176+ cache.Get(i)
177+ }(i)
178+ go func(i int) {
179+ defer wg.Done()
180+ cache.Remove(i + 50)
181+ }(i)
182+ }
183+ wg.Wait()
184+
185+ // Should not panic and cache should be in valid state
186+ require.LessOrEqual(t, cache.Len(), 100)
187+}
188+
189+func TestLRU_PointerValues(t *testing.T) {
190+ type Data struct {
191+ Name string
192+ Value float64
193+ }
194+
195+ cache := NewLRU[string, *Data](2)
196+
197+ cache.Set("key1", &Data{Name: "test1", Value: 1.5})
198+ cache.Set("key2", &Data{Name: "test2", Value: 2.5})
199+
200+ val, ok := cache.Get("key1")
201+ require.True(t, ok)
202+ assert.Equal(t, "test1", val.Name)
203+ assert.InDelta(t, 1.5, val.Value, 0.001)
204+
205+ // Update pointer value
206+ cache.Set("key1", &Data{Name: "updated", Value: 99.9})
207+
208+ val, ok = cache.Get("key1")
209+ require.True(t, ok)
210+ assert.Equal(t, "updated", val.Name)
211+ assert.InDelta(t, 99.9, val.Value, 0.001)
212+}
A internal/infrastructure/colorscheme/config_adapter.go +23 -0
@@ -0,0 +1,23 @@
1+package colorscheme
2+
3+import (
4+ "github.com/bnema/dumber/internal/infrastructure/config"
5+)
6+
7+// ConfigAdapter adapts config.Config to the ConfigProvider interface.
8+type ConfigAdapter struct {
9+ cfg *config.Config
10+}
11+
12+// NewConfigAdapter creates a new config adapter.
13+func NewConfigAdapter(cfg *config.Config) *ConfigAdapter {
14+ return &ConfigAdapter{cfg: cfg}
15+}
16+
17+// GetColorScheme implements ConfigProvider.
18+func (a *ConfigAdapter) GetColorScheme() string {
19+ if a.cfg == nil {
20+ return ""
21+ }
22+ return a.cfg.Appearance.ColorScheme
23+}
A internal/infrastructure/colorscheme/detector_adwaita.go +62 -0
@@ -0,0 +1,62 @@
1+package colorscheme
2+
3+import (
4+ "sync/atomic"
5+
6+ "github.com/jwijenbergh/puregotk/v4/adw"
7+)
8+
9+const (
10+ detectorNameAdwaita = "libadwaita"
11+ priorityAdwaita = 100
12+)
13+
14+// AdwaitaDetector detects color scheme from libadwaita's StyleManager.
15+// This is the most accurate detector after adw.Init() has been called,
16+// as it reflects the same state that WebKit uses for prefers-color-scheme.
17+type AdwaitaDetector struct {
18+ available atomic.Bool
19+}
20+
21+// NewAdwaitaDetector creates a new libadwaita-based detector.
22+// Initially unavailable until MarkAvailable() is called after adw.Init().
23+func NewAdwaitaDetector() *AdwaitaDetector {
24+ return &AdwaitaDetector{}
25+}
26+
27+// Name implements port.ColorSchemeDetector.
28+func (*AdwaitaDetector) Name() string {
29+ return detectorNameAdwaita
30+}
31+
32+// Priority implements port.ColorSchemeDetector.
33+func (*AdwaitaDetector) Priority() int {
34+ return priorityAdwaita
35+}
36+
37+// Available implements port.ColorSchemeDetector.
38+// Returns true only after MarkAvailable() has been called.
39+func (d *AdwaitaDetector) Available() bool {
40+ return d.available.Load()
41+}
42+
43+// MarkAvailable should be called after adw.Init() completes.
44+// This enables the detector to query StyleManager.
45+func (d *AdwaitaDetector) MarkAvailable() {
46+ d.available.Store(true)
47+}
48+
49+// Detect implements port.ColorSchemeDetector.
50+// Queries libadwaita's StyleManager for the dark mode preference.
51+func (d *AdwaitaDetector) Detect() (prefersDark, ok bool) {
52+ if !d.Available() {
53+ return false, false
54+ }
55+
56+ styleMgr := adw.StyleManagerGetDefault()
57+ if styleMgr == nil {
58+ return false, false
59+ }
60+
61+ return styleMgr.GetDark(), true
62+}
A internal/infrastructure/colorscheme/detector_env.go +50 -0
@@ -0,0 +1,50 @@
1+package colorscheme
2+
3+import (
4+ "os"
5+ "strings"
6+)
7+
8+const (
9+ detectorNameEnv = "GTK_THEME"
10+ priorityEnv = 20
11+)
12+
13+// EnvDetector detects color scheme from GTK_THEME environment variable.
14+// This is useful when users explicitly set their theme via environment.
15+type EnvDetector struct{}
16+
17+// NewEnvDetector creates a new environment variable-based detector.
18+func NewEnvDetector() *EnvDetector {
19+ return &EnvDetector{}
20+}
21+
22+// Name implements port.ColorSchemeDetector.
23+func (*EnvDetector) Name() string {
24+ return detectorNameEnv
25+}
26+
27+// Priority implements port.ColorSchemeDetector.
28+func (*EnvDetector) Priority() int {
29+ return priorityEnv
30+}
31+
32+// Available implements port.ColorSchemeDetector.
33+// Returns true if GTK_THEME environment variable is set.
34+func (*EnvDetector) Available() bool {
35+ return os.Getenv("GTK_THEME") != ""
36+}
37+
38+// Detect implements port.ColorSchemeDetector.
39+// Checks if GTK_THEME contains "dark" (case-insensitive).
40+func (*EnvDetector) Detect() (prefersDark, ok bool) {
41+ gtkTheme := os.Getenv("GTK_THEME")
42+ if gtkTheme == "" {
43+ return false, false
44+ }
45+
46+ // If GTK_THEME contains "dark", prefer dark mode
47+ // Otherwise, assume light mode
48+ prefersDark = strings.Contains(strings.ToLower(gtkTheme), "dark")
49+ return prefersDark, true
50+}
A internal/infrastructure/colorscheme/detector_gsettings.go +63 -0
@@ -0,0 +1,63 @@
1+package colorscheme
2+
3+import (
4+ "os/exec"
5+ "strings"
6+)
7+
8+const (
9+ detectorNameGsettings = "gsettings"
10+ priorityGsettings = 10
11+)
12+
13+// GsettingsDetector detects color scheme from GNOME gsettings.
14+// This is the most reliable method for GNOME-based desktops.
15+type GsettingsDetector struct{}
16+
17+// NewGsettingsDetector creates a new gsettings-based detector.
18+func NewGsettingsDetector() *GsettingsDetector {
19+ return &GsettingsDetector{}
20+}
21+
22+// Name implements port.ColorSchemeDetector.
23+func (*GsettingsDetector) Name() string {
24+ return detectorNameGsettings
25+}
26+
27+// Priority implements port.ColorSchemeDetector.
28+func (*GsettingsDetector) Priority() int {
29+ return priorityGsettings
30+}
31+
32+// Available implements port.ColorSchemeDetector.
33+// Returns true if gsettings command is available.
34+func (*GsettingsDetector) Available() bool {
35+ _, err := exec.LookPath("gsettings")
36+ return err == nil
37+}
38+
39+// Detect implements port.ColorSchemeDetector.
40+// Queries org.gnome.desktop.interface color-scheme.
41+func (*GsettingsDetector) Detect() (prefersDark, ok bool) {
42+ cmd := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
43+ output, err := cmd.Output()
44+ if err != nil {
45+ return false, false
46+ }
47+
48+ // Output is like "'prefer-dark'\n", strip quotes and whitespace
49+ result := strings.TrimSpace(string(output))
50+ result = strings.Trim(result, "'\"")
51+
52+ switch result {
53+ case "prefer-dark":
54+ return true, true
55+ case "prefer-light":
56+ return false, true
57+ case "default":
58+ // "default" means follow system, which we can't determine here
59+ return false, false
60+ default:
61+ return false, false
62+ }
63+}
A internal/infrastructure/colorscheme/resolver.go +170 -0
@@ -0,0 +1,170 @@
1+package colorscheme
2+
3+import (
4+ "cmp"
5+ "slices"
6+ "strings"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+const (
13+ // sourceFallback indicates no detector provided the preference.
14+ sourceFallback = "fallback"
15+ // sourceConfig indicates the preference came from user config.
16+ sourceConfig = "config"
17+)
18+
19+// ConfigProvider provides access to the color scheme configuration.
20+type ConfigProvider interface {
21+ // GetColorScheme returns the configured color scheme preference.
22+ // Expected values: "default", "prefer-dark", "prefer-light", "dark", "light"
23+ GetColorScheme() string
24+}
25+
26+// callbackWrapper wraps a callback function to enable pointer comparison for removal.
27+type callbackWrapper struct {
28+ fn func(port.ColorSchemePreference)
29+}
30+
31+// Resolver implements port.ColorSchemeResolver.
32+// It manages multiple detectors and respects config overrides.
33+type Resolver struct {
34+ mu sync.RWMutex
35+ config ConfigProvider
36+ detectors []port.ColorSchemeDetector
37+ sortedDetectors []port.ColorSchemeDetector // cached sorted detectors
38+ current port.ColorSchemePreference
39+ callbacks []*callbackWrapper
40+}
41+
42+// NewResolver creates a new color scheme resolver.
43+// The config provider is used to check for explicit user preferences.
44+func NewResolver(config ConfigProvider) *Resolver {
45+ return &Resolver{
46+ config: config,
47+ detectors: make([]port.ColorSchemeDetector, 0),
48+ current: port.ColorSchemePreference{
49+ PrefersDark: true, // Default to dark until first Resolve()
50+ Source: sourceFallback,
51+ },
52+ }
53+}
54+
55+// Resolve implements port.ColorSchemeResolver.
56+func (r *Resolver) Resolve() port.ColorSchemePreference {
57+ r.mu.RLock()
58+ defer r.mu.RUnlock()
59+ return r.resolveInternal()
60+}
61+
62+// resolveInternal performs the actual resolution without locking.
63+// Caller must hold at least a read lock.
64+func (r *Resolver) resolveInternal() port.ColorSchemePreference {
65+ // Check config for explicit override first
66+ if r.config != nil {
67+ scheme := r.config.GetColorScheme()
68+ switch strings.ToLower(scheme) {
69+ case "prefer-dark", "dark":
70+ return port.ColorSchemePreference{
71+ PrefersDark: true,
72+ Source: sourceConfig,
73+ }
74+ case "prefer-light", "light":
75+ return port.ColorSchemePreference{
76+ PrefersDark: false,
77+ Source: sourceConfig,
78+ }
79+ // "default" or empty falls through to detector chain
80+ }
81+ }
82+
83+ // Try each detector in priority order (already sorted)
84+ for _, detector := range r.sortedDetectors {
85+ if !detector.Available() {
86+ continue
87+ }
88+ if prefersDark, ok := detector.Detect(); ok {
89+ return port.ColorSchemePreference{
90+ PrefersDark: prefersDark,
91+ Source: detector.Name(),
92+ }
93+ }
94+ }
95+
96+ // Fallback to dark mode if all detectors fail
97+ return port.ColorSchemePreference{
98+ PrefersDark: true,
99+ Source: sourceFallback,
100+ }
101+}
102+
103+// RegisterDetector implements port.ColorSchemeResolver.
104+func (r *Resolver) RegisterDetector(detector port.ColorSchemeDetector) {
105+ r.mu.Lock()
106+ defer r.mu.Unlock()
107+ r.detectors = append(r.detectors, detector)
108+ r.rebuildSortedDetectors()
109+}
110+
111+// rebuildSortedDetectors creates a sorted copy of detectors by priority (highest first).
112+// Caller must hold the write lock.
113+func (r *Resolver) rebuildSortedDetectors() {
114+ r.sortedDetectors = make([]port.ColorSchemeDetector, len(r.detectors))
115+ copy(r.sortedDetectors, r.detectors)
116+ slices.SortFunc(r.sortedDetectors, func(a, b port.ColorSchemeDetector) int {
117+ return cmp.Compare(b.Priority(), a.Priority()) // descending order
118+ })
119+}
120+
121+// Refresh implements port.ColorSchemeResolver.
122+func (r *Resolver) Refresh() port.ColorSchemePreference {
123+ r.mu.Lock()
124+
125+ newPref := r.resolveInternal()
126+
127+ // Prepare callbacks to invoke if preference changed
128+ var callbacks []*callbackWrapper
129+ if newPref.PrefersDark != r.current.PrefersDark {
130+ r.current = newPref
131+ callbacks = make([]*callbackWrapper, len(r.callbacks))
132+ copy(callbacks, r.callbacks)
133+ } else {
134+ r.current = newPref
135+ }
136+
137+ // Release lock before invoking callbacks to avoid deadlocks
138+ r.mu.Unlock()
139+
140+ // Invoke callbacks outside of lock
141+ for _, cb := range callbacks {
142+ cb.fn(newPref)
143+ }
144+
145+ return newPref
146+}
147+
148+// OnChange implements port.ColorSchemeResolver.
149+func (r *Resolver) OnChange(callback func(port.ColorSchemePreference)) func() {
150+ r.mu.Lock()
151+ defer r.mu.Unlock()
152+
153+ // Wrap callback to enable pointer comparison for removal
154+ wrapper := &callbackWrapper{fn: callback}
155+ r.callbacks = append(r.callbacks, wrapper)
156+
157+ // Return unregister function
158+ return func() {
159+ r.mu.Lock()
160+ defer r.mu.Unlock()
161+
162+ // Find and remove callback by pointer equality
163+ for i, cb := range r.callbacks {
164+ if cb == wrapper {
165+ r.callbacks = append(r.callbacks[:i], r.callbacks[i+1:]...)
166+ return
167+ }
168+ }
169+ }
170+}
A internal/infrastructure/colorscheme/resolver_test.go +425 -0
@@ -0,0 +1,425 @@
1+package colorscheme
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// mockConfigProvider implements ConfigProvider for testing.
13+type mockConfigProvider struct {
14+ scheme string
15+}
16+
17+func (m *mockConfigProvider) GetColorScheme() string {
18+ return m.scheme
19+}
20+
21+// mockDetector implements port.ColorSchemeDetector for testing.
22+type mockDetector struct {
23+ name string
24+ priority int
25+ available bool
26+ prefersDark bool
27+ detectOk bool
28+}
29+
30+func (m *mockDetector) Name() string { return m.name }
31+func (m *mockDetector) Priority() int { return m.priority }
32+func (m *mockDetector) Available() bool { return m.available }
33+func (m *mockDetector) Detect() (bool, bool) { return m.prefersDark, m.detectOk }
34+
35+func TestResolver_ConfigOverride(t *testing.T) {
36+ tests := []struct {
37+ name string
38+ configValue string
39+ wantDark bool
40+ wantSource string
41+ }{
42+ {
43+ name: "prefer-dark from config",
44+ configValue: "prefer-dark",
45+ wantDark: true,
46+ wantSource: "config",
47+ },
48+ {
49+ name: "dark from config",
50+ configValue: "dark",
51+ wantDark: true,
52+ wantSource: "config",
53+ },
54+ {
55+ name: "prefer-light from config",
56+ configValue: "prefer-light",
57+ wantDark: false,
58+ wantSource: "config",
59+ },
60+ {
61+ name: "light from config",
62+ configValue: "light",
63+ wantDark: false,
64+ wantSource: "config",
65+ },
66+ }
67+
68+ for _, tt := range tests {
69+ t.Run(tt.name, func(t *testing.T) {
70+ config := &mockConfigProvider{scheme: tt.configValue}
71+ resolver := NewResolver(config)
72+
73+ pref := resolver.Resolve()
74+
75+ assert.Equal(t, tt.wantDark, pref.PrefersDark)
76+ assert.Equal(t, tt.wantSource, pref.Source)
77+ })
78+ }
79+}
80+
81+func TestResolver_DefaultFallsThrough(t *testing.T) {
82+ config := &mockConfigProvider{scheme: "default"}
83+ resolver := NewResolver(config)
84+
85+ // Add a detector that returns light
86+ detector := &mockDetector{
87+ name: "test",
88+ priority: 50,
89+ available: true,
90+ prefersDark: false,
91+ detectOk: true,
92+ }
93+ resolver.RegisterDetector(detector)
94+
95+ pref := resolver.Resolve()
96+
97+ assert.False(t, pref.PrefersDark)
98+ assert.Equal(t, "test", pref.Source)
99+}
100+
101+func TestResolver_DetectorPriority(t *testing.T) {
102+ config := &mockConfigProvider{scheme: "default"}
103+ resolver := NewResolver(config)
104+
105+ // Low priority detector returns dark
106+ lowPriority := &mockDetector{
107+ name: "low",
108+ priority: 10,
109+ available: true,
110+ prefersDark: true,
111+ detectOk: true,
112+ }
113+ // High priority detector returns light
114+ highPriority := &mockDetector{
115+ name: "high",
116+ priority: 100,
117+ available: true,
118+ prefersDark: false,
119+ detectOk: true,
120+ }
121+
122+ // Register low first, high second (order shouldn't matter)
123+ resolver.RegisterDetector(lowPriority)
124+ resolver.RegisterDetector(highPriority)
125+
126+ pref := resolver.Resolve()
127+
128+ // Should use high priority detector
129+ assert.False(t, pref.PrefersDark)
130+ assert.Equal(t, "high", pref.Source)
131+}
132+
133+func TestResolver_SkipsUnavailableDetector(t *testing.T) {
134+ config := &mockConfigProvider{scheme: "default"}
135+ resolver := NewResolver(config)
136+
137+ // High priority but unavailable
138+ unavailable := &mockDetector{
139+ name: "unavailable",
140+ priority: 100,
141+ available: false,
142+ prefersDark: false,
143+ detectOk: true,
144+ }
145+ // Low priority but available
146+ available := &mockDetector{
147+ name: "available",
148+ priority: 10,
149+ available: true,
150+ prefersDark: true,
151+ detectOk: true,
152+ }
153+
154+ resolver.RegisterDetector(unavailable)
155+ resolver.RegisterDetector(available)
156+
157+ pref := resolver.Resolve()
158+
159+ // Should use available detector
160+ assert.True(t, pref.PrefersDark)
161+ assert.Equal(t, "available", pref.Source)
162+}
163+
164+func TestResolver_SkipsFailedDetection(t *testing.T) {
165+ config := &mockConfigProvider{scheme: "default"}
166+ resolver := NewResolver(config)
167+
168+ // High priority but detection fails
169+ failing := &mockDetector{
170+ name: "failing",
171+ priority: 100,
172+ available: true,
173+ prefersDark: false,
174+ detectOk: false, // Detection fails
175+ }
176+ // Low priority but succeeds
177+ succeeding := &mockDetector{
178+ name: "succeeding",
179+ priority: 10,
180+ available: true,
181+ prefersDark: true,
182+ detectOk: true,
183+ }
184+
185+ resolver.RegisterDetector(failing)
186+ resolver.RegisterDetector(succeeding)
187+
188+ pref := resolver.Resolve()
189+
190+ // Should use succeeding detector
191+ assert.True(t, pref.PrefersDark)
192+ assert.Equal(t, "succeeding", pref.Source)
193+}
194+
195+func TestResolver_FallbackWhenNoDetectors(t *testing.T) {
196+ config := &mockConfigProvider{scheme: "default"}
197+ resolver := NewResolver(config)
198+
199+ // No detectors registered
200+ pref := resolver.Resolve()
201+
202+ // Should fallback to dark
203+ assert.True(t, pref.PrefersDark)
204+ assert.Equal(t, "fallback", pref.Source)
205+}
206+
207+func TestResolver_FallbackWhenAllFail(t *testing.T) {
208+ config := &mockConfigProvider{scheme: "default"}
209+ resolver := NewResolver(config)
210+
211+ // All detectors fail
212+ resolver.RegisterDetector(&mockDetector{
213+ name: "fail1",
214+ priority: 100,
215+ available: true,
216+ detectOk: false,
217+ })
218+ resolver.RegisterDetector(&mockDetector{
219+ name: "fail2",
220+ priority: 50,
221+ available: false,
222+ })
223+
224+ pref := resolver.Resolve()
225+
226+ // Should fallback to dark
227+ assert.True(t, pref.PrefersDark)
228+ assert.Equal(t, "fallback", pref.Source)
229+}
230+
231+func TestResolver_NilConfig(t *testing.T) {
232+ resolver := NewResolver(nil)
233+
234+ detector := &mockDetector{
235+ name: "test",
236+ priority: 50,
237+ available: true,
238+ prefersDark: false,
239+ detectOk: true,
240+ }
241+ resolver.RegisterDetector(detector)
242+
243+ pref := resolver.Resolve()
244+
245+ // Should use detector since config is nil
246+ assert.False(t, pref.PrefersDark)
247+ assert.Equal(t, "test", pref.Source)
248+}
249+
250+func TestResolver_Refresh(t *testing.T) {
251+ config := &mockConfigProvider{scheme: "default"}
252+ resolver := NewResolver(config)
253+
254+ // Start with light-preferring detector
255+ detector := &mockDetector{
256+ name: "test",
257+ priority: 50,
258+ available: true,
259+ prefersDark: false,
260+ detectOk: true,
261+ }
262+ resolver.RegisterDetector(detector)
263+
264+ pref1 := resolver.Refresh()
265+ assert.False(t, pref1.PrefersDark)
266+
267+ // Change detector preference
268+ detector.prefersDark = true
269+
270+ pref2 := resolver.Refresh()
271+ assert.True(t, pref2.PrefersDark)
272+}
273+
274+func TestResolver_OnChange(t *testing.T) {
275+ config := &mockConfigProvider{scheme: "default"}
276+ resolver := NewResolver(config)
277+
278+ detector := &mockDetector{
279+ name: "test",
280+ priority: 50,
281+ available: true,
282+ prefersDark: false,
283+ detectOk: true,
284+ }
285+ resolver.RegisterDetector(detector)
286+
287+ var callbackPref port.ColorSchemePreference
288+ var callbackCount int
289+ resolver.OnChange(func(pref port.ColorSchemePreference) {
290+ callbackPref = pref
291+ callbackCount++
292+ })
293+
294+ // Initial refresh - callback should be called (default was dark, now light)
295+ resolver.Refresh()
296+ assert.Equal(t, 1, callbackCount)
297+ assert.False(t, callbackPref.PrefersDark)
298+
299+ // Same preference - callback should NOT be called
300+ resolver.Refresh()
301+ assert.Equal(t, 1, callbackCount)
302+
303+ // Change preference - callback should be called
304+ detector.prefersDark = true
305+ resolver.Refresh()
306+ assert.Equal(t, 2, callbackCount)
307+ assert.True(t, callbackPref.PrefersDark)
308+}
309+
310+func TestResolver_OnChangeUnregister(t *testing.T) {
311+ config := &mockConfigProvider{scheme: "default"}
312+ resolver := NewResolver(config)
313+
314+ detector := &mockDetector{
315+ name: "test",
316+ priority: 50,
317+ available: true,
318+ prefersDark: false,
319+ detectOk: true,
320+ }
321+ resolver.RegisterDetector(detector)
322+
323+ var callbackCount int
324+ unregister := resolver.OnChange(func(_ port.ColorSchemePreference) {
325+ callbackCount++
326+ })
327+
328+ // Initial refresh triggers callback
329+ resolver.Refresh()
330+ assert.Equal(t, 1, callbackCount)
331+
332+ // Unregister
333+ unregister()
334+
335+ // Change preference - callback should NOT be called
336+ detector.prefersDark = true
337+ resolver.Refresh()
338+ assert.Equal(t, 1, callbackCount) // Still 1, not 2
339+}
340+
341+func TestResolver_ConcurrentAccess(_ *testing.T) {
342+ config := &mockConfigProvider{scheme: "default"}
343+ resolver := NewResolver(config)
344+
345+ detector := &mockDetector{
346+ name: "test",
347+ priority: 50,
348+ available: true,
349+ prefersDark: false,
350+ detectOk: true,
351+ }
352+ resolver.RegisterDetector(detector)
353+
354+ var wg sync.WaitGroup
355+ const goroutines = 10
356+
357+ // Concurrent Resolve calls
358+ for i := 0; i < goroutines; i++ {
359+ wg.Add(1)
360+ go func() {
361+ defer wg.Done()
362+ for j := 0; j < 100; j++ {
363+ resolver.Resolve()
364+ }
365+ }()
366+ }
367+
368+ // Concurrent Refresh calls
369+ for i := 0; i < goroutines; i++ {
370+ wg.Add(1)
371+ go func() {
372+ defer wg.Done()
373+ for j := 0; j < 100; j++ {
374+ resolver.Refresh()
375+ }
376+ }()
377+ }
378+
379+ // Concurrent RegisterDetector calls
380+ for i := 0; i < goroutines; i++ {
381+ wg.Add(1)
382+ go func(id int) {
383+ defer wg.Done()
384+ resolver.RegisterDetector(&mockDetector{
385+ name: "concurrent",
386+ priority: id,
387+ available: true,
388+ prefersDark: id%2 == 0,
389+ detectOk: true,
390+ })
391+ }(i)
392+ }
393+
394+ wg.Wait()
395+ // Test passes if no race conditions detected
396+}
397+
398+func TestResolver_EmptyConfigScheme(t *testing.T) {
399+ config := &mockConfigProvider{scheme: ""}
400+ resolver := NewResolver(config)
401+
402+ detector := &mockDetector{
403+ name: "test",
404+ priority: 50,
405+ available: true,
406+ prefersDark: false,
407+ detectOk: true,
408+ }
409+ resolver.RegisterDetector(detector)
410+
411+ pref := resolver.Resolve()
412+
413+ // Empty config should fall through to detector
414+ assert.False(t, pref.PrefersDark)
415+ assert.Equal(t, "test", pref.Source)
416+}
417+
418+func TestResolver_ImplementsInterface(t *testing.T) {
419+ config := &mockConfigProvider{scheme: "default"}
420+ resolver := NewResolver(config)
421+
422+ // Verify Resolver implements port.ColorSchemeResolver
423+ var _ port.ColorSchemeResolver = resolver
424+ require.NotNil(t, resolver)
425+}
M internal/infrastructure/config/defaults.go +139 -34
@@ -7,7 +7,7 @@ const (
7 defaultRetentionDays = 365 // 1 year7 defaultRetentionDays = 365 // 1 year
8 8
9 // Dmenu defaults9 // Dmenu defaults
10- defaultMaxHistoryItems = 20 // items10+ defaultMaxHistoryDays = 30 // days
11 11
12 // Logging defaults12 // Logging defaults
13 defaultMaxLogAgeDays = 7 // days13 defaultMaxLogAgeDays = 7 // days
@@ -15,33 +15,60 @@ const (
15 // Appearance defaults15 // Appearance defaults
16 defaultFontSize = 16 // points16 defaultFontSize = 16 // points
17 17
18+ // Workspace defaults
19+ defaultNewPaneURL = "about:blank"
20+
18 // Omnibox defaults21 // Omnibox defaults
19- defaultOmniboxInitialBehavior = "recent"22+ defaultOmniboxInitialBehavior = "recent"
23+ defaultOmniboxAutoOpenOnNewPane = false
20 24
21 // Workspace defaults25 // Workspace defaults
22- defaultPaneActivationShortcut = "ctrl+p"26+ defaultPaneActivationShortcut = "ctrl+p"
23- defaultPaneTimeoutMilliseconds = 300027+ defaultPaneTimeoutMilliseconds = 3000
24- defaultTabActivationShortcut = "ctrl+t"28+ defaultTabActivationShortcut = "ctrl+t"
25- defaultTabTimeoutMilliseconds = 300029+ defaultTabTimeoutMilliseconds = 3000
26- defaultTabBarPosition = "bottom"30+ defaultResizeActivationShortcut = "ctrl+n"
27- defaultPopupPlacement = "right"31+ defaultResizeTimeoutMilliseconds = 3000
32+ defaultResizeStepPercent = 5.0
33+ defaultResizeMinPanePercent = 10.0
34+ defaultTabBarPosition = "bottom"
35+ defaultPopupPlacement = "right"
36+
37+ // Session defaults
38+ defaultSessionActivationShortcut = "ctrl+o"
39+ defaultSessionTimeoutMilliseconds = 3000
40+ defaultSnapshotIntervalMs = 5000
41+ defaultMaxExitedSessions = 50
42+ defaultMaxExitedSessionAgeDays = 7
28 43
29 // Workspace styling defaults44 // Workspace styling defaults
30 // Active pane border (overlay)45 // Active pane border (overlay)
31 defaultBorderWidth = 146 defaultBorderWidth = 1
32 defaultBorderColor = "@theme_selected_bg_color"47 defaultBorderColor = "@theme_selected_bg_color"
33 48
34- // Pane mode border (Ctrl+P N - overlay)49+ // Mode border width (consolidated for all modes)
35- defaultPaneModeBorderWidth = 450+ defaultModeBorderWidth = 4
36- defaultPaneModeBorderColor = "#4A90E2" // Blue for pane mode indicator
37 51
38- // Tab mode border (Ctrl+P T - overlay)52+ // Mode colors (used for both borders and toaster)
39- defaultTabModeBorderWidth = 453+ defaultPaneModeColor = "#4A90E2" // Blue for pane mode
40- defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator54+ defaultTabModeColor = "#FFA500" // Orange for tab mode
55+ defaultSessionModeColor = "#9B59B6" // Purple for session mode
56+ defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode
57+
58+ // Mode indicator toaster
59+ defaultModeIndicatorToasterEnabled = true
41 60
42 // Other styling61 // Other styling
43 defaultTransitionDuration = 12062 defaultTransitionDuration = 120
44 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)63 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
64+
65+ // Performance defaults
66+ defaultZoomCacheSize = 256 // domains to cache (~20KB memory)
67+ defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup
68+
69+ // Skia threading defaults (0 = unset, -1 = unset for GPU threads)
70+ defaultSkiaCPUPaintingThreads = 0
71+ defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting
45 )72 )
46 73
47 // getDefaultLogDir returns the default log directory, falls back to empty string on error74 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -67,7 +94,7 @@ func DefaultConfig() *Config {
67 SearchShortcuts: GetDefaultSearchShortcuts(),94 SearchShortcuts: GetDefaultSearchShortcuts(),
68 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",95 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
69 Dmenu: DmenuConfig{96 Dmenu: DmenuConfig{
70- MaxHistoryItems: defaultMaxHistoryItems,97+ MaxHistoryDays: defaultMaxHistoryDays,
71 ShowVisitCount: true,98 ShowVisitCount: true,
72 ShowLastVisited: true,99 ShowLastVisited: true,
73 HistoryPrefix: "🕒",100 HistoryPrefix: "🕒",
@@ -117,7 +144,7 @@ func DefaultConfig() *Config {
117 DisableDMABufRenderer: false,144 DisableDMABufRenderer: false,
118 ForceCompositingMode: false,145 ForceCompositingMode: false,
119 DisableCompositingMode: false,146 DisableCompositingMode: false,
120- GSKRenderer: GSKRendererVulkan,147+ GSKRenderer: GSKRendererAuto, // Let GTK choose - Vulkan can conflict with WebKit's DMA-BUF
121 DisableMipmaps: false,148 DisableMipmaps: false,
122 PreferGL: false,149 PreferGL: false,
123 DrawCompositingIndicators: false,150 DrawCompositingIndicators: false,
@@ -128,16 +155,26 @@ func DefaultConfig() *Config {
128 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability155 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
129 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%156 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
130 Workspace: WorkspaceConfig{157 Workspace: WorkspaceConfig{
158+ NewPaneURL: defaultNewPaneURL,
159+ SwitchToTabOnMove: true,
131 PaneMode: PaneModeConfig{160 PaneMode: PaneModeConfig{
132 ActivationShortcut: defaultPaneActivationShortcut,161 ActivationShortcut: defaultPaneActivationShortcut,
133 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,162 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
134 Actions: map[string][]string{163 Actions: map[string][]string{
135- "split-right": {"arrowright", "r"},164+ "split-right": {"arrowright", "r"},
136- "split-left": {"arrowleft", "l"},165+ "split-left": {"arrowleft", "l"},
137- "split-up": {"arrowup", "u"},166+ "split-up": {"arrowup", "u"},
138- "split-down": {"arrowdown", "d"},167+ "split-down": {"arrowdown", "d"},
139- "stack-pane": {"s"},168+ "stack-pane": {"s"},
140- "close-pane": {"x"},169+ "close-pane": {"x"},
170+ "move-pane-to-tab": {"m"},
171+ "move-pane-to-next-tab": {"M", "shift+m"},
172+
173+ "consume-or-expel-left": {"bracketleft", "["},
174+ "consume-or-expel-right": {"bracketright", "]"},
175+ "consume-or-expel-up": {"shift+bracketleft", "braceleft", "{"},
176+ "consume-or-expel-down": {"shift+bracketright", "braceright", "}"},
177+
141 "focus-right": {"shift+arrowright", "shift+l"},178 "focus-right": {"shift+arrowright", "shift+l"},
142 "focus-left": {"shift+arrowleft", "shift+h"},179 "focus-left": {"shift+arrowleft", "shift+h"},
143 "focus-up": {"shift+arrowup", "shift+k"},180 "focus-up": {"shift+arrowup", "shift+k"},
@@ -159,10 +196,35 @@ func DefaultConfig() *Config {
159 "cancel": {"escape"},196 "cancel": {"escape"},
160 },197 },
161 },198 },
199+ ResizeMode: ResizeModeConfig{
200+ ActivationShortcut: defaultResizeActivationShortcut,
201+ TimeoutMilliseconds: defaultResizeTimeoutMilliseconds,
202+ StepPercent: defaultResizeStepPercent,
203+ MinPanePercent: defaultResizeMinPanePercent,
204+ Actions: map[string][]string{
205+ "resize-increase-left": {"h", "arrowleft"},
206+ "resize-increase-down": {"j", "arrowdown"},
207+ "resize-increase-up": {"k", "arrowup"},
208+ "resize-increase-right": {"l", "arrowright"},
209+ "resize-decrease-left": {"H"},
210+ "resize-decrease-down": {"J"},
211+ "resize-decrease-up": {"K"},
212+ "resize-decrease-right": {"L"},
213+ "resize-increase": {"+", "="},
214+ "resize-decrease": {"-"},
215+ "confirm": {"enter"},
216+ "cancel": {"escape"},
217+ },
218+ },
162 Shortcuts: GlobalShortcutsConfig{219 Shortcuts: GlobalShortcutsConfig{
163 ClosePane: "ctrl+w",220 ClosePane: "ctrl+w",
164 NextTab: "ctrl+tab",221 NextTab: "ctrl+tab",
165 PreviousTab: "ctrl+shift+tab",222 PreviousTab: "ctrl+shift+tab",
223+
224+ ConsumeOrExpelLeft: "alt+bracketleft",
225+ ConsumeOrExpelRight: "alt+bracketright",
226+ ConsumeOrExpelUp: "alt+shift+bracketleft",
227+ ConsumeOrExpelDown: "alt+shift+bracketright",
166 },228 },
167 TabBarPosition: defaultTabBarPosition,229 TabBarPosition: defaultTabBarPosition,
168 HideTabBarWhenSingleTab: true,230 HideTabBarWhenSingleTab: true,
@@ -176,13 +238,15 @@ func DefaultConfig() *Config {
176 OAuthAutoClose: true, // Auto-close OAuth popups on success238 OAuthAutoClose: true, // Auto-close OAuth popups on success
177 },239 },
178 Styling: WorkspaceStylingConfig{240 Styling: WorkspaceStylingConfig{
179- BorderWidth: defaultBorderWidth,241+ BorderWidth: defaultBorderWidth,
180- BorderColor: defaultBorderColor,242+ BorderColor: defaultBorderColor,
181- PaneModeBorderWidth: defaultPaneModeBorderWidth,243+ ModeBorderWidth: defaultModeBorderWidth,
182- PaneModeBorderColor: defaultPaneModeBorderColor,244+ PaneModeColor: defaultPaneModeColor,
183- TabModeBorderWidth: defaultTabModeBorderWidth,245+ TabModeColor: defaultTabModeColor,
184- TabModeBorderColor: defaultTabModeBorderColor,246+ SessionModeColor: defaultSessionModeColor,
185- TransitionDuration: defaultTransitionDuration,247+ ResizeModeColor: defaultResizeModeColor,
248+ ModeIndicatorToasterEnabled: defaultModeIndicatorToasterEnabled,
249+ TransitionDuration: defaultTransitionDuration,
186 },250 },
187 },251 },
188 ContentFiltering: ContentFilteringConfig{252 ContentFiltering: ContentFilteringConfig{
@@ -190,21 +254,62 @@ func DefaultConfig() *Config {
190 AutoUpdate: true, // Auto-update filters from GitHub releases254 AutoUpdate: true, // Auto-update filters from GitHub releases
191 },255 },
192 Omnibox: OmniboxConfig{256 Omnibox: OmniboxConfig{
193- InitialBehavior: defaultOmniboxInitialBehavior,257+ InitialBehavior: defaultOmniboxInitialBehavior,
258+ AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane,
259+ },
260+ Session: SessionConfig{
261+ AutoRestore: false,
262+ SnapshotIntervalMs: defaultSnapshotIntervalMs,
263+ MaxExitedSessions: defaultMaxExitedSessions,
264+ MaxExitedSessionAgeDays: defaultMaxExitedSessionAgeDays,
265+ SessionMode: SessionModeConfig{
266+ ActivationShortcut: defaultSessionActivationShortcut,
267+ TimeoutMilliseconds: defaultSessionTimeoutMilliseconds,
268+ Actions: map[string][]string{
269+ "session-manager": {"s", "w"},
270+ "confirm": {"enter"},
271+ "cancel": {"escape"},
272+ },
273+ },
194 },274 },
195 Media: MediaConfig{275 Media: MediaConfig{
196 HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback276 HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback
197- PreferAV1: true, // AV1 is most efficient codec277+ PreferAV1: false, // Don't force codec preference, let site choose
198- ShowDiagnosticsOnStartup: true, // Warn users if HW accel unavailable278+ ShowDiagnosticsOnStartup: false, // Disabled - diagnostics can be noisy
199 ForceVSync: false, // Let compositor handle VSync279 ForceVSync: false, // Let compositor handle VSync
200 GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API280 GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API
201 GStreamerDebugLevel: 0, // Disabled by default281 GStreamerDebugLevel: 0, // Disabled by default
202- VideoBufferSizeMB: 64, // Larger buffer for bursty streams (Twitch, YouTube)282+ VideoBufferSizeMB: 0, // Not a valid GStreamer env var, removed
203- QueueBufferTimeSec: 20, // More prebuffering for smooth playback283+ QueueBufferTimeSec: 0, // Not a valid GStreamer env var, removed
204 },284 },
205 Runtime: RuntimeConfig{285 Runtime: RuntimeConfig{
206 Prefix: "",286 Prefix: "",
207 },287 },
288+ Performance: PerformanceConfig{
289+ Profile: ProfileDefault, // Use WebKit defaults, no tuning
290+ ZoomCacheSize: defaultZoomCacheSize,
291+ WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
292+ // Skia threading - balanced defaults (unset = use WebKit defaults)
293+ // These only apply when Profile is "custom"
294+ SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads,
295+ SkiaGPUPaintingThreads: defaultSkiaGPUPaintingThreads,
296+ SkiaEnableCPURendering: false,
297+ // Web process memory pressure - all unset by default
298+ WebProcessMemoryLimitMB: 0,
299+ WebProcessMemoryPollIntervalSec: 0,
300+ WebProcessMemoryConservativeThreshold: 0,
301+ WebProcessMemoryStrictThreshold: 0,
302+ // Network process memory pressure - all unset by default
303+ NetworkProcessMemoryLimitMB: 0,
304+ NetworkProcessMemoryPollIntervalSec: 0,
305+ NetworkProcessMemoryConservativeThreshold: 0,
306+ NetworkProcessMemoryStrictThreshold: 0,
307+ },
308+ Update: UpdateConfig{
309+ EnableOnStartup: true, // Check for updates on startup by default
310+ AutoDownload: false, // Conservative: don't auto-download by default
311+ NotifyOnNewSettings: true, // Show toast when new config settings available
312+ },
208 }313 }
209 }314 }
210 315
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 +76 -5
@@ -212,6 +212,20 @@ func normalizeConfig(config *Config) {
212 }212 }
213 213
214 config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix)214 config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix)
215+ normalizePerformanceProfile(config)
216+}
217+
218+func normalizePerformanceProfile(config *Config) {
219+ switch strings.ToLower(string(config.Performance.Profile)) {
220+ case "", string(ProfileDefault):
221+ config.Performance.Profile = ProfileDefault
222+ case string(ProfileLite):
223+ config.Performance.Profile = ProfileLite
224+ case string(ProfileMax):
225+ config.Performance.Profile = ProfileMax
226+ case string(ProfileCustom):
227+ config.Performance.Profile = ProfileCustom
228+ }
215 }229 }
216 230
217 // Get returns the current configuration (thread-safe).231 // Get returns the current configuration (thread-safe).
@@ -252,6 +266,9 @@ func (m *Manager) Save(cfg *Config) error {
252 m.viper.Set("default_ui_scale", cfg.DefaultUIScale)266 m.viper.Set("default_ui_scale", cfg.DefaultUIScale)
253 m.viper.Set("default_search_engine", cfg.DefaultSearchEngine)267 m.viper.Set("default_search_engine", cfg.DefaultSearchEngine)
254 268
269+ // Performance profile (requires browser restart to take effect)
270+ m.viper.Set("performance.profile", string(cfg.Performance.Profile))
271+
255 if err := m.viper.WriteConfig(); err != nil {272 if err := m.viper.WriteConfig(); err != nil {
256 return fmt.Errorf("failed to write config: %w", err)273 return fmt.Errorf("failed to write config: %w", err)
257 }274 }
@@ -314,6 +331,9 @@ func (m *Manager) setDefaults() {
314 m.setOmniboxDefaults(defaults)331 m.setOmniboxDefaults(defaults)
315 m.setMediaDefaults(defaults)332 m.setMediaDefaults(defaults)
316 m.setRuntimeDefaults(defaults)333 m.setRuntimeDefaults(defaults)
334+ m.setSessionDefaults(defaults)
335+ m.setUpdateDefaults(defaults)
336+ m.setPerformanceDefaults(defaults)
317 }337 }
318 338
319 func (m *Manager) setHistoryDefaults(defaults *Config) {339 func (m *Manager) setHistoryDefaults(defaults *Config) {
@@ -328,7 +348,7 @@ func (m *Manager) setSearchDefaults(defaults *Config) {
328 }348 }
329 349
330 func (m *Manager) setDmenuDefaults(defaults *Config) {350 func (m *Manager) setDmenuDefaults(defaults *Config) {
331- m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)351+ m.viper.SetDefault("dmenu.max_history_days", defaults.Dmenu.MaxHistoryDays)
332 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)352 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
333 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)353 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
334 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)354 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
@@ -378,17 +398,28 @@ func (m *Manager) setRenderingDefaults(defaults *Config) {
378 }398 }
379 399
380 func (m *Manager) setWorkspaceDefaults(defaults *Config) {400 func (m *Manager) setWorkspaceDefaults(defaults *Config) {
401+ m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)
381 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)402 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
382 m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)403 m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
383 m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)404 m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
384 m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)405 m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
385 m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)406 m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
386 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)407 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
408+ m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
409+ m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
410+ m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent)
411+ m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent)
412+ m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions)
387 m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane)413 m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane)
388 m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab)414 m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab)
389 m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab)415 m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab)
416+ m.viper.SetDefault("workspace.shortcuts.consume_or_expel_left", defaults.Workspace.Shortcuts.ConsumeOrExpelLeft)
417+ m.viper.SetDefault("workspace.shortcuts.consume_or_expel_right", defaults.Workspace.Shortcuts.ConsumeOrExpelRight)
418+ m.viper.SetDefault("workspace.shortcuts.consume_or_expel_up", defaults.Workspace.Shortcuts.ConsumeOrExpelUp)
419+ m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown)
390 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)420 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
391 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)421 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
422+ m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
392 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))423 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
393 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)424 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
394 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)425 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
@@ -398,10 +429,12 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
398 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)429 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
399 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)430 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
400 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)431 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
401- m.viper.SetDefault("workspace.styling.pane_mode_border_width", defaults.Workspace.Styling.PaneModeBorderWidth)432+ m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth)
402- m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)433+ m.viper.SetDefault("workspace.styling.pane_mode_color", defaults.Workspace.Styling.PaneModeColor)
403- m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)434+ m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor)
404- m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)435+ m.viper.SetDefault("workspace.styling.session_mode_color", defaults.Workspace.Styling.SessionModeColor)
436+ m.viper.SetDefault("workspace.styling.resize_mode_color", defaults.Workspace.Styling.ResizeModeColor)
437+ m.viper.SetDefault("workspace.styling.mode_indicator_toaster_enabled", defaults.Workspace.Styling.ModeIndicatorToasterEnabled)
405 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)438 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
406 }439 }
407 440
@@ -412,6 +445,7 @@ func (m *Manager) setContentFilteringDefaults(defaults *Config) {
412 445
413 func (m *Manager) setOmniboxDefaults(defaults *Config) {446 func (m *Manager) setOmniboxDefaults(defaults *Config) {
414 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)447 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
448+ m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)
415 }449 }
416 450
417 func (m *Manager) setMediaDefaults(defaults *Config) {451 func (m *Manager) setMediaDefaults(defaults *Config) {
@@ -429,6 +463,43 @@ func (m *Manager) setRuntimeDefaults(defaults *Config) {
429 m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix)463 m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix)
430 }464 }
431 465
466+func (m *Manager) setSessionDefaults(defaults *Config) {
467+ m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore)
468+ m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs)
469+ m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions)
470+ m.viper.SetDefault("session.max_exited_session_age_days", defaults.Session.MaxExitedSessionAgeDays)
471+ m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut)
472+ m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds)
473+ m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions)
474+}
475+
476+func (m *Manager) setUpdateDefaults(defaults *Config) {
477+ m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup)
478+ m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload)
479+ m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings)
480+}
481+
482+func (m *Manager) setPerformanceDefaults(defaults *Config) {
483+ m.viper.SetDefault("performance.profile", string(defaults.Performance.Profile))
484+ m.viper.SetDefault("performance.zoom_cache_size", defaults.Performance.ZoomCacheSize)
485+ m.viper.SetDefault("performance.webview_pool_prewarm_count", defaults.Performance.WebViewPoolPrewarmCount)
486+ // Skia threading (only used when profile = "custom")
487+ m.viper.SetDefault("performance.skia_cpu_painting_threads", defaults.Performance.SkiaCPUPaintingThreads)
488+ m.viper.SetDefault("performance.skia_gpu_painting_threads", defaults.Performance.SkiaGPUPaintingThreads)
489+ m.viper.SetDefault("performance.skia_enable_cpu_rendering", defaults.Performance.SkiaEnableCPURendering)
490+ // Web process memory pressure
491+ m.viper.SetDefault("performance.web_process_memory_limit_mb", defaults.Performance.WebProcessMemoryLimitMB)
492+ m.viper.SetDefault("performance.web_process_memory_poll_interval_sec", defaults.Performance.WebProcessMemoryPollIntervalSec)
493+ m.viper.SetDefault("performance.web_process_memory_conservative_threshold", defaults.Performance.WebProcessMemoryConservativeThreshold)
494+ m.viper.SetDefault("performance.web_process_memory_strict_threshold", defaults.Performance.WebProcessMemoryStrictThreshold)
495+ // Network process memory pressure
496+ m.viper.SetDefault("performance.network_process_memory_limit_mb", defaults.Performance.NetworkProcessMemoryLimitMB)
497+ m.viper.SetDefault("performance.network_process_memory_poll_interval_sec", defaults.Performance.NetworkProcessMemoryPollIntervalSec)
498+ netConservativeThreshold := defaults.Performance.NetworkProcessMemoryConservativeThreshold
499+ m.viper.SetDefault("performance.network_process_memory_conservative_threshold", netConservativeThreshold)
500+ m.viper.SetDefault("performance.network_process_memory_strict_threshold", defaults.Performance.NetworkProcessMemoryStrictThreshold)
501+}
502+
432 // New returns a new default configuration instance.503 // New returns a new default configuration instance.
433 // This is a convenience function for getting default config without the full manager.504 // This is a convenience function for getting default config without the full manager.
434 func New() *Config {505 func New() *Config {
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+}
A internal/infrastructure/config/profile.go +307 -0
@@ -0,0 +1,307 @@
1+package config
2+
3+import (
4+ "runtime"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+)
8+
9+const (
10+ // maxSkiaCPUThreads is the maximum number of Skia CPU painting threads
11+ // supported by WebKitGTK (0-8 range).
12+ maxSkiaCPUThreads = 8
13+
14+ // maxSkiaGPUThreads is the maximum number of Skia GPU painting threads
15+ // supported by WebKitGTK (-1 to 8 range, where -1 means "let WebKit decide").
16+ maxSkiaGPUThreads = 8
17+
18+ // Memory tier thresholds (in bytes)
19+ // Explicitly typed as uint64 to prevent overflow on 32-bit systems.
20+ ramTierLow uint64 = 8 * 1024 * 1024 * 1024 // 8 GB
21+ ramTierMedium uint64 = 16 * 1024 * 1024 * 1024 // 16 GB
22+ ramTierHigh uint64 = 32 * 1024 * 1024 * 1024 // 32 GB
23+
24+ // VRAM tier thresholds (in bytes)
25+ vramTierLow uint64 = 4 * 1024 * 1024 * 1024 // 4 GB (low-end discrete / integrated)
26+ vramTierMid uint64 = 8 * 1024 * 1024 * 1024 // 8 GB (mid-range discrete)
27+ vramTierHigh uint64 = 16 * 1024 * 1024 * 1024 // 16 GB (high-end discrete)
28+
29+ // GPU thread scaling values
30+ gpuThreadsHigh = 8 // For 16GB+ VRAM
31+ gpuThreadsMid = 6 // For 8-16GB VRAM
32+ gpuThreadsLow = 4 // For 4-8GB VRAM
33+ gpuThreadsMin = 2 // For <4GB VRAM or unknown
34+
35+ // Memory limit values (in MB)
36+ webMemHigh = 4096 // For 32GB+ RAM
37+ webMemMedium = 3072 // For 16-32GB RAM
38+ webMemLow = 2048 // For 8-16GB RAM
39+ webMemMin = 1024 // For <8GB RAM
40+ netMemHigh = 1024 // For 32GB+ RAM
41+ netMemMedium = 768 // For 16-32GB RAM
42+ netMemLow = 512 // For 8-16GB RAM
43+ netMemMin = 256 // For <8GB RAM
44+ webMemFallback = 2048 // When RAM unknown
45+ netMemFallback = 512 // When RAM unknown
46+
47+ // WebView pool prewarm counts
48+ poolPrewarmHigh = 12 // For 32GB+ RAM
49+ poolPrewarmMedium = 8 // For 16-32GB RAM
50+ poolPrewarmLow = 6 // For 8-16GB RAM
51+ poolPrewarmMin = 4 // For <8GB RAM
52+ poolPrewarmFallback = 8 // When RAM unknown
53+)
54+
55+// ResolvedPerformanceSettings contains the computed performance settings
56+// after profile resolution. These are the actual values to apply.
57+type ResolvedPerformanceSettings struct {
58+ // Skia threading
59+ SkiaCPUPaintingThreads int
60+ SkiaGPUPaintingThreads int
61+ SkiaEnableCPURendering bool
62+
63+ // Web process memory pressure
64+ WebProcessMemoryLimitMB int
65+ WebProcessMemoryPollIntervalSec float64
66+ WebProcessMemoryConservativeThreshold float64
67+ WebProcessMemoryStrictThreshold float64
68+
69+ // Network process memory pressure
70+ NetworkProcessMemoryLimitMB int
71+ NetworkProcessMemoryPollIntervalSec float64
72+ NetworkProcessMemoryConservativeThreshold float64
73+ NetworkProcessMemoryStrictThreshold float64
74+
75+ // WebView pool
76+ WebViewPoolPrewarmCount int
77+}
78+
79+// ResolvePerformanceProfile computes the actual performance settings based on
80+// the selected profile. For ProfileCustom, it returns the user's configured values.
81+// For other profiles, it computes appropriate values based on detected hardware.
82+//
83+// The hw parameter is optional - if nil, fallback values are used.
84+func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) ResolvedPerformanceSettings {
85+ switch cfg.Profile {
86+ case ProfileLite:
87+ return resolveLiteProfile(hw)
88+ case ProfileMax:
89+ return resolveMaxProfile(hw)
90+ case ProfileCustom:
91+ return resolveCustomProfile(cfg)
92+ default: // ProfileDefault or empty
93+ return resolveDefaultProfile()
94+ }
95+}
96+
97+// resolveDefaultProfile returns WebKit defaults (no tuning).
98+func resolveDefaultProfile() ResolvedPerformanceSettings {
99+ return ResolvedPerformanceSettings{
100+ // All zeros/negatives mean "unset, use WebKit defaults"
101+ SkiaCPUPaintingThreads: 0,
102+ SkiaGPUPaintingThreads: -1,
103+ SkiaEnableCPURendering: false,
104+
105+ WebProcessMemoryLimitMB: 0,
106+ WebProcessMemoryPollIntervalSec: 0,
107+ WebProcessMemoryConservativeThreshold: 0,
108+ WebProcessMemoryStrictThreshold: 0,
109+
110+ NetworkProcessMemoryLimitMB: 0,
111+ NetworkProcessMemoryPollIntervalSec: 0,
112+ NetworkProcessMemoryConservativeThreshold: 0,
113+ NetworkProcessMemoryStrictThreshold: 0,
114+
115+ WebViewPoolPrewarmCount: 4,
116+ }
117+}
118+
119+// resolveLiteProfile returns settings optimized for low-RAM systems.
120+// Uses hardware info to scale CPU threads if available.
121+func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {
122+ // Use 2 CPU threads, or scale down on very low-core systems
123+ cpuThreads := 2
124+ if hw != nil && hw.CPUCores > 0 && hw.CPUCores < 4 {
125+ cpuThreads = 1
126+ }
127+
128+ return ResolvedPerformanceSettings{
129+ SkiaCPUPaintingThreads: cpuThreads,
130+ SkiaGPUPaintingThreads: -1, // unset
131+ SkiaEnableCPURendering: false,
132+
133+ WebProcessMemoryLimitMB: 768,
134+ WebProcessMemoryPollIntervalSec: 0, // use WebKit default (30s)
135+ WebProcessMemoryConservativeThreshold: 0.25,
136+ WebProcessMemoryStrictThreshold: 0.4,
137+
138+ NetworkProcessMemoryLimitMB: 384,
139+ NetworkProcessMemoryPollIntervalSec: 0,
140+ NetworkProcessMemoryConservativeThreshold: 0.25,
141+ NetworkProcessMemoryStrictThreshold: 0.4,
142+
143+ WebViewPoolPrewarmCount: 2,
144+ }
145+}
146+
147+// resolveMaxProfile returns settings optimized for maximum responsiveness.
148+// Scales values based on detected hardware capabilities.
149+func resolveMaxProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {
150+ // CPU threads: use physical cores (not threads), capped at WebKitGTK limit
151+ cpuThreads := computeMaxCPUThreads(hw)
152+
153+ // GPU threads: scale based on VRAM
154+ gpuThreads := computeMaxGPUThreads(hw)
155+
156+ // WebView pool: scale based on RAM
157+ poolPrewarm := computeMaxPoolPrewarm(hw)
158+
159+ return ResolvedPerformanceSettings{
160+ SkiaCPUPaintingThreads: cpuThreads,
161+ SkiaGPUPaintingThreads: gpuThreads,
162+ SkiaEnableCPURendering: false,
163+
164+ // Max profile: no memory limits, let WebKit use defaults
165+ WebProcessMemoryLimitMB: 0,
166+ WebProcessMemoryPollIntervalSec: 0,
167+ WebProcessMemoryConservativeThreshold: 0,
168+ WebProcessMemoryStrictThreshold: 0,
169+
170+ NetworkProcessMemoryLimitMB: 0,
171+ NetworkProcessMemoryPollIntervalSec: 0,
172+ NetworkProcessMemoryConservativeThreshold: 0,
173+ NetworkProcessMemoryStrictThreshold: 0,
174+
175+ WebViewPoolPrewarmCount: poolPrewarm,
176+ }
177+}
178+
179+// computeMaxCPUThreads calculates Skia CPU threads for max profile.
180+// Uses physical cores (not hyperthreaded) capped at WebKitGTK's limit.
181+func computeMaxCPUThreads(hw *port.HardwareInfo) int {
182+ var cpuThreads int
183+
184+ if hw != nil && hw.CPUCores > 0 {
185+ // Use physical cores directly - they're better for parallel painting
186+ cpuThreads = hw.CPUCores
187+ } else {
188+ // Fallback: assume hyperthreading, use half of logical CPUs
189+ cpuThreads = runtime.NumCPU() / 2
190+ }
191+
192+ // Minimum 4 for meaningful parallelism
193+ if cpuThreads < 4 {
194+ cpuThreads = 4
195+ }
196+ // Cap at WebKitGTK Skia limit
197+ if cpuThreads > maxSkiaCPUThreads {
198+ cpuThreads = maxSkiaCPUThreads
199+ }
200+
201+ return cpuThreads
202+}
203+
204+// computeMaxGPUThreads calculates Skia GPU threads based on VRAM.
205+func computeMaxGPUThreads(hw *port.HardwareInfo) int {
206+ if hw == nil || hw.VRAM == 0 {
207+ // No VRAM info - use conservative default
208+ return gpuThreadsMin
209+ }
210+
211+ // Scale GPU threads based on VRAM tiers
212+ switch {
213+ case hw.VRAM >= vramTierHigh: // 16GB+ VRAM (high-end: RX 7900, RTX 4080+)
214+ return gpuThreadsHigh
215+ case hw.VRAM >= vramTierMid: // 8-16GB VRAM (mid-range: RX 7800, RTX 4070)
216+ return gpuThreadsMid
217+ case hw.VRAM >= vramTierLow: // 4-8GB VRAM (entry discrete)
218+ return gpuThreadsLow
219+ default: // <4GB VRAM (integrated / old discrete)
220+ return gpuThreadsMin
221+ }
222+}
223+
224+// computeMaxMemoryLimits returns web process and network process memory limits.
225+func computeMaxMemoryLimits(hw *port.HardwareInfo) (webMB, netMB int) {
226+ if hw == nil || hw.TotalRAM == 0 {
227+ // Fallback: assume 16GB system
228+ return webMemFallback, netMemFallback
229+ }
230+
231+ // Scale memory limits based on system RAM
232+ switch {
233+ case hw.TotalRAM >= ramTierHigh: // 32GB+ RAM
234+ return webMemHigh, netMemHigh
235+ case hw.TotalRAM >= ramTierMedium: // 16-32GB RAM
236+ return webMemMedium, netMemMedium
237+ case hw.TotalRAM >= ramTierLow: // 8-16GB RAM
238+ return webMemLow, netMemLow
239+ default: // <8GB RAM
240+ return webMemMin, netMemMin
241+ }
242+}
243+
244+// computeMaxPoolPrewarm returns WebView pool prewarm count based on RAM.
245+func computeMaxPoolPrewarm(hw *port.HardwareInfo) int {
246+ if hw == nil || hw.TotalRAM == 0 {
247+ return poolPrewarmFallback
248+ }
249+
250+ switch {
251+ case hw.TotalRAM >= ramTierHigh: // 32GB+
252+ return poolPrewarmHigh
253+ case hw.TotalRAM >= ramTierMedium: // 16-32GB
254+ return poolPrewarmMedium
255+ case hw.TotalRAM >= ramTierLow: // 8-16GB
256+ return poolPrewarmLow
257+ default: // <8GB
258+ return poolPrewarmMin
259+ }
260+}
261+
262+// resolveCustomProfile returns the user's configured values directly.
263+func resolveCustomProfile(cfg *PerformanceConfig) ResolvedPerformanceSettings {
264+ return ResolvedPerformanceSettings{
265+ SkiaCPUPaintingThreads: cfg.SkiaCPUPaintingThreads,
266+ SkiaGPUPaintingThreads: cfg.SkiaGPUPaintingThreads,
267+ SkiaEnableCPURendering: cfg.SkiaEnableCPURendering,
268+
269+ WebProcessMemoryLimitMB: cfg.WebProcessMemoryLimitMB,
270+ WebProcessMemoryPollIntervalSec: cfg.WebProcessMemoryPollIntervalSec,
271+ WebProcessMemoryConservativeThreshold: cfg.WebProcessMemoryConservativeThreshold,
272+ WebProcessMemoryStrictThreshold: cfg.WebProcessMemoryStrictThreshold,
273+
274+ NetworkProcessMemoryLimitMB: cfg.NetworkProcessMemoryLimitMB,
275+ NetworkProcessMemoryPollIntervalSec: cfg.NetworkProcessMemoryPollIntervalSec,
276+ NetworkProcessMemoryConservativeThreshold: cfg.NetworkProcessMemoryConservativeThreshold,
277+ NetworkProcessMemoryStrictThreshold: cfg.NetworkProcessMemoryStrictThreshold,
278+
279+ WebViewPoolPrewarmCount: cfg.WebViewPoolPrewarmCount,
280+ }
281+}
282+
283+// HasCustomPerformanceFields returns true if any individual performance tuning
284+// fields are set to non-default values. Used for validation.
285+func HasCustomPerformanceFields(cfg *PerformanceConfig) bool {
286+ return cfg.SkiaCPUPaintingThreads != 0 ||
287+ cfg.SkiaGPUPaintingThreads != -1 ||
288+ cfg.SkiaEnableCPURendering ||
289+ cfg.WebProcessMemoryLimitMB != 0 ||
290+ cfg.WebProcessMemoryPollIntervalSec != 0 ||
291+ cfg.WebProcessMemoryConservativeThreshold != 0 ||
292+ cfg.WebProcessMemoryStrictThreshold != 0 ||
293+ cfg.NetworkProcessMemoryLimitMB != 0 ||
294+ cfg.NetworkProcessMemoryPollIntervalSec != 0 ||
295+ cfg.NetworkProcessMemoryConservativeThreshold != 0 ||
296+ cfg.NetworkProcessMemoryStrictThreshold != 0
297+}
298+
299+// IsValidPerformanceProfile returns true if the profile name is recognized.
300+func IsValidPerformanceProfile(profile PerformanceProfile) bool {
301+ switch profile {
302+ case ProfileDefault, ProfileLite, ProfileMax, ProfileCustom, "":
303+ return true
304+ default:
305+ return false
306+ }
307+}
A internal/infrastructure/config/profile_test.go +401 -0
@@ -0,0 +1,401 @@
1+package config
2+
3+import (
4+ "runtime"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+func TestResolvePerformanceProfile_Default(t *testing.T) {
11+ cfg := &PerformanceConfig{
12+ Profile: ProfileDefault,
13+ }
14+
15+ result := ResolvePerformanceProfile(cfg, nil)
16+
17+ // Default should have no tuning (unset values)
18+ if result.SkiaCPUPaintingThreads != 0 {
19+ t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads)
20+ }
21+ if result.SkiaGPUPaintingThreads != -1 {
22+ t.Errorf("expected SkiaGPUPaintingThreads=-1, got %d", result.SkiaGPUPaintingThreads)
23+ }
24+ if result.WebProcessMemoryLimitMB != 0 {
25+ t.Errorf("expected WebProcessMemoryLimitMB=0, got %d", result.WebProcessMemoryLimitMB)
26+ }
27+ if result.WebViewPoolPrewarmCount != 4 {
28+ t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount)
29+ }
30+}
31+
32+func TestResolvePerformanceProfile_Empty(t *testing.T) {
33+ cfg := &PerformanceConfig{
34+ Profile: "", // Empty should behave like default
35+ }
36+
37+ result := ResolvePerformanceProfile(cfg, nil)
38+
39+ if result.SkiaCPUPaintingThreads != 0 {
40+ t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads)
41+ }
42+ if result.WebViewPoolPrewarmCount != 4 {
43+ t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount)
44+ }
45+}
46+
47+func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) {
48+ cfg := &PerformanceConfig{
49+ Profile: ProfileLite,
50+ }
51+
52+ result := ResolvePerformanceProfile(cfg, nil)
53+
54+ // Lite without hardware info should use default lite values
55+ if result.SkiaCPUPaintingThreads != 2 {
56+ t.Errorf("expected SkiaCPUPaintingThreads=2, got %d", result.SkiaCPUPaintingThreads)
57+ }
58+ if result.WebProcessMemoryLimitMB != 768 {
59+ t.Errorf("expected WebProcessMemoryLimitMB=768, got %d", result.WebProcessMemoryLimitMB)
60+ }
61+ if result.NetworkProcessMemoryLimitMB != 384 {
62+ t.Errorf("expected NetworkProcessMemoryLimitMB=384, got %d", result.NetworkProcessMemoryLimitMB)
63+ }
64+ if result.WebViewPoolPrewarmCount != 2 {
65+ t.Errorf("expected WebViewPoolPrewarmCount=2, got %d", result.WebViewPoolPrewarmCount)
66+ }
67+}
68+
69+func TestResolvePerformanceProfile_Lite_LowCoreSystem(t *testing.T) {
70+ cfg := &PerformanceConfig{
71+ Profile: ProfileLite,
72+ }
73+ hw := &port.HardwareInfo{
74+ CPUCores: 2, // Very low core count
75+ CPUThreads: 4,
76+ }
77+
78+ result := ResolvePerformanceProfile(cfg, hw)
79+
80+ // Lite on low-core system should scale down
81+ if result.SkiaCPUPaintingThreads != 1 {
82+ t.Errorf("expected SkiaCPUPaintingThreads=1 for 2-core system, got %d", result.SkiaCPUPaintingThreads)
83+ }
84+}
85+
86+func TestResolvePerformanceProfile_Max_NoHardware(t *testing.T) {
87+ cfg := &PerformanceConfig{
88+ Profile: ProfileMax,
89+ }
90+
91+ result := ResolvePerformanceProfile(cfg, nil)
92+
93+ // Max without hardware info should use fallback values
94+ // CPU threads: NumCPU/2 (assuming hyperthreading), clamped to [4, 8]
95+ expectedCPUThreads := runtime.NumCPU() / 2
96+ if expectedCPUThreads < 4 {
97+ expectedCPUThreads = 4
98+ }
99+ if expectedCPUThreads > 8 {
100+ expectedCPUThreads = 8
101+ }
102+ if result.SkiaCPUPaintingThreads != expectedCPUThreads {
103+ t.Errorf("expected SkiaCPUPaintingThreads=%d, got %d", expectedCPUThreads, result.SkiaCPUPaintingThreads)
104+ }
105+ // GPU threads: conservative default when no VRAM info
106+ if result.SkiaGPUPaintingThreads != 2 {
107+ t.Errorf("expected SkiaGPUPaintingThreads=2 (fallback), got %d", result.SkiaGPUPaintingThreads)
108+ }
109+ // Max profile: no memory limits (unset)
110+ if result.WebProcessMemoryLimitMB != 0 {
111+ t.Errorf("expected WebProcessMemoryLimitMB=0 (unset), got %d", result.WebProcessMemoryLimitMB)
112+ }
113+ if result.WebViewPoolPrewarmCount != 8 {
114+ t.Errorf("expected WebViewPoolPrewarmCount=8, got %d", result.WebViewPoolPrewarmCount)
115+ }
116+}
117+
118+func TestResolvePerformanceProfile_Max_HighEndSystem(t *testing.T) {
119+ cfg := &PerformanceConfig{
120+ Profile: ProfileMax,
121+ }
122+ hw := &port.HardwareInfo{
123+ CPUCores: 16,
124+ CPUThreads: 32,
125+ TotalRAM: 64 * 1024 * 1024 * 1024, // 64 GB
126+ VRAM: 16 * 1024 * 1024 * 1024, // 16 GB (high-end GPU)
127+ GPUVendor: port.GPUVendorAMD,
128+ }
129+
130+ result := ResolvePerformanceProfile(cfg, hw)
131+
132+ // CPU threads: use physical cores capped at 8 (WebKitGTK limit)
133+ if result.SkiaCPUPaintingThreads != 8 {
134+ t.Errorf("expected SkiaCPUPaintingThreads=8 (capped), got %d", result.SkiaCPUPaintingThreads)
135+ }
136+ // GPU threads: high-end tier (16GB+ VRAM)
137+ if result.SkiaGPUPaintingThreads != 8 {
138+ t.Errorf("expected SkiaGPUPaintingThreads=8 for 16GB VRAM, got %d", result.SkiaGPUPaintingThreads)
139+ }
140+ // Max profile: no memory limits (unset)
141+ if result.WebProcessMemoryLimitMB != 0 {
142+ t.Errorf("expected WebProcessMemoryLimitMB=0 (unset) for max profile, got %d", result.WebProcessMemoryLimitMB)
143+ }
144+ if result.NetworkProcessMemoryLimitMB != 0 {
145+ t.Errorf("expected NetworkProcessMemoryLimitMB=0 (unset) for max profile, got %d", result.NetworkProcessMemoryLimitMB)
146+ }
147+ // Pool prewarm: high-end tier
148+ if result.WebViewPoolPrewarmCount != 12 {
149+ t.Errorf("expected WebViewPoolPrewarmCount=12 for 64GB RAM, got %d", result.WebViewPoolPrewarmCount)
150+ }
151+}
152+
153+func TestResolvePerformanceProfile_Max_MidRangeSystem(t *testing.T) {
154+ cfg := &PerformanceConfig{
155+ Profile: ProfileMax,
156+ }
157+ hw := &port.HardwareInfo{
158+ CPUCores: 8,
159+ CPUThreads: 16,
160+ TotalRAM: 16 * 1024 * 1024 * 1024, // 16 GB
161+ VRAM: 8 * 1024 * 1024 * 1024, // 8 GB (mid-range GPU)
162+ GPUVendor: port.GPUVendorNVIDIA,
163+ }
164+
165+ result := ResolvePerformanceProfile(cfg, hw)
166+
167+ // CPU threads: use physical cores (8)
168+ if result.SkiaCPUPaintingThreads != 8 {
169+ t.Errorf("expected SkiaCPUPaintingThreads=8, got %d", result.SkiaCPUPaintingThreads)
170+ }
171+ // GPU threads: mid-range tier (8-16GB VRAM)
172+ if result.SkiaGPUPaintingThreads != 6 {
173+ t.Errorf("expected SkiaGPUPaintingThreads=6 for 8GB VRAM, got %d", result.SkiaGPUPaintingThreads)
174+ }
175+ // Max profile: no memory limits (unset)
176+ if result.WebProcessMemoryLimitMB != 0 {
177+ t.Errorf("expected WebProcessMemoryLimitMB=0 (unset) for max profile, got %d", result.WebProcessMemoryLimitMB)
178+ }
179+ // Pool prewarm: mid-range tier
180+ if result.WebViewPoolPrewarmCount != 8 {
181+ t.Errorf("expected WebViewPoolPrewarmCount=8 for 16GB RAM, got %d", result.WebViewPoolPrewarmCount)
182+ }
183+}
184+
185+func TestResolvePerformanceProfile_Max_LowEndSystem(t *testing.T) {
186+ cfg := &PerformanceConfig{
187+ Profile: ProfileMax,
188+ }
189+ hw := &port.HardwareInfo{
190+ CPUCores: 4,
191+ CPUThreads: 4,
192+ TotalRAM: 4 * 1024 * 1024 * 1024, // 4 GB
193+ VRAM: 2 * 1024 * 1024 * 1024, // 2 GB (integrated/old discrete)
194+ GPUVendor: port.GPUVendorIntel,
195+ }
196+
197+ result := ResolvePerformanceProfile(cfg, hw)
198+
199+ // CPU threads: use physical cores (4)
200+ if result.SkiaCPUPaintingThreads != 4 {
201+ t.Errorf("expected SkiaCPUPaintingThreads=4, got %d", result.SkiaCPUPaintingThreads)
202+ }
203+ // GPU threads: low tier (<4GB VRAM)
204+ if result.SkiaGPUPaintingThreads != 2 {
205+ t.Errorf("expected SkiaGPUPaintingThreads=2 for 2GB VRAM, got %d", result.SkiaGPUPaintingThreads)
206+ }
207+ // Max profile: no memory limits (unset)
208+ if result.WebProcessMemoryLimitMB != 0 {
209+ t.Errorf("expected WebProcessMemoryLimitMB=0 (unset) for max profile, got %d", result.WebProcessMemoryLimitMB)
210+ }
211+ // Pool prewarm: low tier
212+ if result.WebViewPoolPrewarmCount != 4 {
213+ t.Errorf("expected WebViewPoolPrewarmCount=4 for 4GB RAM, got %d", result.WebViewPoolPrewarmCount)
214+ }
215+}
216+
217+func TestResolvePerformanceProfile_Custom(t *testing.T) {
218+ cfg := &PerformanceConfig{
219+ Profile: ProfileCustom,
220+ SkiaCPUPaintingThreads: 6,
221+ SkiaGPUPaintingThreads: 3,
222+ SkiaEnableCPURendering: true,
223+ WebProcessMemoryLimitMB: 1024,
224+ WebProcessMemoryConservativeThreshold: 0.4,
225+ WebViewPoolPrewarmCount: 5,
226+ }
227+ // Hardware info should be ignored for custom profile
228+ hw := &port.HardwareInfo{
229+ CPUCores: 16,
230+ CPUThreads: 32,
231+ TotalRAM: 64 * 1024 * 1024 * 1024,
232+ VRAM: 16 * 1024 * 1024 * 1024,
233+ }
234+
235+ result := ResolvePerformanceProfile(cfg, hw)
236+
237+ // Custom should use user-specified values, ignoring hardware
238+ if result.SkiaCPUPaintingThreads != 6 {
239+ t.Errorf("expected SkiaCPUPaintingThreads=6, got %d", result.SkiaCPUPaintingThreads)
240+ }
241+ if result.SkiaGPUPaintingThreads != 3 {
242+ t.Errorf("expected SkiaGPUPaintingThreads=3, got %d", result.SkiaGPUPaintingThreads)
243+ }
244+ if !result.SkiaEnableCPURendering {
245+ t.Error("expected SkiaEnableCPURendering=true")
246+ }
247+ if result.WebProcessMemoryLimitMB != 1024 {
248+ t.Errorf("expected WebProcessMemoryLimitMB=1024, got %d", result.WebProcessMemoryLimitMB)
249+ }
250+ if result.WebViewPoolPrewarmCount != 5 {
251+ t.Errorf("expected WebViewPoolPrewarmCount=5, got %d", result.WebViewPoolPrewarmCount)
252+ }
253+}
254+
255+func TestHasCustomPerformanceFields(t *testing.T) {
256+ tests := []struct {
257+ name string
258+ cfg PerformanceConfig
259+ expected bool
260+ }{
261+ {
262+ name: "all defaults - no custom fields",
263+ cfg: PerformanceConfig{
264+ SkiaCPUPaintingThreads: 0,
265+ SkiaGPUPaintingThreads: -1,
266+ SkiaEnableCPURendering: false,
267+ },
268+ expected: false,
269+ },
270+ {
271+ name: "skia cpu threads set",
272+ cfg: PerformanceConfig{
273+ SkiaCPUPaintingThreads: 4,
274+ SkiaGPUPaintingThreads: -1,
275+ },
276+ expected: true,
277+ },
278+ {
279+ name: "memory limit set",
280+ cfg: PerformanceConfig{
281+ SkiaGPUPaintingThreads: -1,
282+ WebProcessMemoryLimitMB: 1024,
283+ },
284+ expected: true,
285+ },
286+ {
287+ name: "conservative threshold set",
288+ cfg: PerformanceConfig{
289+ SkiaGPUPaintingThreads: -1,
290+ WebProcessMemoryConservativeThreshold: 0.4,
291+ },
292+ expected: true,
293+ },
294+ }
295+
296+ for _, tt := range tests {
297+ t.Run(tt.name, func(t *testing.T) {
298+ result := HasCustomPerformanceFields(&tt.cfg)
299+ if result != tt.expected {
300+ t.Errorf("expected %v, got %v", tt.expected, result)
301+ }
302+ })
303+ }
304+}
305+
306+func TestIsValidPerformanceProfile(t *testing.T) {
307+ tests := []struct {
308+ profile PerformanceProfile
309+ expected bool
310+ }{
311+ {ProfileDefault, true},
312+ {ProfileLite, true},
313+ {ProfileMax, true},
314+ {ProfileCustom, true},
315+ {"", true}, // Empty is valid (treated as default)
316+ {"invalid", false},
317+ {"LITE", false}, // Case sensitive
318+ {"Maximum", false},
319+ }
320+
321+ for _, tt := range tests {
322+ t.Run(string(tt.profile), func(t *testing.T) {
323+ result := IsValidPerformanceProfile(tt.profile)
324+ if result != tt.expected {
325+ t.Errorf("IsValidPerformanceProfile(%q): expected %v, got %v", tt.profile, tt.expected, result)
326+ }
327+ })
328+ }
329+}
330+
331+func TestComputeMaxGPUThreads_VRAMTiers(t *testing.T) {
332+ tests := []struct {
333+ name string
334+ vramGB int
335+ expected int
336+ }{
337+ {"no VRAM info", 0, 2},
338+ {"2GB VRAM (integrated)", 2, 2},
339+ {"4GB VRAM (entry discrete)", 4, 4},
340+ {"6GB VRAM (low-mid discrete)", 6, 4},
341+ {"8GB VRAM (mid discrete)", 8, 6},
342+ {"12GB VRAM (mid-high discrete)", 12, 6},
343+ {"16GB VRAM (high-end)", 16, 8},
344+ {"24GB VRAM (workstation)", 24, 8},
345+ }
346+
347+ for _, tt := range tests {
348+ t.Run(tt.name, func(t *testing.T) {
349+ hw := &port.HardwareInfo{
350+ VRAM: uint64(tt.vramGB) * 1024 * 1024 * 1024,
351+ }
352+ if tt.vramGB == 0 {
353+ hw = nil
354+ }
355+ result := computeMaxGPUThreads(hw)
356+ if result != tt.expected {
357+ t.Errorf("computeMaxGPUThreads(%dGB VRAM): expected %d, got %d", tt.vramGB, tt.expected, result)
358+ }
359+ })
360+ }
361+}
362+
363+func TestComputeMaxMemoryLimits_RAMTiers(t *testing.T) {
364+ tests := []struct {
365+ name string
366+ ramGB int
367+ expectedWeb int
368+ expectedNet int
369+ expectedPool int
370+ }{
371+ {"no RAM info", 0, 2048, 512, 8},
372+ {"4GB RAM", 4, 1024, 256, 4},
373+ {"8GB RAM", 8, 2048, 512, 6},
374+ {"16GB RAM", 16, 3072, 768, 8},
375+ {"32GB RAM", 32, 4096, 1024, 12},
376+ {"64GB RAM", 64, 4096, 1024, 12},
377+ }
378+
379+ for _, tt := range tests {
380+ t.Run(tt.name, func(t *testing.T) {
381+ hw := &port.HardwareInfo{
382+ TotalRAM: uint64(tt.ramGB) * 1024 * 1024 * 1024,
383+ }
384+ if tt.ramGB == 0 {
385+ hw = nil
386+ }
387+ webMB, netMB := computeMaxMemoryLimits(hw)
388+ poolPrewarm := computeMaxPoolPrewarm(hw)
389+
390+ if webMB != tt.expectedWeb {
391+ t.Errorf("computeMaxMemoryLimits(%dGB RAM) web: expected %d, got %d", tt.ramGB, tt.expectedWeb, webMB)
392+ }
393+ if netMB != tt.expectedNet {
394+ t.Errorf("computeMaxMemoryLimits(%dGB RAM) net: expected %d, got %d", tt.ramGB, tt.expectedNet, netMB)
395+ }
396+ if poolPrewarm != tt.expectedPool {
397+ t.Errorf("computeMaxPoolPrewarm(%dGB RAM): expected %d, got %d", tt.ramGB, tt.expectedPool, poolPrewarm)
398+ }
399+ })
400+ }
401+}
M internal/infrastructure/config/schema.go +196 -11
@@ -17,6 +17,8 @@ type Config struct {
17 DefaultUIScale float64 `mapstructure:"default_ui_scale" yaml:"default_ui_scale" toml:"default_ui_scale"`17 DefaultUIScale float64 `mapstructure:"default_ui_scale" yaml:"default_ui_scale" toml:"default_ui_scale"`
18 // Workspace defines workspace, pane, and tab handling behavior.18 // Workspace defines workspace, pane, and tab handling behavior.
19 Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"`19 Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"`
20+ // Session controls session persistence and restoration.
21+ Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"`
20 // ContentFiltering controls ad blocking and content filtering22 // ContentFiltering controls ad blocking and content filtering
21 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`23 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`
22 // Omnibox controls the omnibox behavior (initial history display)24 // Omnibox controls the omnibox behavior (initial history display)
@@ -27,6 +29,10 @@ type Config struct {
27 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`29 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`
28 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).30 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).
29 Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"`31 Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"`
32+ // Performance holds internal performance tuning options (not exposed in UI).
33+ Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"`
34+ // Update controls automatic update checking and downloading.
35+ Update UpdateConfig `mapstructure:"update" yaml:"update" toml:"update"`
30 }36 }
31 37
32 // RenderingMode selects GPU vs CPU rendering.38 // RenderingMode selects GPU vs CPU rendering.
@@ -119,6 +125,20 @@ const (
119 GLRenderingModeNone GLRenderingMode = "none"125 GLRenderingModeNone GLRenderingMode = "none"
120 )126 )
121 127
128+// PerformanceProfile selects preset performance tuning settings.
129+type PerformanceProfile string
130+
131+const (
132+ // ProfileDefault uses WebKit defaults (no tuning applied).
133+ ProfileDefault PerformanceProfile = "default"
134+ // ProfileLite reduces resource usage for low-RAM systems.
135+ ProfileLite PerformanceProfile = "lite"
136+ // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc).
137+ ProfileMax PerformanceProfile = "max"
138+ // ProfileCustom allows full control over individual performance settings.
139+ ProfileCustom PerformanceProfile = "custom"
140+)
141+
122 // ThemeDefault is the default theme setting (follows system).142 // ThemeDefault is the default theme setting (follows system).
123 const ThemeDefault = "default"143 const ThemeDefault = "default"
124 144
@@ -172,6 +192,74 @@ type HistoryConfig struct {
172 CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days" toml:"cleanup_interval_days"`192 CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days" toml:"cleanup_interval_days"`
173 }193 }
174 194
195+// PerformanceConfig holds internal performance tuning options.
196+// These are not exposed in dumb://config UI but can be set in config file.
197+type PerformanceConfig struct {
198+ // Profile selects a preset performance configuration.
199+ // Values: "default" (no tuning), "lite" (low RAM), "max" (heavy pages), "custom" (manual control)
200+ // When profile is not "custom", individual tuning fields below are ignored and computed from the profile.
201+ // Default: "default"
202+ Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"`
203+
204+ // ZoomCacheSize is the number of domain zoom levels to cache in memory.
205+ // Higher values reduce database queries but use more memory.
206+ // Default: 256 domains (~20KB memory)
207+ ZoomCacheSize int `mapstructure:"zoom_cache_size" yaml:"zoom_cache_size" toml:"zoom_cache_size"`
208+
209+ // WebViewPoolPrewarmCount is the number of WebViews to pre-create at startup.
210+ // Higher values speed up initial tab creation but use more memory.
211+ // Default: 4 (custom mode), computed from profile otherwise
212+ WebViewPoolPrewarmCount int `mapstructure:"webview_pool_prewarm_count" yaml:"webview_pool_prewarm_count" toml:"webview_pool_prewarm_count"` //nolint:lll // struct tags must stay on one line
213+
214+ // --- Skia rendering threads (env vars) ---
215+ // NOTE: Fields below only apply when Profile is "custom".
216+ // SkiaCPUPaintingThreads sets WEBKIT_SKIA_CPU_PAINTING_THREADS.
217+ // Default: 0 (unset, uses WebKit default)
218+ SkiaCPUPaintingThreads int `mapstructure:"skia_cpu_painting_threads" yaml:"skia_cpu_painting_threads" toml:"skia_cpu_painting_threads"` //nolint:lll // struct tags must stay on one line
219+
220+ // SkiaGPUPaintingThreads sets WEBKIT_SKIA_GPU_PAINTING_THREADS.
221+ // Default: -1 (unset). Value 0 disables GPU tile painting.
222+ SkiaGPUPaintingThreads int `mapstructure:"skia_gpu_painting_threads" yaml:"skia_gpu_painting_threads" toml:"skia_gpu_painting_threads"` //nolint:lll // struct tags must stay on one line
223+
224+ // SkiaEnableCPURendering forces CPU rendering via WEBKIT_SKIA_ENABLE_CPU_RENDERING=1.
225+ // Default: false
226+ SkiaEnableCPURendering bool `mapstructure:"skia_enable_cpu_rendering" yaml:"skia_enable_cpu_rendering" toml:"skia_enable_cpu_rendering"` //nolint:lll // struct tags must stay on one line
227+
228+ // --- Web process memory pressure ---
229+ // WebProcessMemoryLimitMB sets memory limit in MB for web processes.
230+ // Default: 0 (unset, uses WebKit default: system RAM capped at 3GB)
231+ WebProcessMemoryLimitMB int `mapstructure:"web_process_memory_limit_mb" yaml:"web_process_memory_limit_mb" toml:"web_process_memory_limit_mb"` //nolint:lll // struct tags must stay on one line
232+
233+ // WebProcessMemoryPollIntervalSec sets poll interval for memory checks.
234+ // Default: 0 (unset, uses WebKit default: 30 seconds)
235+ WebProcessMemoryPollIntervalSec float64 `mapstructure:"web_process_memory_poll_interval_sec" yaml:"web_process_memory_poll_interval_sec" toml:"web_process_memory_poll_interval_sec"` //nolint:lll // struct tags must stay on one line
236+
237+ // WebProcessMemoryConservativeThreshold sets threshold for conservative memory release.
238+ // Valid: (0, 1). Default: 0 (unset, uses WebKit default: 0.33)
239+ WebProcessMemoryConservativeThreshold float64 `mapstructure:"web_process_memory_conservative_threshold" yaml:"web_process_memory_conservative_threshold" toml:"web_process_memory_conservative_threshold"` //nolint:lll // struct tags must stay on one line
240+
241+ // WebProcessMemoryStrictThreshold sets threshold for strict memory release.
242+ // Valid: (0, 1). Default: 0 (unset, uses WebKit default: 0.5)
243+ WebProcessMemoryStrictThreshold float64 `mapstructure:"web_process_memory_strict_threshold" yaml:"web_process_memory_strict_threshold" toml:"web_process_memory_strict_threshold"` //nolint:lll // struct tags must stay on one line
244+
245+ // --- Network process memory pressure ---
246+ // NetworkProcessMemoryLimitMB sets memory limit in MB for network process.
247+ // Default: 0 (unset)
248+ NetworkProcessMemoryLimitMB int `mapstructure:"network_process_memory_limit_mb" yaml:"network_process_memory_limit_mb" toml:"network_process_memory_limit_mb"` //nolint:lll // struct tags must stay on one line
249+
250+ // NetworkProcessMemoryPollIntervalSec sets poll interval for memory checks.
251+ // Default: 0 (unset)
252+ NetworkProcessMemoryPollIntervalSec float64 `mapstructure:"network_process_memory_poll_interval_sec" yaml:"network_process_memory_poll_interval_sec" toml:"network_process_memory_poll_interval_sec"` //nolint:lll // struct tags must stay on one line
253+
254+ // NetworkProcessMemoryConservativeThreshold sets threshold for conservative memory release.
255+ // Valid: (0, 1). Default: 0 (unset)
256+ NetworkProcessMemoryConservativeThreshold float64 `mapstructure:"network_process_memory_conservative_threshold" yaml:"network_process_memory_conservative_threshold" toml:"network_process_memory_conservative_threshold"` //nolint:lll // struct tags must stay on one line
257+
258+ // NetworkProcessMemoryStrictThreshold sets threshold for strict memory release.
259+ // Valid: (0, 1). Default: 0 (unset)
260+ NetworkProcessMemoryStrictThreshold float64 `mapstructure:"network_process_memory_strict_threshold" yaml:"network_process_memory_strict_threshold" toml:"network_process_memory_strict_threshold"` //nolint:lll // struct tags must stay on one line
261+}
262+
175 // SearchShortcut represents a search shortcut configuration.263 // SearchShortcut represents a search shortcut configuration.
176 type SearchShortcut struct {264 type SearchShortcut struct {
177 URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`265 URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`
@@ -190,7 +278,7 @@ func (c *Config) ShortcutURLs() map[string]string {
190 278
191 // DmenuConfig holds dmenu/rofi integration configuration.279 // DmenuConfig holds dmenu/rofi integration configuration.
192 type DmenuConfig struct {280 type DmenuConfig struct {
193- MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"`281+ MaxHistoryDays int `mapstructure:"max_history_days" yaml:"max_history_days" toml:"max_history_days"`
194 ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"`282 ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"`
195 ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"`283 ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"`
196 HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"`284 HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"`
@@ -254,6 +342,9 @@ type OmniboxConfig struct {
254 // InitialBehavior controls what to show when omnibox opens with empty input342 // InitialBehavior controls what to show when omnibox opens with empty input
255 // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing)343 // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing)
256 InitialBehavior string `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"`344 InitialBehavior string `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"`
345+ // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created.
346+ // Default: false
347+ AutoOpenOnNewPane bool `mapstructure:"auto_open_on_new_pane" yaml:"auto_open_on_new_pane" toml:"auto_open_on_new_pane"`
257 }348 }
258 349
259 // DebugConfig holds debug and troubleshooting options350 // DebugConfig holds debug and troubleshooting options
@@ -262,18 +353,40 @@ type DebugConfig struct {
262 EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"`353 EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"`
263 }354 }
264 355
356+// UpdateConfig holds automatic update settings.
357+type UpdateConfig struct {
358+ // EnableOnStartup enables checking for updates when the browser starts.
359+ // Default: true
360+ EnableOnStartup bool `mapstructure:"enable_on_startup" yaml:"enable_on_startup" toml:"enable_on_startup"`
361+ // AutoDownload automatically downloads updates in the background.
362+ // When enabled, updates are applied on browser exit.
363+ // Default: false
364+ AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"`
365+ // NotifyOnNewSettings shows a toast notification on startup when new config settings are available.
366+ // Default: true
367+ NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
368+}
369+
265 // WorkspaceConfig captures layout, pane, and tab behavior preferences.370 // WorkspaceConfig captures layout, pane, and tab behavior preferences.
266 type WorkspaceConfig struct {371 type WorkspaceConfig struct {
372+ // NewPaneURL is the URL loaded when creating a new empty pane/tab.
373+ // Default: "about:blank"
374+ NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url"`
267 // PaneMode defines modal pane behavior and bindings.375 // PaneMode defines modal pane behavior and bindings.
268 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`376 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
269 // TabMode defines modal tab behavior and bindings (Alt+T).377 // TabMode defines modal tab behavior and bindings (Alt+T).
270 TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`378 TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
379+ // ResizeMode defines modal pane resizing behavior and bindings (Ctrl+N).
380+ ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
271 // Shortcuts holds global (non-modal) keyboard shortcuts.381 // Shortcuts holds global (non-modal) keyboard shortcuts.
272 Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`382 Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
273 // TabBarPosition determines tab bar placement: "top" or "bottom".383 // TabBarPosition determines tab bar placement: "top" or "bottom".
274 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`384 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
275 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.385 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
276 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 line386 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
387+ // SwitchToTabOnMove controls whether moving a pane to another tab switches focus to that tab.
388+ // Default: true
389+ 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
277 // Popups configures default popup placement rules.390 // Popups configures default popup placement rules.
278 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`391 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
279 // Styling configures workspace visual appearance.392 // Styling configures workspace visual appearance.
@@ -318,11 +431,37 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string {
318 return keyToAction431 return keyToAction
319 }432 }
320 433
434+// ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).
435+type ResizeModeConfig struct {
436+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
437+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
438+ StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"`
439+ MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line
440+ Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
441+}
442+
443+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
444+// This is built from the action→keys structure in the config.
445+func (r *ResizeModeConfig) GetKeyBindings() map[string]string {
446+ keyToAction := make(map[string]string)
447+ for action, keys := range r.Actions {
448+ for _, key := range keys {
449+ keyToAction[key] = action
450+ }
451+ }
452+ return keyToAction
453+}
454+
321 // GlobalShortcutsConfig defines global shortcuts (always active, not modal).455 // GlobalShortcutsConfig defines global shortcuts (always active, not modal).
322 type GlobalShortcutsConfig struct {456 type GlobalShortcutsConfig struct {
323 ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"`457 ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"`
324 NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"`458 NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"`
325 PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"`459 PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"`
460+
461+ ConsumeOrExpelLeft string `mapstructure:"consume_or_expel_left" yaml:"consume_or_expel_left" toml:"consume_or_expel_left" json:"consume_or_expel_left"` //nolint:lll // struct tags must stay on one line
462+ ConsumeOrExpelRight string `mapstructure:"consume_or_expel_right" yaml:"consume_or_expel_right" toml:"consume_or_expel_right" json:"consume_or_expel_right"` //nolint:lll // struct tags must stay on one line
463+ ConsumeOrExpelUp string `mapstructure:"consume_or_expel_up" yaml:"consume_or_expel_up" toml:"consume_or_expel_up" json:"consume_or_expel_up"` //nolint:lll // struct tags must stay on one line
464+ ConsumeOrExpelDown string `mapstructure:"consume_or_expel_down" yaml:"consume_or_expel_down" toml:"consume_or_expel_down" json:"consume_or_expel_down"` //nolint:lll // struct tags must stay on one line
326 }465 }
327 466
328 // PopupBehavior defines how popup windows should be opened467 // PopupBehavior defines how popup windows should be opened
@@ -373,18 +512,64 @@ type WorkspaceStylingConfig struct {
373 // BorderColor for focused panes (CSS color value or theme variable)512 // BorderColor for focused panes (CSS color value or theme variable)
374 BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`513 BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
375 514
376- // PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay)515+ // ModeBorderWidth in pixels for all mode indicator borders (pane, tab, session, resize)
377- 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 line516+ 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
378- // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)517+
379- // Defaults to "#4A90E2" (blue) if not set518+ // PaneModeColor for pane mode indicator (border and toaster). Defaults to "#4A90E2" (blue)
380- 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 line519+ 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
520+ // TabModeColor for tab mode indicator (border and toaster). Defaults to "#FFA500" (orange)
521+ 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
522+ // SessionModeColor for session mode indicator (border and toaster). Defaults to "#9B59B6" (purple)
523+ 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
524+ // ResizeModeColor for resize mode indicator (border and toaster). Defaults to "#00D4AA" (teal)
525+ 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
381 526
382- // TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay)527+ // ModeIndicatorToasterEnabled shows a toaster notification when modal modes are active.
383- 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 line528+ // Default: true
384- // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable)529+ 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
385- // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor
386- 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
387 530
388 // TransitionDuration in milliseconds for border animations531 // TransitionDuration in milliseconds for border animations
389 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line532 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
390 }533 }
534+
535+// SessionConfig holds session persistence settings.
536+type SessionConfig struct {
537+ // AutoRestore automatically restores the last session on startup.
538+ // Default: false
539+ AutoRestore bool `mapstructure:"auto_restore" yaml:"auto_restore" toml:"auto_restore" json:"auto_restore"`
540+
541+ // SnapshotIntervalMs is the minimum interval between snapshots in milliseconds.
542+ // Default: 5000
543+ SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line
544+
545+ // MaxExitedSessions is the maximum number of exited sessions to keep.
546+ // Default: 50
547+ MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line
548+
549+ // MaxExitedSessionAgeDays is the maximum age in days for exited sessions.
550+ // Sessions older than this will be automatically deleted on startup.
551+ // Default: 7
552+ MaxExitedSessionAgeDays int `mapstructure:"max_exited_session_age_days" yaml:"max_exited_session_age_days" toml:"max_exited_session_age_days" json:"max_exited_session_age_days"` //nolint:lll // struct tags must stay on one line
553+
554+ // SessionMode defines modal behavior for session management (Ctrl+O).
555+ SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"`
556+}
557+
558+// SessionModeConfig defines modal behavior for session management.
559+type SessionModeConfig struct {
560+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
561+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
562+ Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
563+}
564+
565+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
566+// This is built from the action→keys structure in the config.
567+func (s *SessionModeConfig) GetKeyBindings() map[string]string {
568+ keyToAction := make(map[string]string)
569+ for action, keys := range s.Actions {
570+ for _, key := range keys {
571+ keyToAction[key] = action
572+ }
573+ }
574+ return keyToAction
575+}
A internal/infrastructure/config/schema_provider.go +903 -0 collapsed to keep the page light
M internal/infrastructure/config/validation.go +98 -7
@@ -2,8 +2,10 @@ package config
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "net/url"
5 "strings"6 "strings"
6 7
8+ domainurl "github.com/bnema/dumber/internal/domain/url"
7 domainvalidation "github.com/bnema/dumber/internal/domain/validation"9 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
8 )10 )
9 11
@@ -21,9 +23,12 @@ func validateConfig(config *Config) error {
21 validationErrors = append(validationErrors, validateTabBar(config)...)23 validationErrors = append(validationErrors, validateTabBar(config)...)
22 validationErrors = append(validationErrors, validateTabMode(config)...)24 validationErrors = append(validationErrors, validateTabMode(config)...)
23 validationErrors = append(validationErrors, validateLogging(config)...)25 validationErrors = append(validationErrors, validateLogging(config)...)
26+ validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
24 validationErrors = append(validationErrors, validateOmnibox(config)...)27 validationErrors = append(validationErrors, validateOmnibox(config)...)
25 validationErrors = append(validationErrors, validateRendering(config)...)28 validationErrors = append(validationErrors, validateRendering(config)...)
26 validationErrors = append(validationErrors, validateColorScheme(config)...)29 validationErrors = append(validationErrors, validateColorScheme(config)...)
30+ validationErrors = append(validationErrors, validateSession(config)...)
31+ validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
27 32
28 // If there are validation errors, return them33 // If there are validation errors, return them
29 if len(validationErrors) > 0 {34 if len(validationErrors) > 0 {
@@ -48,8 +53,8 @@ func validateHistory(config *Config) []string {
48 }53 }
49 54
50 func validateDmenu(config *Config) []string {55 func validateDmenu(config *Config) []string {
51- if config.Dmenu.MaxHistoryItems < 0 {56+ if config.Dmenu.MaxHistoryDays < 0 {
52- return []string{"dmenu.max_history_items must be non-negative"}57+ return []string{"dmenu.max_history_days must be non-negative"}
53 }58 }
54 return nil59 return nil
55 }60 }
@@ -142,11 +147,8 @@ func validateWorkspaceStyling(config *Config) []string {
142 if config.Workspace.Styling.BorderWidth < 0 {147 if config.Workspace.Styling.BorderWidth < 0 {
143 validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")148 validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
144 }149 }
145- if config.Workspace.Styling.PaneModeBorderWidth < 0 {150+ if config.Workspace.Styling.ModeBorderWidth < 0 {
146- validationErrors = append(validationErrors, "workspace.styling.pane_mode_border_width must be non-negative")151+ validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative")
147- }
148- if config.Workspace.Styling.TabModeBorderWidth < 0 {
149- validationErrors = append(validationErrors, "workspace.styling.tab_mode_border_width must be non-negative")
150 }152 }
151 if config.Workspace.Styling.TransitionDuration < 0 {153 if config.Workspace.Styling.TransitionDuration < 0 {
152 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")154 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
@@ -245,6 +247,37 @@ func validateLogging(config *Config) []string {
245 return validationErrors247 return validationErrors
246 }248 }
247 249
250+func validateWorkspaceNewPaneURL(config *Config) []string {
251+ var validationErrors []string
252+
253+ if config.Workspace.NewPaneURL == "" {
254+ validationErrors = append(validationErrors, "workspace.new_pane_url cannot be empty")
255+ return validationErrors
256+ }
257+
258+ normalized := domainurl.Normalize(config.Workspace.NewPaneURL)
259+ parsed, err := url.Parse(normalized)
260+ if err != nil {
261+ validationErrors = append(validationErrors, fmt.Sprintf(
262+ "workspace.new_pane_url must be a valid URL (got: %s)",
263+ config.Workspace.NewPaneURL,
264+ ))
265+ return validationErrors
266+ }
267+
268+ switch parsed.Scheme {
269+ case "http", "https", "dumb", "file", "about":
270+ // ok
271+ default:
272+ validationErrors = append(validationErrors, fmt.Sprintf(
273+ "workspace.new_pane_url must use one of: http, https, dumb, file, about (got: %s)",
274+ parsed.Scheme,
275+ ))
276+ }
277+
278+ return validationErrors
279+}
280+
248 func validateOmnibox(config *Config) []string {281 func validateOmnibox(config *Config) []string {
249 switch config.Omnibox.InitialBehavior {282 switch config.Omnibox.InitialBehavior {
250 case "recent", "most_visited", "none":283 case "recent", "most_visited", "none":
@@ -301,3 +334,61 @@ func validateColorScheme(config *Config) []string {
301 )}334 )}
302 }335 }
303 }336 }
337+
338+func validateSession(config *Config) []string {
339+ var validationErrors []string
340+ if config.Session.MaxExitedSessions < 0 {
341+ validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative")
342+ }
343+ if config.Session.MaxExitedSessionAgeDays < 0 {
344+ validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative")
345+ }
346+ if config.Session.SnapshotIntervalMs < 0 {
347+ validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative")
348+ }
349+ return validationErrors
350+}
351+
352+func validatePerformanceProfile(config *Config) []string {
353+ var validationErrors []string
354+
355+ // Validate profile name
356+ if !IsValidPerformanceProfile(config.Performance.Profile) {
357+ validationErrors = append(validationErrors, fmt.Sprintf(
358+ "performance.profile must be one of: default, lite, max, custom (got: %s)",
359+ config.Performance.Profile,
360+ ))
361+ }
362+
363+ // When profile is not "custom", warn if individual fields are set
364+ // (they will be ignored in favor of profile-computed values)
365+ if config.Performance.Profile != ProfileCustom && config.Performance.Profile != "" {
366+ if HasCustomPerformanceFields(&config.Performance) {
367+ validationErrors = append(validationErrors,
368+ "performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+
369+ "set profile = \"custom\" to use individual field values",
370+ )
371+ }
372+ }
373+
374+ // Validate memory pressure threshold ordering: WebKit requires conservative < strict
375+ webCons := config.Performance.WebProcessMemoryConservativeThreshold
376+ webStrict := config.Performance.WebProcessMemoryStrictThreshold
377+ if webCons > 0 && webStrict > 0 && webCons >= webStrict {
378+ validationErrors = append(validationErrors,
379+ "performance.web_process_memory_conservative_threshold must be less than "+
380+ "web_process_memory_strict_threshold when both are set",
381+ )
382+ }
383+
384+ netCons := config.Performance.NetworkProcessMemoryConservativeThreshold
385+ netStrict := config.Performance.NetworkProcessMemoryStrictThreshold
386+ if netCons > 0 && netStrict > 0 && netCons >= netStrict {
387+ validationErrors = append(validationErrors,
388+ "performance.network_process_memory_conservative_threshold must be less than "+
389+ "network_process_memory_strict_threshold when both are set",
390+ )
391+ }
392+
393+ return validationErrors
394+}
M internal/infrastructure/config/webui_gateway.go +32 -0 collapsed to keep the page light
M internal/infrastructure/desktop/adapter.go +54 -0 collapsed to keep the page light
A internal/infrastructure/env/hardware_survey.go +349 -0 collapsed to keep the page light
M internal/infrastructure/env/manager.go +22 -12 collapsed to keep the page light
A internal/infrastructure/env/manager_test.go +172 -0 collapsed to keep the page light
M internal/infrastructure/favicon/cache.go +27 -0 collapsed to keep the page light
M internal/infrastructure/favicon/service.go +36 -0 collapsed to keep the page light
A internal/infrastructure/idle/portal_inhibitor.go +253 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/connection.go +23 -6 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/history_repo.go +63 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/history_repo_test.go +325 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/lazy_db.go +92 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/lazy_db_test.go +125 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/lazy_repos.go +697 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/migrations/006_session_states.sql +14 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/queries/history.sql +18 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/queries/session_states.sql +36 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/queries/sessions.sql +20 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/session_repo.go +33 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/session_state_repo.go +118 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +146 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/sqlc/models.go +9 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +15 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/sqlc/session_states.sql.go +175 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/sqlc/sessions.sql.go +44 -0 collapsed to keep the page light
A internal/infrastructure/snapshot/service.go +128 -0 collapsed to keep the page light
A internal/infrastructure/updater/applier.go +203 -0 collapsed to keep the page light
A internal/infrastructure/updater/github.go +519 -0 collapsed to keep the page light
A internal/infrastructure/updater/github_test.go +361 -0 collapsed to keep the page light
A internal/infrastructure/webkit/bgcolor.go +38 -0 collapsed to keep the page light
M internal/infrastructure/webkit/context.go +44 -10 collapsed to keep the page light
M internal/infrastructure/webkit/factory.go +26 -5 collapsed to keep the page light
M internal/infrastructure/webkit/injector.go +107 -85 collapsed to keep the page light
A internal/infrastructure/webkit/memory_pressure.go +117 -0 collapsed to keep the page light
M internal/infrastructure/webkit/messaging.go +53 -3 collapsed to keep the page light
M internal/infrastructure/webkit/pool.go +187 -5 collapsed to keep the page light
M internal/infrastructure/webkit/scheme_handler.go +127 -19 collapsed to keep the page light
M internal/infrastructure/webkit/webview.go +233 -17 collapsed to keep the page light
M internal/infrastructure/xdg/adapter.go +17 -0 collapsed to keep the page light
M internal/logging/logger.go +12 -3 collapsed to keep the page light
M internal/ui/adapter/favicon.go +31 -0 collapsed to keep the page light
M internal/ui/app.go +906 -39 collapsed to keep the page light
A internal/ui/component/link_status.go +154 -0 collapsed to keep the page light
A internal/ui/component/loading_skeleton.go +97 -0 collapsed to keep the page light
A internal/ui/component/loading_skeleton_test.go +121 -0 collapsed to keep the page light
A internal/ui/component/logo.go +37 -0 collapsed to keep the page light
A internal/ui/component/modal_sizing.go +138 -0 collapsed to keep the page light
A internal/ui/component/modal_sizing_test.go +145 -0 collapsed to keep the page light
M internal/ui/component/omnibox.go +424 -131 collapsed to keep the page light
M internal/ui/component/pane_view.go +122 -7 collapsed to keep the page light
A internal/ui/component/pane_view_internal_test.go +71 -0 collapsed to keep the page light
M internal/ui/component/pane_view_test.go +110 -25 collapsed to keep the page light
M internal/ui/component/progress_bar.go +51 -3 collapsed to keep the page light
A internal/ui/component/session_manager.go +1028 -0 collapsed to keep the page light
M internal/ui/component/stacked_pane_manager.go +1 -1
@@ -76,7 +76,7 @@ func (spm *StackedPaneManager) AddPaneToStack(
76 Int("stack_size_before", stackedView.Count()).76 Int("stack_size_before", stackedView.Count()).
77 Msg("StackedPaneManager: inserting pane after active position")77 Msg("StackedPaneManager: inserting pane after active position")
78 78
79- stackedView.InsertPaneAfter(ctx, currentActiveIndex, title, "", widget)79+ stackedView.InsertPaneAfter(ctx, currentActiveIndex, string(newPaneView.PaneID()), title, "", widget)
80 80
81 // Register the new pane in the TreeRenderer's tracking81 // Register the new pane in the TreeRenderer's tracking
82 tr.RegisterPaneInStack(string(newPaneView.PaneID()), stackedView)82 tr.RegisterPaneInStack(string(newPaneView.PaneID()), stackedView)
A internal/ui/component/tab_picker.go +482 -0 collapsed to keep the page light
M internal/ui/component/toaster.go +239 -8 collapsed to keep the page light
M internal/ui/component/workspace_view.go +40 -7 collapsed to keep the page light
M internal/ui/component/workspace_view_test.go +62 -0 collapsed to keep the page light
A internal/ui/config_migration.go +38 -0 collapsed to keep the page light
M internal/ui/coordinator/content.go +541 -60 collapsed to keep the page light
A internal/ui/coordinator/content_reveal_test.go +179 -0 collapsed to keep the page light
M internal/ui/coordinator/navigation.go +2 -2 collapsed to keep the page light
M internal/ui/coordinator/tab.go +21 -0 collapsed to keep the page light
A internal/ui/coordinator/update.go +190 -0 collapsed to keep the page light
M internal/ui/coordinator/workspace.go +191 -10 collapsed to keep the page light
M internal/ui/deps.go +22 -5 collapsed to keep the page light
M internal/ui/dispatcher/keyboard.go +89 -12 collapsed to keep the page light
M internal/ui/focus/borders.go +11 -2 collapsed to keep the page light
A internal/ui/input/global_shortcuts.go +185 -0 collapsed to keep the page light
M internal/ui/input/keyboard.go +129 -28 collapsed to keep the page light
A internal/ui/input/keycodes.go +75 -0 collapsed to keep the page light
A internal/ui/input/keycodes_test.go +306 -0 collapsed to keep the page light
M internal/ui/input/modal.go +94 -0 collapsed to keep the page light
M internal/ui/input/shortcuts.go +210 -81 collapsed to keep the page light
M internal/ui/input/shortcuts_test.go +28 -1 collapsed to keep the page light
M internal/ui/layout/gtk_widgets.go +74 -0 collapsed to keep the page light
M internal/ui/layout/mocks/mocks.go +1503 -127 collapsed to keep the page light
M internal/ui/layout/split.go +85 -0 collapsed to keep the page light
M internal/ui/layout/split_test.go +2 -0 collapsed to keep the page light
M internal/ui/layout/stacked.go +19 -2 collapsed to keep the page light
M internal/ui/layout/stacked_test.go +31 -31 collapsed to keep the page light
M internal/ui/layout/tree.go +70 -20 collapsed to keep the page light
M internal/ui/layout/tree_test.go +8 -1 collapsed to keep the page light
M internal/ui/layout/widgets.go +12 -0 collapsed to keep the page light
M internal/ui/theme/css.go +335 -13 collapsed to keep the page light
M internal/ui/theme/manager.go +64 -21 collapsed to keep the page light
A internal/ui/theme/manager_test.go +342 -0 collapsed to keep the page light
M internal/ui/theme/palette.go +92 -0 collapsed to keep the page light
D internal/ui/theme/system.go +0 -78 collapsed to keep the page light
A internal/ui/theme/tab_picker_css.go +81 -0 collapsed to keep the page light
M internal/ui/window/main_window.go +15 -0 collapsed to keep the page light
M webui/package-lock.json +96 -0 generated file: collapsed
M webui/package.json +2 -0 collapsed to keep the page light
A webui/public/favicon.png +0 -0 binary file: no text diff

binary file: no text diff

A webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +17 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +17 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte +24 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte +19 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte +19 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte +19 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte +16 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog.svelte +10 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/index.ts +37 -0 collapsed to keep the page light
A webui/src/lib/components/ui/color-picker/color-picker.svelte +89 -0 collapsed to keep the page light
A webui/src/lib/components/ui/color-picker/index.ts +8 -0 collapsed to keep the page light
M webui/src/pages/ConfigPage.svelte +277 -22 collapsed to keep the page light
M webui/src/pages/ErrorPage.svelte +2 -0 collapsed to keep the page light
M webui/src/pages/Homepage.svelte +2 -0 collapsed to keep the page light
M webui/src/pages/homepage/favorites/FavoriteCard.svelte +4 -0 collapsed to keep the page light
M webui/src/pages/homepage/history/HistoryItem.svelte +4 -0 collapsed to keep the page light
M webui/vite.config.ts +21 -0 collapsed to keep the page light

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close