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
22
33 on:
44 push:
5- branches: [dev, main]
5+ branches: [main]
66 paths:
77 - '**.go'
88 - 'go.mod'
@@ -65,7 +65,9 @@ jobs:
6565 run: |
6666 curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin v2.6.2
6767 - name: Run golangci-lint
68- run: golangci-lint run --timeout=20m
68+ run: |
69+ git config --global --add safe.directory "$GITHUB_WORKSPACE"
70+ golangci-lint run --timeout=20m
6971
7072 test:
7173 runs-on: ubuntu-latest
@@ -75,7 +77,7 @@ jobs:
7577 - name: Install dependencies
7678 run: |
7779 pacman -Syu --noconfirm
78- pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 gobject-introspection base-devel
80+ pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel
7981 - uses: actions/checkout@v6
8082 - name: Cache Go modules
8183 uses: actions/cache@v4
@@ -101,4 +103,7 @@ jobs:
101103 - name: Build frontend assets
102104 run: make build-frontend
103105 - name: Run tests
106+ env:
107+ GDK_BACKEND: wayland
108+ WAYLAND_DISPLAY: wayland-0
104109 run: go test -v ./...
M .goreleaser.yaml +12 -5
@@ -33,14 +33,14 @@ archives:
3333 - id: default
3434 name_template: >-
3535 {{ .ProjectName }}_
36- {{- .Version }}_
3736 {{- .Os }}_
3837 {{- if eq .Arch "amd64" }}x86_64
3938 {{- else if eq .Arch "386" }}i386
4039 {{- else }}{{ .Arch }}{{ end }}
40+ wrap_in_directory: "{{ .ProjectName }}_{{ .Version }}"
4141 format_overrides:
4242 - goos: linux
43- format: tar.gz
43+ formats: ['tar.gz']
4444 files:
4545 - LICENSE
4646 - README.md
@@ -109,9 +109,16 @@ release:
109109
110110 ### Linux (x86_64/amd64)
111111 ```bash
112- wget https://github.com/bnema/dumber/releases/download/{{.Tag}}/dumber_{{.Version}}_linux_x86_64.tar.gz
113- tar -xzf dumber_{{.Version}}_linux_x86_64.tar.gz
114- sudo install -m 755 dumber /usr/local/bin/
112+ # Download latest release
113+ wget https://github.com/bnema/dumber/releases/latest/download/dumber_linux_x86_64.tar.gz
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"
115122 ```
116123
117124 ### Dependencies
M .mockery.yaml +13 -0
@@ -8,10 +8,21 @@ packages:
88 filename: mocks.go
99 pkgname: mocks
1010 interfaces:
11+ ConfigMigrator: {}
12+ ConfigSchemaProvider: {}
13+ DatabaseProvider: {}
14+ DiffFormatter: {}
1115 SessionLogger: {}
16+ SessionSpawner: {}
17+ TabListProvider: {}
1218 WebView: {}
1319 WebViewFactory: {}
1420 WebViewPool: {}
21+ MemoryPressureApplier: {}
22+ WebKitContextProvider: {}
23+ HardwareSurveyor: {}
24+ ColorSchemeDetector: {}
25+ ColorSchemeResolver: {}
1526 github.com/bnema/dumber/internal/domain/repository:
1627 config:
1728 dir: '{{.InterfaceDir}}/mocks'
@@ -23,6 +34,7 @@ packages:
2334 FolderRepository: {}
2435 HistoryRepository: {}
2536 SessionRepository: {}
37+ SessionStateRepository: {}
2638 TagRepository: {}
2739 ZoomRepository: {}
2840 github.com/bnema/dumber/internal/infrastructure/filtering:
@@ -48,5 +60,6 @@ packages:
4860 PaneViewFactory: {}
4961 PanedWidget: {}
5062 ProgressBarWidget: {}
63+ SpinnerWidget: {}
5164 Widget: {}
5265 WidgetFactory: {}
M CHANGELOG.md +107 -2
@@ -4,13 +4,118 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
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+
7113 ### 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.
8115 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.
9116 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.
10117 - **WebUI config improvements**: Search shortcut management (CRUD) and reset-to-defaults support.
11118 - **Find-in-page**: New find UI and shortcut handling.
12-
13-### Changed
14119 - Migrated GUI stack to `puregotk` + `puregotk-webkit` and disabled CGO for pure Go compilation.
15120 - **Omnibox rewrite**: Replaced injected Svelte/JS omnibox with a native GTK4 widget overlay.
16121 - 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 @@
11 # Makefile for dumber (Clean Architecture - puregotk)
22
3-.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs
3+.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man
44
55 # Load local overrides from .env.local if present (Makefile syntax)
66 ifneq (,$(wildcard .env.local))
@@ -137,6 +137,11 @@ check: ## Check that all tools and dependencies are working
137137 @$(MAKE) test
138138 @echo "\nAll checks passed!"
139139
140+# Documentation
141+man: build-quick ## Install man pages to ~/.local/share/man/man1/
142+ @echo "Installing man pages..."
143+ $(DIST_DIR)/$(BINARY_NAME) gen-docs
144+
140145 # Native release targets
141146 .PHONY: release-snapshot release
142147
M README.md +98 -36
@@ -23,6 +23,7 @@ A dumb browser that works like your favorite terminal multiplexer.
2323 - **Customizable themes**: Light and dark palettes with semantic color tokens.
2424 - **Persistent storage**: SQLite for history, zoom levels, and settings.
2525 - **Live configuration**: Single config file with hot reload when possible.
26+- **Session management**: Zellij-style session save/restore with automatic snapshots and resurrection.
2627
2728 ## Status
2829 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
3132
3233 Early development with regular releases. Core features work well for daily use but expect some rough edges.
3334
35+Some features are very alpha/experimental (notably consume-or-expel panes) and may change behavior between releases.
36+
3437 ## Controls & Shortcuts
3538
3639 ### Keyboard Shortcuts
@@ -74,14 +77,37 @@ Early development with regular releases. Core features work well for daily use b
7477 | **↓** / **D** (in pane mode) | Split Down | Create new pane below |
7578 | **S** (in pane mode) | Stack Pane | Create stacked pane (Zellij-style) |
7679 | **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 |
7782 | **Enter** (in pane mode) | Confirm Action | Confirm pane operation |
7883 | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation |
7984 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
8085 | **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 |
81106
82107 ### Mouse Controls
83108 | Action | Result | Notes |
84109 |--------|---------|-------|
110+| **Drag pane divider** | Resize panes | Updates split ratio and persists in session snapshots |
85111 | **Ctrl+Scroll Up** | Zoom In | Smooth zoom control |
86112 | **Ctrl+Scroll Down** | Zoom Out | Smooth zoom control |
87113 | **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
108134
109135 Download the latest release from the [releases page](https://github.com/bnema/dumber/releases):
110136 ```bash
111-wget https://github.com/bnema/dumber/releases/latest/download/dumber_<version>_linux_x86_64.tar.gz
112-tar -xzf dumber_<version>_linux_x86_64.tar.gz
113-sudo install -m 755 dumber /usr/local/bin/
137+# Download latest release
138+wget https://github.com/bnema/dumber/releases/latest/download/dumber_linux_x86_64.tar.gz
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"
114147 ```
115148
116-Then install runtime dependencies (see [Install](#install) section for details).
149+Then install runtime dependencies (see [Dependencies](#dependencies) section below).
117150
118151 **Option 2: Build From Source**
119152
@@ -142,18 +175,11 @@ Development:
142175
143176 ### Pre-built Binaries (Recommended)
144177
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:
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-```
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.
153179
154-Replace `<version>` with the desired version (e.g., `0.14.1`), or use the direct link from the releases page.
180+### Dependencies
155181
156-**Dependencies** (required for both pre-built binaries and source builds):
182+Required for both pre-built binaries and source builds:
157183
158184 **Arch Linux:**
159185 ```bash
@@ -210,38 +236,61 @@ sudo apt install va-driver-all
210236 - The resulting `./dist/dumber-no-gui` binary runs CLI flows with GUI code stubbed
211237
212238 ## Usage
213-- Open a URL or search:
239+- Open a URL, local file, or search:
214240 - `dumber browse https://example.com`
215- - `dumber browse example.com` # scheme auto‑added
216- - `dumber browse dumb://home` # built-in home page (stats, shortcuts, etc.)
217- - `dumber browse "!g golang"` # Google search via bang shortcut
241+ - `dumber browse example.com` # scheme auto‑added
242+ - `dumber browse ./test.html` # local file path (converted to file://)
243+ - `dumber browse dumb://home` # built-in home page (stats, shortcuts, etc.)
244+ - `dumber browse "!g golang"` # search via bang shortcut
218245 - Show version information:
219- - `dumber about` # display version, commit, and build date
246+ - `dumber about` # version, commit, and build date
247+- Check for and install updates:
248+ - `dumber update` # check and install if available
249+ - `dumber update --force` # force reinstall (skips version check)
220250 - Launcher integration (dmenu‑style examples with favicon support):
221251 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`
222252 - 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
223256 - Manage browsing history:
224- - `dumber history` # interactive history browser (timeline tabs + fuzzy search)
225- - `dumber history --json` # output recent entries as JSON
226- - `dumber history --json --max 50` # limit JSON output to N entries
227- - `dumber history stats` # show history statistics
228- - `dumber history clear` # interactive cleanup (select time range)
229-- Clean up data and cache:
230- - `dumber purge` # purge all data (with confirmation)
231- - `dumber purge --force` # purge all data (no confirmation)
232- - `dumber purge -d -H -c` # purge database and both caches
233- - `dumber purge --browser-data` # purge WebKit data (cookies, etc.)
257+ - `dumber history` # interactive history browser (timeline tabs + fuzzy search)
258+ - `dumber history --json` # output recent entries as JSON
259+ - `dumber history --json --max 50` # limit JSON output to N entries
260+ - `dumber history stats` # show history statistics
261+ - `dumber history clear` # interactive time-range cleanup
262+- Clean up data:
263+ - `dumber purge` # interactive selection (TUI)
264+ - `dumber purge --force` # remove everything (no prompts)
265+- Manage configuration:
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
234276 - Manage logs:
235- - `dumber logs list` # list available log files
236- - `dumber logs tail` # tail current log file
237- - `dumber logs clean` # clean up old log files
277+ - `dumber logs` # list sessions with log files
278+ - `dumber logs <session>` # show logs for a session (full ID or unique suffix)
279+ - `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
238287
239288 ### Dmenu mode invocation
240289 You can invoke dmenu mode in two ways:
241-- Subcommand (recommended): `dumber dmenu` … and `dumber dmenu --select`
242-- Root flag (generate options only): `dumber --dmenu`
290+- Piped launcher (generate options): `dumber dmenu`
291+- Receive selection from stdin: `dumber dmenu --select`
243292
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`
245294
246295 In GUI mode the app serves an embedded home page via `dumb://home`, and frontend assets under `dumb://app/...`.
247296
@@ -259,7 +308,7 @@ Each tab holds its own workspace. Each workspace can contain multiple panes (spl
259308 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode
260309 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout
261310
262-Tab mode provides visual feedback with an orange border indicator.
311+
263312
264313 ## Pane Management
265314
@@ -276,6 +325,19 @@ Split your browser window into multiple panes, each running independent web sess
276325 3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes
277326 4. **Close**: Press `X` in pane mode to close the current pane
278327
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+
279341 ## Configuration
280342
281343 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 @@
11 <?xml version="1.0" encoding="UTF-8"?>
2-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512">
33 <defs>
44 <!-- Background gradient - deeper dark -->
55 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
M assets/webui_embed.go +5 -0
@@ -14,3 +14,8 @@ var WebUIAssets embed.FS
1414 //
1515 //go:embed logo.svg
1616 var LogoSVG []byte
17+
18+// LogoPNG32 contains the dumber logo as 32x32 PNG for CLI tools like dmenu/rofi.
19+//
20+//go:embed logo-32.png
21+var LogoPNG32 []byte
M cmd/dumber/main.go +307 -156
@@ -6,7 +6,6 @@ import (
66 "fmt"
77 "os"
88 "os/signal"
9- "path/filepath"
109 "runtime"
1110 "syscall"
1211
@@ -15,20 +14,23 @@ import (
1514 "github.com/bnema/dumber/internal/bootstrap"
1615 "github.com/bnema/dumber/internal/cli/cmd"
1716 "github.com/bnema/dumber/internal/domain/build"
17+ "github.com/bnema/dumber/internal/domain/entity"
1818 "github.com/bnema/dumber/internal/domain/repository"
19+ "github.com/bnema/dumber/internal/infrastructure/cache"
1920 "github.com/bnema/dumber/internal/infrastructure/clipboard"
21+ "github.com/bnema/dumber/internal/infrastructure/colorscheme"
2022 "github.com/bnema/dumber/internal/infrastructure/config"
2123 "github.com/bnema/dumber/internal/infrastructure/deps"
2224 "github.com/bnema/dumber/internal/infrastructure/favicon"
23- "github.com/bnema/dumber/internal/infrastructure/media"
25+ "github.com/bnema/dumber/internal/infrastructure/idle"
2426 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
27+ "github.com/bnema/dumber/internal/infrastructure/updater"
2528 "github.com/bnema/dumber/internal/logging"
2629 "github.com/bnema/dumber/internal/ui"
2730 "github.com/bnema/dumber/internal/ui/theme"
28- "github.com/rs/zerolog"
2931 )
3032
31-// Build-time variables (set via ldflags)
33+// Build-time variables (set via ldflags).
3234 var (
3335 version = "dev"
3436 commit = "unknown"
@@ -38,14 +40,16 @@ var (
3840 // initialURL holds the URL to open on startup (from browse command).
3941 var initialURL string
4042
43+// restoreSessionID holds the session ID to restore on startup.
44+var restoreSessionID string
45+
4146 func main() {
4247 // Run GUI mode for browse command
4348 if len(os.Args) > 1 && os.Args[1] == "browse" {
44- // Extract URL if provided
4549 if len(os.Args) > 2 {
4650 initialURL = os.Args[2]
4751 }
48- // Strip "browse" and URL from args so GTK doesn't see them
52+ restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
4953 os.Args = os.Args[:1]
5054 os.Exit(runGUI())
5155 return
@@ -64,190 +68,305 @@ func main() {
6468 }
6569
6670 func runGUI() int {
67- // GTK requires all GTK calls to be made from the main thread
6871 runtime.LockOSThread()
72+ timer := bootstrap.NewStartupTimer()
6973
7074 cfg := initConfig()
75+ timer.Mark("config")
7176
72- // Apply optional /opt-style runtime prefix overrides (if configured)
73- deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
77+ ctx := initStartupContext(cfg)
78+ timer.Mark("logger")
79+ bootstrapLog := logging.FromContext(ctx)
7480
75- bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
76- bootstrapLogger.Info().
77- Str("version", version).
78- Str("commit", commit).
79- Str("build_date", buildDate).
80- Msg("starting dumber")
81-
82- ctx := logging.WithContext(context.Background(), bootstrapLogger)
81+ // Parallel phase: directories and theme setup
82+ initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
83+ Ctx: ctx,
84+ Config: cfg,
85+ })
86+ if err != nil {
87+ handleParallelInitError(ctx, err)
88+ return 1
89+ }
90+ timer.MarkDuration("parallel_phase", initResult.Duration)
8391
84- dataDir, cacheDir := resolveWebKitDirs(bootstrapLogger)
92+ needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore
8593
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+ }
8798 if dbCleanup != nil {
8899 defer dbCleanup()
89100 }
101+ timer.Mark("db_webkit_parallel")
90102
91- browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)
103+ browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB)
92104 if err != nil {
93- bootstrapLogger.Fatal().Err(err).Msg("failed to start session")
105+ bootstrapLog.Fatal().Err(err).Msg("failed to start session")
94106 }
95107 if browserSession.LogCleanup != nil {
96108 defer browserSession.LogCleanup()
97109 }
98- defer func() {
99- _ = browserSession.End(sessionCtx)
100- }()
110+ defer func() { _ = browserSession.End(sessionCtx) }()
111+ timer.Mark("session")
101112
102- logger := browserSession.Logger
103113 ctx = sessionCtx
114+ log := logging.FromContext(ctx)
115+ if stack.MessageRouter != nil {
116+ stack.MessageRouter.SetBaseContext(ctx)
117+ }
104118
105- checkRuntimeRequirements(ctx, cfg, logger)
106- checkMediaRequirements(ctx, cfg, logger)
107-
108- themeManager := theme.NewManager(ctx, cfg)
109- stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger)
110- repos := createRepositories(db)
111- useCases := createUseCases(repos, cfg.DefaultWebpageZoom)
112- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases)
119+ // Repositories and use cases
120+ useCases := createUseCases(repos, cfg)
121+ if needsEagerDB {
122+ handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
123+ }
113124
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)
114140 app, err := ui.New(uiDeps)
115141 if err != nil {
116- logger.Error().Err(err).Msg("failed to create application")
142+ log.Error().Err(err).Msg("failed to create application")
117143 return 1
118144 }
145+ timer.Mark("ui_deps")
146+ timer.Log(ctx)
119147
120- // Ensure sessions are closed on Ctrl+C / SIGTERM.
121- // Without this, the process may exit immediately and skip defers,
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- }()
148+ // Signal handling
149+ setupSignalHandler(ctx, app)
131150
132- // Run the application
133- exitCode := app.Run(ctx, os.Args)
134- return exitCode
151+ return app.Run(ctx, os.Args)
135152 }
136153
137-func initConfig() *config.Config {
138- if err := config.Init(); err != nil {
139- fmt.Fprintf(os.Stderr, "Failed to initialize configuration: %v\n", err)
140- os.Exit(1)
141- }
142- return config.Get()
154+func initStartupContext(cfg *config.Config) context.Context {
155+ deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
156+ bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat(
157+ cfg.Logging.Level,
158+ cfg.Logging.Format,
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
143168 }
144169
145-func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
146- probe := deps.NewPkgConfigProbe()
147- checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
148- runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
149- Prefix: cfg.Runtime.Prefix,
170+func initStackAndRepos(
171+ ctx context.Context,
172+ cfg *config.Config,
173+ initResult *bootstrap.ParallelInitResult,
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,
150201 })
202+
203+ lazyDB, err := bootstrap.CreateLazyDatabase()
151204 if err != nil {
152- logger.Fatal().Err(err).Msg("runtime requirements check failed")
205+ return stack, nil, nil, err
153206 }
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 {
155217 return
156218 }
157- for _, c := range runtimeOut.Checks {
158- if c.Installed {
159- logger.Error().
160- Str("dependency", c.PkgConfigName).
161- Str("have", c.Version).
162- Str("need", c.RequiredVersion).
163- Bool("ok", c.MeetsRequirement).
164- Msg("runtime dependency")
165- } else {
166- logger.Error().
167- Str("dependency", c.PkgConfigName).
168- Str("need", c.RequiredVersion).
169- Msg("runtime dependency missing")
219+ uiDeps.OnFirstWebViewShown = func(cbCtx context.Context) {
220+ logger := logging.FromContext(cbCtx)
221+ bgCtx := logging.WithContext(context.Background(), *logger)
222+ go func() {
223+ result := bootstrap.RunDeferredInit(bootstrap.DeferredInitInput{
224+ Ctx: bgCtx,
225+ Config: cfg,
226+ })
227+ logDeferredInitResults(bgCtx, result)
228+ }()
229+ if session != nil && session.Persist != nil {
230+ go func() {
231+ if persistErr := session.Persist(bgCtx); persistErr != nil {
232+ logger.Error().Err(persistErr).Msg("deferred session persistence failed")
233+ }
234+ }()
170235 }
171236 }
172- logger.Fatal().
173- Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
174- Msg("runtime requirements not met")
175237 }
176238
177-func checkMediaRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
178- mediaDiagAdapter := media.New()
179- checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
180- if _, mediaErr := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
181- ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
182- }); mediaErr != nil {
183- logger.Fatal().Err(mediaErr).Msg("media requirements check failed")
239+func initConfig() *config.Config {
240+ if err := config.Init(); err != nil {
241+ fmt.Fprintf(os.Stderr, "Failed to initialize configuration: %v\n", err)
242+ os.Exit(1)
243+ }
244+ return config.Get()
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")
184254 }
255+ log.Fatal().Err(err).Msg("initialization failed")
185256 }
186257
187-func resolveWebKitDirs(logger zerolog.Logger) (string, string) {
188- const cacheDirPerm = 0o755
189- dataDir, err := config.GetDataDir()
190- if err != nil {
191- logger.Fatal().Err(err).Msg("failed to resolve data directory")
258+func logDeferredInitResults(ctx context.Context, result bootstrap.DeferredInitResult) {
259+ log := logging.FromContext(ctx)
260+ if result.SQLiteErr != nil {
261+ log.Warn().Err(result.SQLiteErr).Msg("deferred sqlite wasm init failed")
192262 }
193- stateDir, err := config.GetStateDir()
194- if err != nil {
195- logger.Fatal().Err(err).Msg("failed to resolve state directory")
263+ if result.RuntimeErr != nil {
264+ if runtimeErr, ok := result.RuntimeErr.(*bootstrap.RuntimeRequirementsError); ok {
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+ }
196272 }
197- cacheDir := filepath.Join(stateDir, "webkit-cache")
198- if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {
199- logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory")
273+ if result.MediaErr != nil {
274+ log.Warn().Err(result.MediaErr).Msg("media check failed")
200275 }
201- return dataDir, cacheDir
276+ log.Debug().Dur("duration", result.Duration).Msg("deferred init complete")
202277 }
203278
204-func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (*sql.DB, func()) {
205- dbPath := filepath.Join(dataDir, "dumber.db")
206- db, err := sqlite.NewConnection(ctx, dbPath)
279+func handleAutoRestore(
280+ ctx context.Context,
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+ })
207292 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
209295 }
210- cleanup := func() {
211- if err := sqlite.Close(db); err != nil {
212- logger.Error().Err(err).Msg("failed to close database")
213- }
296+ if out.SessionID != "" {
297+ restoreSessionID = string(out.SessionID)
298+ log.Info().
299+ Str("session_id", restoreSessionID).
300+ Int("tabs", len(out.State.Tabs)).
301+ Msg("auto-restore: found last session")
214302 }
215- return db, cleanup
303+}
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+ }()
216315 }
217316
218317 // repositories groups infrastructure layer repository implementations.
219318 type repositories struct {
220- history repository.HistoryRepository
221- favorite repository.FavoriteRepository
222- folder repository.FolderRepository
223- tag repository.TagRepository
224- zoom repository.ZoomRepository
319+ history repository.HistoryRepository
320+ favorite repository.FavoriteRepository
321+ folder repository.FolderRepository
322+ tag repository.TagRepository
323+ zoom repository.ZoomRepository
324+ session repository.SessionRepository
325+ sessionState repository.SessionStateRepository
225326 }
226327
227328 func createRepositories(db *sql.DB) *repositories {
228329 return &repositories{
229- history: sqlite.NewHistoryRepository(db),
230- favorite: sqlite.NewFavoriteRepository(db),
231- folder: sqlite.NewFolderRepository(db),
232- tag: sqlite.NewTagRepository(db),
233- zoom: sqlite.NewZoomRepository(db),
330+ history: sqlite.NewHistoryRepository(db),
331+ favorite: sqlite.NewFavoriteRepository(db),
332+ folder: sqlite.NewFolderRepository(db),
333+ tag: sqlite.NewTagRepository(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),
234349 }
235350 }
236351
237352 // useCases groups application layer use case implementations.
238353 type useCases struct {
239- tabs *usecase.ManageTabsUseCase
240- panes *usecase.ManagePanesUseCase
241- history *usecase.SearchHistoryUseCase
242- favorites *usecase.ManageFavoritesUseCase
243- zoom *usecase.ManageZoomUseCase
244- navigate *usecase.NavigateUseCase
245- copyURL *usecase.CopyURLUseCase
246- clipboard port.Clipboard
247- favicon *favicon.Service
354+ tabs *usecase.ManageTabsUseCase
355+ panes *usecase.ManagePanesUseCase
356+ history *usecase.SearchHistoryUseCase
357+ favorites *usecase.ManageFavoritesUseCase
358+ zoom *usecase.ManageZoomUseCase
359+ navigate *usecase.NavigateUseCase
360+ copyURL *usecase.CopyURLUseCase
361+ snapshot *usecase.SnapshotSessionUseCase
362+ lastRestorable *usecase.GetLastRestorableSessionUseCase
363+ checkUpdate *usecase.CheckUpdateUseCase
364+ applyUpdate *usecase.ApplyUpdateUseCase
365+ clipboard port.Clipboard
366+ favicon *favicon.Service
248367 }
249368
250-func createUseCases(repos *repositories, defaultZoom float64) *useCases {
369+func createUseCases(repos *repositories, cfg *config.Config) *useCases {
251370 const idAlphabetSize = 26
252371 idCounter := uint64(0)
253372 idGenerator := func() string {
@@ -257,17 +376,35 @@ func createUseCases(repos *repositories, defaultZoom float64) *useCases {
257376
258377 clipboardAdapter := clipboard.New()
259378 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)
260393
261394 return &useCases{
262- tabs: usecase.NewManageTabsUseCase(idGenerator),
263- panes: usecase.NewManagePanesUseCase(idGenerator),
264- history: usecase.NewSearchHistoryUseCase(repos.history),
265- favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
266- zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom),
267- navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
268- copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
269- clipboard: clipboardAdapter,
270- favicon: favicon.NewService(faviconCacheDir),
395+ tabs: usecase.NewManageTabsUseCase(idGenerator),
396+ panes: usecase.NewManagePanesUseCase(idGenerator),
397+ history: usecase.NewSearchHistoryUseCase(repos.history),
398+ favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
399+ zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
400+ navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
401+ copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
402+ snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
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),
271408 }
272409 }
273410
@@ -275,32 +412,46 @@ func buildUIDependencies(
275412 ctx context.Context,
276413 cfg *config.Config,
277414 themeManager *theme.Manager,
415+ colorResolver port.ColorSchemeResolver,
416+ adwaitaDetector *colorscheme.AdwaitaDetector,
278417 stack *bootstrap.WebKitStack,
279418 repos *repositories,
280419 uc *useCases,
420+ idleInhibitor port.IdleInhibitor,
421+ currentSessionID entity.SessionID,
281422 ) *ui.Dependencies {
282423 return &ui.Dependencies{
283- Ctx: ctx,
284- Config: cfg,
285- InitialURL: initialURL,
286- Theme: themeManager,
287- WebContext: stack.Context,
288- Pool: stack.Pool,
289- Settings: stack.Settings,
290- Injector: stack.Injector,
291- MessageRouter: stack.MessageRouter,
292- FilterManager: stack.FilterManager,
293- HistoryRepo: repos.history,
294- FavoriteRepo: repos.favorite,
295- ZoomRepo: repos.zoom,
296- TabsUC: uc.tabs,
297- PanesUC: uc.panes,
298- HistoryUC: uc.history,
299- FavoritesUC: uc.favorites,
300- ZoomUC: uc.zoom,
301- NavigateUC: uc.navigate,
302- CopyURLUC: uc.copyURL,
303- Clipboard: uc.clipboard,
304- FaviconService: uc.favicon,
424+ Ctx: ctx,
425+ Config: cfg,
426+ InitialURL: initialURL,
427+ RestoreSessionID: restoreSessionID,
428+ Theme: themeManager,
429+ ColorResolver: colorResolver,
430+ AdwaitaDetector: adwaitaDetector,
431+ WebContext: stack.Context,
432+ Pool: stack.Pool,
433+ Settings: stack.Settings,
434+ Injector: stack.Injector,
435+ MessageRouter: stack.MessageRouter,
436+ FilterManager: stack.FilterManager,
437+ HistoryRepo: repos.history,
438+ FavoriteRepo: repos.favorite,
439+ ZoomRepo: repos.zoom,
440+ TabsUC: uc.tabs,
441+ PanesUC: uc.panes,
442+ HistoryUC: uc.history,
443+ FavoritesUC: uc.favorites,
444+ ZoomUC: uc.zoom,
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,
305456 }
306457 }
M docs/CONFIG.md +246 -22
@@ -83,14 +83,14 @@ These settings control the `dumber dmenu` CLI command for rofi/fuzzel integratio
8383
8484 | Key | Type | Default | Description |
8585 |-----|------|---------|-------------|
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) |
8787 | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output |
8888 | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output |
8989 | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items |
9090 | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts |
9191 | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs |
9292 | `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 |
9494
9595 **CLI Usage:**
9696 ```bash
@@ -101,8 +101,14 @@ dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select
101101 # Interactive TUI mode
102102 dumber dmenu --interactive
103103
104-# Override max items via CLI flag
105-dumber dmenu --max 50
104+# Override history days via CLI flag (show last 7 days)
105+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
106112 ```
107113
108114 ## Omnibox
@@ -110,11 +116,13 @@ dumber dmenu --max 50
110116 | Key | Type | Default | Valid Values | Description |
111117 |-----|------|---------|--------------|-------------|
112118 | `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 |
113120
114121 **Example:**
115122 ```toml
116123 [omnibox]
117124 initial_behavior = "recent" # Show recent history when omnibox opens
125+auto_open_on_new_pane = false
118126
119127 # Alternative options:
120128 # initial_behavior = "most_visited" # Show most visited sites
@@ -182,7 +190,7 @@ border = "#363636"
182190 | `rendering.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit DMA-BUF renderer (may fix flicker on Wayland; slower) |
183191 | `rendering.force_compositing_mode` | bool | `false` | - | Force WebKit compositing mode (`WEBKIT_FORCE_COMPOSITING_MODE`) |
184192 | `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`) |
186194 | `rendering.disable_mipmaps` | bool | `false` | - | Disable GTK mipmaps (`GSK_GPU_DISABLE=mipmap`) |
187195 | `rendering.prefer_gl` | bool | `false` | - | Prefer OpenGL over GLES (`GDK_DEBUG=gl-prefer-gl`) |
188196 | `rendering.draw_compositing_indicators` | bool | `false` | - | Draw WebKit compositing indicators (debug) |
@@ -194,6 +202,19 @@ border = "#363636"
194202
195203 ## Workspace Configuration
196204
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+
197218 ### Pane Mode
198219
199220 | Key | Type | Default | Description |
@@ -211,6 +232,15 @@ split-up = ["arrowup", "u"]
211232 split-down = ["arrowdown", "d"]
212233 stack-pane = ["s"]
213234 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+
214244 focus-right = ["shift+arrowright", "shift+l"]
215245 focus-left = ["shift+arrowleft", "shift+h"]
216246 focus-up = ["shift+arrowup", "shift+k"]
@@ -243,6 +273,38 @@ cancel = ["escape"]
243273
244274 > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation.
245275
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+
246308 ### Global Shortcuts
247309
248310 | Key | Type | Default | Description |
@@ -250,8 +312,14 @@ cancel = ["escape"]
250312 | `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) |
251313 | `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut |
252314 | `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) |
253319
254320 > **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.
255323
256324 ### Popup Behavior
257325
@@ -271,24 +339,72 @@ cancel = ["escape"]
271339 |-----|------|---------|-------------|
272340 | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay |
273341 | `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 |
275-| `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) |
276-| `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay |
277-| `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |
342+| `workspace.styling.mode_border_width` | int | `4` | Modal mode border width (px) - applies to all modes |
343+| `workspace.styling.pane_mode_color` | string | `"#4A90E2"` | Pane mode color (blue) - used for border and toaster |
344+| `workspace.styling.tab_mode_color` | string | `"#FFA500"` | Tab mode color (orange) - used for border and toaster |
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 |
278348 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
279349
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+
280398 ## Media
281399
282400 | Key | Type | Default | Valid Values | Description |
283401 |-----|------|---------|--------------|-------------|
284402 | `media.hardware_decoding` | string | `"auto"` | `auto`, `force`, `disable` | Hardware video decoding mode |
285-| `media.prefer_av1` | bool | `true` | - | Prefer AV1 codec when available |
286-| `media.show_diagnostics` | bool | `true` | - | Show media diagnostics warnings at startup |
403+| `media.prefer_av1` | bool | `false` | - | Prefer AV1 codec when available |
404+| `media.show_diagnostics` | bool | `false` | - | Show media diagnostics warnings at startup |
287405 | `media.force_vsync` | bool | `false` | - | Force VSync for video playback (may help with tearing) |
288406 | `media.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | OpenGL API selection for video rendering |
289407 | `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 |
292408
293409 **Hardware decoding modes:**
294410 - `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
307423 - **Intel**: Uses `iHD` driver (modern, for Broadwell+)
308424 - **NVIDIA**: Uses `nvidia` driver with EGL platform
309425
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-
314426 **Example:**
315427 ```toml
316428 [media]
317429 hardware_decoding = "auto" # HW preferred, SW fallback
318-prefer_av1 = true # AV1 is most efficient codec
319-show_diagnostics = true # Log warnings if HW accel unavailable
430+prefer_av1 = false # Let site choose codec
431+show_diagnostics = false # Enable for debugging
320432 force_vsync = false # Let compositor handle VSync
321433 gl_rendering_mode = "auto" # GStreamer picks best GL API
322434 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
325435 ```
326436
327437 **Diagnostics CLI:**
@@ -359,6 +469,120 @@ Notes:
359469 - Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases.
360470 - Domain whitelist is managed via the database (`content_whitelist` table), not the config file.
361471
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+
362586 ## Environment Variables
363587
364588 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
368592 DUMBER_DATABASE_PATH=/custom/path/db.sqlite
369593
370594 # Rendering (examples)
371-# Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="vulkan"
595+# Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="auto"
372596 DUMBER_RENDERING_MODE=cpu
373597 # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true
374598 # DUMBER_RENDERING_GSK_RENDERER=opengl
M go.mod +7 -4
@@ -3,19 +3,23 @@ module github.com/bnema/dumber
33 go 1.25.3
44
55 require (
6- github.com/bnema/puregotk-webkit v0.0.7
6+ github.com/bnema/puregotk-webkit v0.0.9
77 github.com/charmbracelet/bubbles v0.21.0
88 github.com/charmbracelet/bubbletea v1.3.10
99 github.com/charmbracelet/lipgloss v1.1.0
1010 github.com/fsnotify/fsnotify v1.9.0
11+ github.com/godbus/dbus/v5 v5.2.1
1112 github.com/jwijenbergh/puregotk v0.0.0-20251222174431-73930527faa3
1213 github.com/ncruces/go-sqlite3 v0.30.3
14+ github.com/pelletier/go-toml/v2 v2.2.4
1315 github.com/pressly/goose/v3 v3.26.0
1416 github.com/rs/zerolog v1.33.0
1517 github.com/spf13/cobra v1.10.2
1618 github.com/spf13/viper v1.21.0
1719 github.com/stretchr/testify v1.11.1
1820 golang.org/x/image v0.34.0
21+ golang.org/x/sys v0.38.0
22+ gopkg.in/yaml.v3 v3.0.1
1923 )
2024
2125 replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3
@@ -27,6 +31,7 @@ require (
2731 github.com/charmbracelet/x/ansi v0.10.1 // indirect
2832 github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
2933 github.com/charmbracelet/x/term v0.2.1 // indirect
34+ github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
3035 github.com/davecgh/go-spew v1.1.1 // indirect
3136 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
3237 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
@@ -42,9 +47,9 @@ require (
4247 github.com/muesli/cancelreader v0.2.2 // indirect
4348 github.com/muesli/termenv v0.16.0 // indirect
4449 github.com/ncruces/julianday v1.0.0 // indirect
45- github.com/pelletier/go-toml/v2 v2.2.4 // indirect
4650 github.com/pmezard/go-difflib v1.0.0 // indirect
4751 github.com/rivo/uniseg v0.4.7 // indirect
52+ github.com/russross/blackfriday/v2 v2.1.0 // indirect
4853 github.com/sagikazarmark/locafero v0.11.0 // indirect
4954 github.com/sahilm/fuzzy v0.1.1 // indirect
5055 github.com/sethvargo/go-retry v0.3.0 // indirect
@@ -59,7 +64,5 @@ require (
5964 go.uber.org/multierr v1.11.0 // indirect
6065 go.yaml.in/yaml/v3 v3.0.4 // indirect
6166 golang.org/x/sync v0.19.0 // indirect
62- golang.org/x/sys v0.38.0 // indirect
6367 golang.org/x/text v0.32.0 // indirect
64- gopkg.in/yaml.v3 v3.0.1 // indirect
6568 )
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
66
77 import (
88 "context"
9+ "database/sql"
910
1011 "github.com/bnema/dumber/internal/application/port"
1112 "github.com/bnema/dumber/internal/domain/entity"
@@ -13,13 +14,1589 @@ import (
1314 mock "github.com/stretchr/testify/mock"
1415 )
1516
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+
161139 // 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.
171140 // 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 {
191596 mock.TestingT
201597 Cleanup(func())
21-}) *MockSessionLogger {
22- mock := &MockSessionLogger{}
1598+}) *MockWebKitContextProvider {
1599+ mock := &MockWebKitContextProvider{}
231600 mock.Mock.Test(t)
241601
251602 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -27,99 +1604,248 @@ func NewMockSessionLogger(t interface {
271604 return mock
281605 }
291606
30-// MockSessionLogger is an autogenerated mock type for the SessionLogger type
31-type MockSessionLogger struct {
1607+// MockWebKitContextProvider is an autogenerated mock type for the WebKitContextProvider type
1608+type MockWebKitContextProvider struct {
321609 mock.Mock
331610 }
341611
35-type MockSessionLogger_Expecter struct {
1612+type MockWebKitContextProvider_Expecter struct {
361613 mock *mock.Mock
371614 }
381615
39-func (_m *MockSessionLogger) EXPECT() *MockSessionLogger_Expecter {
40- return &MockSessionLogger_Expecter{mock: &_m.Mock}
1616+func (_m *MockWebKitContextProvider) EXPECT() *MockWebKitContextProvider_Expecter {
1617+ return &MockWebKitContextProvider_Expecter{mock: &_m.Mock}
411618 }
421619
43-// CreateLogger provides a mock function for the type MockSessionLogger
44-func (_mock *MockSessionLogger) CreateLogger(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig) (zerolog.Logger, func(), error) {
45- ret := _mock.Called(ctx, sessionID, cfg)
1620+// CacheDir provides a mock function for the type MockWebKitContextProvider
1621+func (_mock *MockWebKitContextProvider) CacheDir() string {
1622+ ret := _mock.Called()
461623
471624 if len(ret) == 0 {
48- panic("no return value specified for CreateLogger")
1625+ panic("no return value specified for CacheDir")
491626 }
501627
51- var r0 zerolog.Logger
52- var r1 func()
53- var r2 error
54- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, port.SessionLogConfig) (zerolog.Logger, func(), error)); ok {
55- return returnFunc(ctx, sessionID, cfg)
1628+ var r0 string
1629+ if returnFunc, ok := ret.Get(0).(func() string); ok {
1630+ r0 = returnFunc()
1631+ } else {
1632+ r0 = ret.Get(0).(string)
561633 }
57- if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, port.SessionLogConfig) zerolog.Logger); ok {
58- r0 = returnFunc(ctx, sessionID, cfg)
1634+ return r0
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()
591675 } else {
60- r0 = ret.Get(0).(zerolog.Logger)
1676+ r0 = ret.Error(0)
611677 }
62- if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID, port.SessionLogConfig) func()); ok {
63- r1 = returnFunc(ctx, sessionID, cfg)
1678+ return r0
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()
641719 } else {
65- if ret.Get(1) != nil {
66- r1 = ret.Get(1).(func())
67- }
1720+ r0 = ret.Get(0).(string)
681721 }
69- if returnFunc, ok := ret.Get(2).(func(context.Context, entity.SessionID, port.SessionLogConfig) error); ok {
70- r2 = returnFunc(ctx, sessionID, cfg)
1722+ return r0
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)
711763 } else {
72- r2 = ret.Error(2)
1764+ r0 = ret.Error(0)
731765 }
74- return r0, r1, r2
1766+ return r0
751767 }
761768
77-// MockSessionLogger_CreateLogger_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateLogger'
78-type MockSessionLogger_CreateLogger_Call struct {
1769+// MockWebKitContextProvider_Initialize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Initialize'
1770+type MockWebKitContextProvider_Initialize_Call struct {
791771 *mock.Call
801772 }
811773
82-// CreateLogger is a helper method to define mock.On call
1774+// Initialize is a helper method to define mock.On call
831775 // - ctx context.Context
84-// - sessionID entity.SessionID
85-// - cfg port.SessionLogConfig
86-func (_e *MockSessionLogger_Expecter) CreateLogger(ctx interface{}, sessionID interface{}, cfg interface{}) *MockSessionLogger_CreateLogger_Call {
87- return &MockSessionLogger_CreateLogger_Call{Call: _e.mock.On("CreateLogger", ctx, sessionID, cfg)}
1776+// - opts port.WebKitContextOptions
1777+func (_e *MockWebKitContextProvider_Expecter) Initialize(ctx interface{}, opts interface{}) *MockWebKitContextProvider_Initialize_Call {
1778+ return &MockWebKitContextProvider_Initialize_Call{Call: _e.mock.On("Initialize", ctx, opts)}
881779 }
891780
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 {
911782 _c.Call.Run(func(args mock.Arguments) {
921783 var arg0 context.Context
931784 if args[0] != nil {
941785 arg0 = args[0].(context.Context)
951786 }
96- var arg1 entity.SessionID
1787+ var arg1 port.WebKitContextOptions
971788 if args[1] != nil {
98- arg1 = args[1].(entity.SessionID)
99- }
100- var arg2 port.SessionLogConfig
101- if args[2] != nil {
102- arg2 = args[2].(port.SessionLogConfig)
1789+ arg1 = args[1].(port.WebKitContextOptions)
1031790 }
1041791 run(
1051792 arg0,
1061793 arg1,
107- arg2,
1081794 )
1091795 })
1101796 return _c
1111797 }
1121798
113-func (_c *MockSessionLogger_CreateLogger_Call) Return(logger zerolog.Logger, fn func(), err error) *MockSessionLogger_CreateLogger_Call {
114- _c.Call.Return(logger, fn, err)
1799+func (_c *MockWebKitContextProvider_Initialize_Call) Return(err error) *MockWebKitContextProvider_Initialize_Call {
1800+ _c.Call.Return(err)
1151801 return _c
1161802 }
1171803
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 {
1191805 _c.Call.Return(run)
1201806 return _c
1211807 }
1221808
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+
1231849 // 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.
1241850 // The first argument is typically a *testing.T value.
1251851 func NewMockWebView(t interface {
@@ -592,6 +2318,50 @@ func (_c *MockWebView_IsDestroyed_Call) RunAndReturn(run func() bool) *MockWebVi
5922318 return _c
5932319 }
5942320
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+
5952365 // IsLoading provides a mock function for the type MockWebView
5962366 func (_mock *MockWebView) IsLoading() bool {
5972367 ret := _mock.Called()
@@ -636,6 +2406,50 @@ func (_c *MockWebView_IsLoading_Call) RunAndReturn(run func() bool) *MockWebView
6362406 return _c
6372407 }
6382408
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+
6392453 // LoadHTML provides a mock function for the type MockWebView
6402454 func (_mock *MockWebView) LoadHTML(ctx context.Context, content string, baseURI string) error {
6412455 ret := _mock.Called(ctx, content, baseURI)
M internal/application/port/rendering_env.go +10 -0
@@ -46,6 +46,16 @@ type RenderingEnvSettings struct {
4646 ShowFPS bool
4747 SampleMemory bool
4848 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
4959 }
5060
5161 // 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"
55 // WebUIConfig represents the subset of configuration editable in dumb://config.
66 type WebUIConfig struct {
77 Appearance WebUIAppearanceConfig `json:"appearance"`
8+ Performance WebUIPerformanceConfig `json:"performance"`
89 DefaultUIScale float64 `json:"default_ui_scale"`
910 DefaultSearchEngine string `json:"default_search_engine"`
1011 SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
1112 }
1213
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+
1329 type WebUIAppearanceConfig struct {
1430 SansFont string `json:"sans_font"`
1531 SerifFont string `json:"serif_font"`
M internal/application/port/webview.go +11 -0
@@ -89,6 +89,9 @@ type WebViewCallbacks struct {
8989 OnCreate func(request PopupRequest) WebView
9090 // OnReadyToShow is called when a popup WebView is ready to display.
9191 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)
9295 }
9396
9497 // FindOptions configures search behavior.
@@ -190,6 +193,14 @@ type WebView interface {
190193 // Pass nil to clear all callbacks.
191194 SetCallbacks(callbacks *WebViewCallbacks)
192195
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+
193204 // --- Lifecycle ---
194205
195206 // IsDestroyed returns true if the WebView has been destroyed.
M internal/application/port/xdg.go +4 -0
@@ -10,4 +10,8 @@ type XDGPaths interface {
1010 FilterJSONDir() (string, error)
1111 FilterStoreDir() (string, error)
1212 FilterCacheDir() (string, error)
13+
14+ // ManDir returns the user man page directory (man1 section).
15+ // Typically $XDG_DATA_HOME/man/man1 or ~/.local/share/man/man1.
16+ ManDir() (string, error)
1317 }
A internal/application/usecase/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
196196 return fav != nil, nil
197197 }
198198
199+// ToggleResult indicates the result of a toggle operation.
200+type ToggleResult struct {
201+ Added bool // true if favorite was added, false if removed
202+ URL string
203+ Title string
204+ Message string // User-friendly message
205+}
206+
207+// Toggle adds or removes a URL from favorites based on current state.
208+// If the URL is already a favorite, it removes it. Otherwise, it adds it.
209+func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) {
210+ log := logging.FromContext(ctx)
211+ log.Debug().Str("url", url).Msg("toggling favorite")
212+
213+ if url == "" {
214+ return nil, fmt.Errorf("URL cannot be empty")
215+ }
216+
217+ // Check if already favorited
218+ existing, err := uc.favoriteRepo.FindByURL(ctx, url)
219+ if err != nil {
220+ return nil, fmt.Errorf("failed to check existing favorite: %w", err)
221+ }
222+
223+ if existing != nil {
224+ // Remove favorite
225+ if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
226+ return nil, fmt.Errorf("failed to remove favorite: %w", err)
227+ }
228+ log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
229+ return &ToggleResult{
230+ Added: false,
231+ URL: url,
232+ Title: existing.Title,
233+ Message: "Favorite removed",
234+ }, nil
235+ }
236+
237+ // Add favorite
238+ fav := entity.NewFavorite(url, title)
239+ if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
240+ return nil, fmt.Errorf("failed to add favorite: %w", err)
241+ }
242+ log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
243+ return &ToggleResult{
244+ Added: true,
245+ URL: url,
246+ Title: title,
247+ Message: "Favorite added",
248+ }, nil
249+}
250+
251+// GetAllURLs returns a set of all favorited URLs for efficient batch lookup.
252+func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) {
253+ log := logging.FromContext(ctx)
254+ log.Debug().Msg("getting all favorite URLs")
255+
256+ favs, err := uc.favoriteRepo.GetAll(ctx)
257+ if err != nil {
258+ return nil, fmt.Errorf("failed to get favorites: %w", err)
259+ }
260+
261+ urls := make(map[string]struct{}, len(favs))
262+ for _, fav := range favs {
263+ urls[fav.URL] = struct{}{}
264+ }
265+
266+ log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs")
267+ return urls, nil
268+}
269+
199270 // GetTree builds a complete hierarchical view of folders and favorites.
200271 func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) {
201272 log := logging.FromContext(ctx)
A internal/application/usecase/manage_favorites_test.go +200 -0
@@ -0,0 +1,200 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestManageFavoritesUseCase_GetAllURLs_ReturnsURLSet(t *testing.T) {
16+ ctx := testContext()
17+
18+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
19+ folderRepo := repomocks.NewMockFolderRepository(t)
20+ tagRepo := repomocks.NewMockTagRepository(t)
21+
22+ favorites := []*entity.Favorite{
23+ {ID: 1, URL: "https://example.com"},
24+ {ID: 2, URL: "https://github.com"},
25+ {ID: 3, URL: "https://google.com"},
26+ }
27+
28+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
29+
30+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
31+
32+ urls, err := uc.GetAllURLs(ctx)
33+ require.NoError(t, err)
34+ require.NotNil(t, urls)
35+
36+ assert.Len(t, urls, 3)
37+ _, hasExample := urls["https://example.com"]
38+ _, hasGithub := urls["https://github.com"]
39+ _, hasGoogle := urls["https://google.com"]
40+ assert.True(t, hasExample)
41+ assert.True(t, hasGithub)
42+ assert.True(t, hasGoogle)
43+}
44+
45+func TestManageFavoritesUseCase_GetAllURLs_ReturnsEmptySetWhenNoFavorites(t *testing.T) {
46+ ctx := testContext()
47+
48+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
49+ folderRepo := repomocks.NewMockFolderRepository(t)
50+ tagRepo := repomocks.NewMockTagRepository(t)
51+
52+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
53+
54+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
55+
56+ urls, err := uc.GetAllURLs(ctx)
57+ require.NoError(t, err)
58+ require.NotNil(t, urls)
59+ assert.Empty(t, urls)
60+}
61+
62+func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing.T) {
63+ ctx := testContext()
64+
65+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
66+ folderRepo := repomocks.NewMockFolderRepository(t)
67+ tagRepo := repomocks.NewMockTagRepository(t)
68+
69+ favoriteRepo.EXPECT().GetAll(mock.Anything).Return(nil, errors.New("db connection failed"))
70+
71+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
72+
73+ urls, err := uc.GetAllURLs(ctx)
74+ require.Error(t, err)
75+ assert.Nil(t, urls)
76+ assert.Contains(t, err.Error(), "failed to get favorites")
77+}
78+
79+func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) {
80+ ctx := testContext()
81+
82+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
83+ folderRepo := repomocks.NewMockFolderRepository(t)
84+ tagRepo := repomocks.NewMockTagRepository(t)
85+
86+ // URL is not a favorite
87+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil)
88+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(f *entity.Favorite) bool {
89+ return f.URL == "https://example.com" && f.Title == "Example"
90+ })).Return(nil)
91+
92+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
93+
94+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
95+ require.NoError(t, err)
96+ require.NotNil(t, result)
97+
98+ assert.True(t, result.Added)
99+ assert.Equal(t, "https://example.com", result.URL)
100+ assert.Equal(t, "Example", result.Title)
101+ assert.Equal(t, "Favorite added", result.Message)
102+}
103+
104+func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T) {
105+ ctx := testContext()
106+
107+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
108+ folderRepo := repomocks.NewMockFolderRepository(t)
109+ tagRepo := repomocks.NewMockTagRepository(t)
110+
111+ existingFav := &entity.Favorite{
112+ ID: 42,
113+ URL: "https://example.com",
114+ Title: "Example Site",
115+ }
116+
117+ // URL is already a favorite
118+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)
119+ favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil)
120+
121+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
122+
123+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
124+ require.NoError(t, err)
125+ require.NotNil(t, result)
126+
127+ assert.False(t, result.Added)
128+ assert.Equal(t, "https://example.com", result.URL)
129+ assert.Equal(t, "Example Site", result.Title) // Uses existing title
130+ assert.Equal(t, "Favorite removed", result.Message)
131+}
132+
133+func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) {
134+ ctx := testContext()
135+
136+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
137+ folderRepo := repomocks.NewMockFolderRepository(t)
138+ tagRepo := repomocks.NewMockTagRepository(t)
139+
140+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
141+
142+ result, err := uc.Toggle(ctx, "", "Title")
143+ require.Error(t, err)
144+ assert.Nil(t, result)
145+ assert.Contains(t, err.Error(), "URL cannot be empty")
146+}
147+
148+func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) {
149+ ctx := testContext()
150+
151+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
152+ folderRepo := repomocks.NewMockFolderRepository(t)
153+ tagRepo := repomocks.NewMockTagRepository(t)
154+
155+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error"))
156+
157+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
158+
159+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
160+ require.Error(t, err)
161+ assert.Nil(t, result)
162+ assert.Contains(t, err.Error(), "failed to check existing favorite")
163+}
164+
165+func TestManageFavoritesUseCase_Toggle_SaveErrorReturnsError(t *testing.T) {
166+ ctx := testContext()
167+
168+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
169+ folderRepo := repomocks.NewMockFolderRepository(t)
170+ tagRepo := repomocks.NewMockTagRepository(t)
171+
172+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil)
173+ favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error"))
174+
175+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
176+
177+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
178+ require.Error(t, err)
179+ assert.Nil(t, result)
180+ assert.Contains(t, err.Error(), "failed to add favorite")
181+}
182+
183+func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) {
184+ ctx := testContext()
185+
186+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
187+ folderRepo := repomocks.NewMockFolderRepository(t)
188+ tagRepo := repomocks.NewMockTagRepository(t)
189+
190+ existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"}
191+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)
192+ favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error"))
193+
194+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
195+
196+ result, err := uc.Toggle(ctx, "https://example.com", "Example")
197+ require.Error(t, err)
198+ assert.Nil(t, result)
199+ assert.Contains(t, err.Error(), "failed to remove favorite")
200+}
M internal/application/usecase/manage_panes.go +851 -1
@@ -2,10 +2,14 @@ package usecase
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
7+ "math"
68 "sort"
9+ "strings"
710
811 "github.com/bnema/dumber/internal/domain/entity"
12+ domainurl "github.com/bnema/dumber/internal/domain/url"
913 "github.com/bnema/dumber/internal/logging"
1014 )
1115
@@ -29,6 +33,42 @@ const (
2933 NavDown NavigateDirection = "down"
3034 )
3135
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+
3272 // ManagePanesUseCase handles pane tree operations.
3373 type ManagePanesUseCase struct {
3474 idGenerator IDGenerator
@@ -91,7 +131,7 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
91131 paneID := entity.PaneID(uc.idGenerator())
92132 newPane = entity.NewPane(paneID)
93133 if input.InitialURL != "" {
94- newPane.URI = input.InitialURL
134+ newPane.URI = domainurl.Normalize(input.InitialURL)
95135 } else {
96136 newPane.URI = "about:blank"
97137 }
@@ -166,6 +206,254 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) (
166206 }, nil
167207 }
168208
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+
169457 // Close removes a pane and promotes its sibling.
170458 // Returns the sibling node that was promoted, or nil if this was the last pane.
171459 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
8031091 return nil
8041092 }
8051093
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+
8061656 // GetAllPanes returns all leaf panes in a workspace.
8071657 //
8081658 //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 (
1313 )
1414
1515 // ManageZoomUseCase handles per-domain zoom level operations.
16+// It uses an LRU cache to avoid database queries on every navigation.
1617 type ManageZoomUseCase struct {
1718 zoomRepo repository.ZoomRepository
1819 defaultZoom float64
20+ cache port.Cache[string, *entity.ZoomLevel]
1921 }
2022
2123 // NewManageZoomUseCase creates a new zoom management use case.
2224 // 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 {
2431 if defaultZoom <= 0 {
2532 defaultZoom = entity.ZoomDefault
2633 }
2734 return &ManageZoomUseCase{
2835 zoomRepo: zoomRepo,
2936 defaultZoom: defaultZoom,
37+ cache: cache,
3038 }
3139 }
3240
@@ -37,10 +45,20 @@ func (uc *ManageZoomUseCase) DefaultZoom() float64 {
3745
3846 // GetZoom retrieves the zoom level for a domain.
3947 // Returns the configured default zoom level if none is set.
48+// Uses LRU cache to avoid database queries on repeat visits.
4049 func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entity.ZoomLevel, error) {
4150 log := logging.FromContext(ctx)
42- log.Debug().Str("domain", domain).Msg("getting zoom level")
4351
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")
4462 zoom, err := uc.zoomRepo.Get(ctx, domain)
4563 if err != nil {
4664 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
5169 log.Debug().Str("domain", domain).Float64("zoom", zoom.ZoomFactor).Msg("using default zoom")
5270 }
5371
72+ // Cache for future lookups
73+ if uc.cache != nil {
74+ uc.cache.Set(domain, zoom)
75+ }
76+
5477 return zoom, nil
5578 }
5679
@@ -64,6 +87,11 @@ func (uc *ManageZoomUseCase) SetZoom(ctx context.Context, domain string, factor
6487 return fmt.Errorf("failed to set zoom level: %w", err)
6588 }
6689
90+ // Update cache
91+ if uc.cache != nil {
92+ uc.cache.Set(domain, zoom)
93+ }
94+
6795 log.Info().Str("domain", domain).Float64("factor", zoom.ZoomFactor).Msg("zoom level saved")
6896 return nil
6997 }
@@ -77,6 +105,11 @@ func (uc *ManageZoomUseCase) ResetZoom(ctx context.Context, domain string) error
77105 return fmt.Errorf("failed to reset zoom level: %w", err)
78106 }
79107
108+ // Invalidate cache
109+ if uc.cache != nil {
110+ uc.cache.Remove(domain)
111+ }
112+
80113 log.Info().Str("domain", domain).Msg("zoom level reset to default")
81114 return nil
82115 }
@@ -98,6 +131,11 @@ func (uc *ManageZoomUseCase) ZoomIn(ctx context.Context, domain string, current
98131 return nil, fmt.Errorf("failed to save zoom level: %w", err)
99132 }
100133
134+ // Update cache
135+ if uc.cache != nil {
136+ uc.cache.Set(domain, zoom)
137+ }
138+
101139 return zoom, nil
102140 }
103141
@@ -118,6 +156,11 @@ func (uc *ManageZoomUseCase) ZoomOut(ctx context.Context, domain string, current
118156 return nil, fmt.Errorf("failed to save zoom level: %w", err)
119157 }
120158
159+ // Update cache
160+ if uc.cache != nil {
161+ uc.cache.Set(domain, zoom)
162+ }
163+
121164 return zoom, nil
122165 }
123166
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 (
1313 "github.com/bnema/dumber/internal/logging"
1414 )
1515
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+
1628 // NavigateUseCase handles URL navigation with history recording and zoom application.
1729 type NavigateUseCase struct {
1830 historyRepo repository.HistoryRepository
1931 zoomRepo repository.ZoomRepository
2032 defaultZoom float64
2133 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
2240 }
2341
2442 // NewNavigateUseCase creates a new navigation use case.
@@ -31,11 +49,28 @@ func NewNavigateUseCase(
3149 if defaultZoom <= 0 {
3250 defaultZoom = entity.ZoomDefault
3351 }
34- return &NavigateUseCase{
35- historyRepo: historyRepo,
36- zoomRepo: zoomRepo,
37- defaultZoom: defaultZoom,
52+
53+ uc := &NavigateUseCase{
54+ historyRepo: historyRepo,
55+ zoomRepo: zoomRepo,
56+ defaultZoom: defaultZoom,
57+ historyQueue: make(chan historyRecord, historyQueueSize),
58+ done: make(chan struct{}),
59+ ctx: context.Background(),
3860 }
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()
3974 }
4075
4176 // NavigateInput contains parameters for navigation.
@@ -79,7 +114,8 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N
79114 // This prevents inflation from redirects and rapid navigation.
80115 const historyDeduplicationWindow = 2 * time.Second
81116
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.
83119 // Should be called on LoadCommitted when URI is guaranteed correct.
84120 func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
85121 log := logging.FromContext(ctx)
@@ -88,6 +124,7 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
88124 url = normalizeURLForHistory(url)
89125
90126 // Time-based deduplication: skip if same URL was recorded recently
127+ // This check is fast (sync.Map lookup) and stays on the main thread
91128 now := time.Now()
92129 if lastTime, ok := uc.recentVisits.Load(url); ok {
93130 if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow {
@@ -97,23 +134,67 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) {
97134 }
98135 uc.recentVisits.Store(url, now)
99136
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+
100181 // Check if entry exists
101- existing, err := uc.historyRepo.FindByURL(ctx, url)
182+ existing, err := uc.historyRepo.FindByURL(ctx, record.url)
102183 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")
104185 return
105186 }
106187
107188 if existing != nil {
108189 // Increment visit count
109- if err := uc.historyRepo.IncrementVisitCount(ctx, url); err != nil {
110- log.Warn().Err(err).Str("url", url).Msg("failed to increment visit count")
190+ if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
191+ log.Warn().Err(err).Str("url", record.url).Msg("failed to increment visit count")
111192 }
112193 } else {
113194 // Create new entry
114- entry := entity.NewHistoryEntry(url, "")
195+ entry := entity.NewHistoryEntry(record.url, "")
115196 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")
117198 }
118199 }
119200 }
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
7575 return entries, nil
7676 }
7777
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+
78128 // FindByURL retrieves a history entry by its URL.
79129 func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
80130 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
22
33 import (
44 "context"
5- "database/sql"
65 "errors"
76 "fmt"
87 "os"
98 "path/filepath"
9+ "sync"
1010 "syscall"
1111 "time"
1212
1313 "github.com/bnema/dumber/internal/application/port"
1414 "github.com/bnema/dumber/internal/application/usecase"
1515 "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/domain/repository"
1617 "github.com/bnema/dumber/internal/infrastructure/config"
1718 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
18- "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
1919 corelogging "github.com/bnema/dumber/internal/logging"
2020 "github.com/rs/zerolog"
2121 )
@@ -31,11 +31,20 @@ type BrowserSession struct {
3131 Logger zerolog.Logger
3232 LogCleanup func()
3333
34- End func(context.Context) error
34+ Persist func(context.Context) error
35+ End func(context.Context) error
3536 }
3637
37-func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*BrowserSession, context.Context, error) {
38+func StartBrowserSession(
39+ ctx context.Context,
40+ cfg *config.Config,
41+ sessionRepo repository.SessionRepository,
42+ deferPersist bool,
43+) (*BrowserSession, context.Context, error) {
3844 log := corelogging.FromContext(ctx)
45+ if sessionRepo == nil {
46+ return nil, ctx, errors.New("session repository is nil")
47+ }
3948
4049 lockDir := cfg.Logging.LogDir
4150 if lockDir == "" {
@@ -44,58 +53,112 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*
4453 }
4554 }
4655
47- sessionRepo := sqlite.NewSessionRepository(db)
4856 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
4957 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
58+ cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
5059
51- // If a previous browser process died without cleanup, it may have left sessions "active".
52- // Use per-session lock files to distinguish truly running sessions from stale ones.
53- if lockDir != "" {
54- if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil {
55- log.Warn().Err(err).Msg("failed to mark stale sessions ended")
56- }
60+ now := time.Now()
61+ session := &entity.Session{
62+ ID: entity.SessionID(corelogging.GenerateSessionID()),
63+ Type: entity.SessionTypeBrowser,
64+ StartedAt: now.UTC(),
65+ }
66+ if err := session.Validate(); err != nil {
67+ return nil, ctx, err
5768 }
5869
59- now := time.Now()
60- out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{
61- Type: entity.SessionTypeBrowser,
62- Now: now,
63- LogConfig: port.SessionLogConfig{
64- Level: cfg.Logging.Level,
65- Format: cfg.Logging.Format,
66- TimeFormat: "15:04:05",
67- LogDir: cfg.Logging.LogDir,
68- WriteToStderr: true,
69- EnableFileLog: cfg.Logging.EnableFileLog,
70- },
70+ logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
71+ Level: cfg.Logging.Level,
72+ Format: cfg.Logging.Format,
73+ TimeFormat: corelogging.ConsoleTimeFormat,
74+ LogDir: cfg.Logging.LogDir,
75+ WriteToStderr: true,
76+ EnableFileLog: cfg.Logging.EnableFileLog,
7177 })
7278 if err != nil {
73- return nil, ctx, err
79+ log.Warn().Err(err).Msg("failed to create session logger")
7480 }
7581
76- lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID)
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- }
82+ sessionCtx := corelogging.WithContext(context.Background(), logger)
8183
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+ }
83126
84127 endFn := func(endCtx context.Context) error {
85- endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now())
86- if lockFile != nil {
87- _ = unlockAndClose(lockFile)
88- _ = os.Remove(lockPath)
128+ _ = persistFn(endCtx)
129+ endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
130+
131+ lockMu.Lock()
132+ lf := lockFile
133+ lp := lockPath
134+ lockMu.Unlock()
135+
136+ if lf != nil {
137+ _ = unlockAndClose(lf)
138+ _ = os.Remove(lp)
89139 }
90140 return endErr
91141 }
92142
93- return &BrowserSession{
94- Session: out.Session,
95- Logger: out.Logger,
96- LogCleanup: out.LogCleanup,
143+ browserSession := &BrowserSession{
144+ Session: session,
145+ Logger: logger,
146+ LogCleanup: cleanup,
97147 End: endFn,
98- }, sessionCtx, nil
148+ }
149+
150+ if deferPersist {
151+ browserSession.Persist = persistFn
152+ } else {
153+ if err := persistFn(ctx); err != nil {
154+ if cleanup != nil {
155+ cleanup()
156+ }
157+ return nil, ctx, err
158+ }
159+ }
160+
161+ return browserSession, sessionCtx, nil
99162 }
100163
101164 func endStaleActiveBrowserSessions(
@@ -199,3 +262,49 @@ func unlockAndClose(f *os.File) error {
199262 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
200263 return f.Close()
201264 }
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 (
1515 "github.com/rs/zerolog"
1616 )
1717
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+
1829 type WebKitStack struct {
1930 Context *webkit.WebKitContext
2031 Settings *webkit.SettingsManager
@@ -24,50 +35,64 @@ type WebKitStack struct {
2435 FilterManager *filtering.Manager
2536 }
2637
27-func BuildWebKitStack(
28- ctx context.Context,
29- cfg *config.Config,
30- dataDir string,
31- cacheDir string,
32- themeManager *theme.Manager,
33- logger zerolog.Logger,
34-) WebKitStack {
35- // CRITICAL: Configure rendering environment BEFORE WebKit/GTK initialization.
36- // Environment variables must be set before GTK/WebKit/GStreamer initializes.
37- renderEnv := env.NewManager()
38- gpuVendor := renderEnv.DetectGPUVendor(ctx)
39- renderSettings := port.RenderingEnvSettings{
40- // GStreamer settings
41- ForceVSync: cfg.Media.ForceVSync,
42- GLRenderingMode: string(cfg.Media.GLRenderingMode),
43- GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel,
44- VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB,
45- QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec,
38+func BuildWebKitStack(input WebKitStackInput) WebKitStack {
39+ ctx := input.Ctx
40+ cfg := input.Config
41+ dataDir := input.DataDir
42+ cacheDir := input.CacheDir
43+ themeManager := input.ThemeManager
44+ colorResolver := input.ColorResolver
45+ logger := input.Logger
46+ // Detect hardware for performance profile scaling
47+ hwSurveyor := env.NewHardwareSurveyor()
48+ hwInfo := hwSurveyor.Survey(ctx)
49+ logger.Info().
50+ Int("cpu_cores", hwInfo.CPUCores).
51+ Int("cpu_threads", hwInfo.CPUThreads).
52+ Uint64("ram_mb", hwInfo.TotalRAM/(1024*1024)).
53+ Str("gpu_vendor", string(hwInfo.GPUVendor)).
54+ Uint64("vram_mb", hwInfo.VRAM/(1024*1024)).
55+ Msg("hardware survey completed")
4656
47- // WebKit compositor settings
48- DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer,
49- ForceCompositingMode: cfg.Rendering.ForceCompositingMode,
50- DisableCompositingMode: cfg.Rendering.DisableCompositingMode,
57+ // Resolve performance profile to get actual settings
58+ perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo)
59+ logger.Info().
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")
5165
52- // GTK/GSK settings
53- GSKRenderer: string(cfg.Rendering.GSKRenderer),
54- DisableMipmaps: cfg.Rendering.DisableMipmaps,
55- PreferGL: cfg.Rendering.PreferGL,
66+ // Configure rendering environment (must happen before WebKit/GTK init)
67+ configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)
5668
57- // Debug settings
58- ShowFPS: cfg.Rendering.ShowFPS,
59- SampleMemory: cfg.Rendering.SampleMemory,
60- DebugFrames: cfg.Rendering.DebugFrames,
69+ // Build WebKitContext options with memory pressure settings
70+ wkOpts := port.WebKitContextOptions{
71+ DataDir: dataDir,
72+ CacheDir: cacheDir,
6173 }
62- if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil {
63- logger.Warn().Err(err).Msg("failed to apply rendering environment")
74+
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+ }
6493 }
65- logger.Info().
66- Str("gpu", string(gpuVendor)).
67- Interface("vars", renderEnv.GetAppliedVars()).
68- Msg("rendering environment configured")
6994
70- wkCtx, err := webkit.NewWebKitContext(ctx, dataDir, cacheDir)
95+ wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)
7196 if err != nil {
7297 logger.Fatal().Err(err).Msg("failed to initialize WebKit context")
7398 }
@@ -93,7 +118,7 @@ func BuildWebKitStack(
93118 schemeHandler.RegisterWithContext(wkCtx)
94119
95120 settings := webkit.NewSettingsManager(ctx, cfg)
96- injector := webkit.NewContentInjector(themeManager.PrefersDark())
121+ injector := webkit.NewContentInjector(colorResolver)
97122
98123 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
99124 themeCSSText := themeManager.GetWebUIThemeCSS()
@@ -103,12 +128,26 @@ func BuildWebKitStack(
103128
104129 messageRouter := webkit.NewMessageRouter(ctx)
105130 poolCfg := webkit.DefaultPoolConfig()
131+ // Override prewarm count from resolved profile settings
132+ if perfSettings.WebViewPoolPrewarmCount > 0 {
133+ poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount
134+ }
106135 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)
107139
108140 if filterManager != nil {
109141 pool.SetFilterApplier(filterManager)
110142 }
111143
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+
112151 return WebKitStack{
113152 Context: wkCtx,
114153 Settings: settings,
@@ -118,3 +157,66 @@ func BuildWebKitStack(
118157 FilterManager: filterManager,
119158 }
120159 }
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 {
3131 // Use cases
3232 SearchHistoryUC *usecase.SearchHistoryUseCase
3333 SessionUC *usecase.ManageSessionUseCase
34+ ListSessionsUC *usecase.ListSessionsUseCase
35+ RestoreUC *usecase.RestoreSessionUseCase
36+ DeleteSessionUC *usecase.DeleteSessionUseCase
3437
3538 // Services
3639 FaviconService *favicon.Service
@@ -105,9 +108,13 @@ func NewApp() (*App, error) {
105108
106109 // Create repositories
107110 historyRepo := sqlite.NewHistoryRepository(db)
111+ sessionStateRepo := sqlite.NewSessionStateRepository(db)
108112
109113 // Create use cases
110114 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)
111118
112119 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
113120 faviconCacheDir, _ := config.GetFaviconCacheDir()
@@ -120,6 +127,9 @@ func NewApp() (*App, error) {
120127 History: historyRepo,
121128 SearchHistoryUC: searchHistoryUC,
122129 SessionUC: sessionUC,
130+ ListSessionsUC: listSessionsUC,
131+ RestoreUC: restoreUC,
132+ DeleteSessionUC: deleteSessionUC,
123133 FaviconService: faviconService,
124134 ctx: ctx,
125135 logCleanup: logCleanup,
M internal/cli/cmd/about.go +40 -0
@@ -1,11 +1,16 @@
11 package cmd
22
33 import (
4+ "context"
45 "fmt"
56
67 "github.com/spf13/cobra"
78
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/cli"
811 "github.com/bnema/dumber/internal/cli/styles"
12+ "github.com/bnema/dumber/internal/infrastructure/updater"
13+ "github.com/bnema/dumber/internal/logging"
914 )
1015
1116 var aboutCmd = &cobra.Command{
@@ -29,5 +34,40 @@ func runAbout(_ *cobra.Command, _ []string) error {
2934 output := renderer.Render(app.BuildInfo)
3035
3136 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+
3272 return nil
3373 }
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 (
1111 tea "github.com/charmbracelet/bubbletea"
1212 "github.com/spf13/cobra"
1313
14+ "github.com/bnema/dumber/assets"
1415 "github.com/bnema/dumber/internal/cli/model"
16+ "github.com/bnema/dumber/internal/domain/entity"
1517 domainurl "github.com/bnema/dumber/internal/domain/url"
1618 "github.com/bnema/dumber/internal/infrastructure/config"
19+ "github.com/bnema/dumber/internal/infrastructure/favicon"
1720 "github.com/bnema/dumber/internal/logging"
1821 )
1922
2023 var (
2124 dmenuInteractive bool
2225 dmenuSelect bool
23- dmenuMax int
26+ dmenuDays int
27+ dmenuMostVisited bool
2428 )
2529
2630 var dmenuCmd = &cobra.Command{
@@ -41,7 +45,8 @@ func init() {
4145
4246 dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode")
4347 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")
4550 }
4651
4752 func runDmenu(_ *cobra.Command, _ []string) error {
@@ -69,9 +74,14 @@ func getDmenuConfig() config.DmenuConfig {
6974 app := GetApp()
7075 cfg := app.Config.Dmenu
7176
72- // Override max if explicitly set via CLI flag
73- if dmenuMax > 0 {
74- cfg.MaxHistoryItems = dmenuMax
77+ // Override days if explicitly set via CLI flag
78+ if dmenuDays > 0 {
79+ cfg.MaxHistoryDays = dmenuDays
80+ }
81+
82+ // Override sort_by_visit_count if --most-visited flag is set
83+ if dmenuMostVisited {
84+ cfg.SortByVisitCount = true
7585 }
7686
7787 return cfg
@@ -87,6 +97,13 @@ func getFaviconPath(rawURL string) string {
8797 return ""
8898 }
8999
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+
90107 domain := domainurl.ExtractDomain(rawURL)
91108 if domain == "" {
92109 return ""
@@ -94,7 +111,6 @@ func getFaviconPath(rawURL string) string {
94111
95112 ctx := app.Ctx()
96113 log := logging.FromContext(ctx)
97- const normalizedSize = 32
98114
99115 // Check if sized PNG already exists
100116 if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) {
@@ -123,7 +139,14 @@ func runDmenuPipe() error {
123139 ctx := context.Background()
124140 cfg := getDmenuConfig()
125141
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+ }
127150 if err != nil {
128151 return fmt.Errorf("get history: %w", err)
129152 }
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{
2424
2525 This can remove:
2626 - Config directory
27- - Data directory
27+ - Data directory (includes database)
2828 - State directory
2929 - Cache directory
3030 - Content filter caches
3131 - Desktop integration files
32+ - Inactive browser sessions
3233
3334 Use --force to remove everything without prompting.`,
3435 RunE: runPurge,
@@ -54,20 +55,48 @@ func runPurge(_ *cobra.Command, _ []string) error {
5455 return runPurgeForce(app, purgeUC)
5556 }
5657
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)
5865 p := tea.NewProgram(m, tea.WithAltScreen())
5966 _, err := p.Run()
6067 return err
6168 }
6269
6370 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)
6594 if out != nil {
6695 for _, r := range out.Results {
6796 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)
6998 } 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)
71100 }
72101 }
73102 }
M internal/cli/cmd/root.go +21 -4
@@ -16,11 +16,28 @@ var (
1616 buildInfo build.Info
1717 rootCmd = &cobra.Command{
1818 Use: "dumber",
19- Short: "A minimal web browser",
20- Long: `Dumber is a minimal, keyboard-driven web browser built with GTK4 and WebKitGTK.`,
19+ Short: "A fully unfeatured unbloated browser for tiling WMs",
20+ Long: `Dumber - a dumb browser that works like your favorite terminal multiplexer.
21+
22+A fully unfeatured unbloated browser for tiling WMs, built with GTK4 and WebKitGTK.
23+
24+Features:
25+ - Wayland native (Sway, Hyprland, River, Niri, etc.)
26+ - Tabs and workspaces with split or stacked panes
27+ - Keyboard-driven workflow inspired by Zellij
28+ - GPU rendering with automatic VA-API/VDPAU detection
29+ - Built-in ad blocking (UBlock-based network + cosmetic filtering)
30+ - Launcher integration (rofi/fuzzel) with favicons
31+ - Search shortcuts via bangs (!g, !gi, etc.)
32+ - Session management with auto-save and restore
33+
34+Use 'dumber browse' to launch the graphical browser, or explore the
35+subcommands for CLI-based operations like history search and session
36+management.`,
2137 PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
22- // Skip initialization for help commands
23- if cmd.Name() == "help" || cmd.Name() == "completion" {
38+ // Skip initialization for commands that don't need app context
39+ switch cmd.Name() {
40+ case "help", "completion", "gen-docs":
2441 return nil
2542 }
2643
A internal/cli/cmd/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 (
1414
1515 // PurgeModel wraps styles.PurgeModel for standalone CLI use.
1616 type PurgeModel struct {
17- selector styles.PurgeModel
18- purgeUC *usecase.PurgeDataUseCase
17+ selector styles.PurgeModel
18+ purgeUC *usecase.PurgeDataUseCase
19+ listSessionsUC *usecase.ListSessionsUseCase
20+ deleteSessionUC *usecase.DeleteSessionUseCase
1921
20- loading bool
21- purging bool
22- done bool
22+ loading bool
23+ loadingSessions bool
24+ purging bool
25+ done bool
2326
24- results *usecase.PurgeOutput
25- info string
26- err error
27+ // Track loading state for parallel loading
28+ targetsLoaded bool
29+ 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
2738
2839 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
2954 }
3055
3156 // NewPurgeModel creates a new purge command model.
32-func NewPurgeModel(theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase) PurgeModel {
33- return PurgeModel{theme: theme, purgeUC: purgeUC, loading: true}
57+func NewPurgeModel(ctx context.Context, theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase, cfg PurgeModelConfig) PurgeModel {
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
3468 }
3569
3670 type purgeTargetsLoadedMsg struct {
@@ -38,14 +72,25 @@ type purgeTargetsLoadedMsg struct {
3872 err error
3973 }
4074
75+type purgeSessionsLoadedMsg struct {
76+ sessions []entity.SessionPurgeItem
77+ size int64
78+ err error
79+}
80+
4181 type purgeCompleteMsg struct {
42- out *usecase.PurgeOutput
43- err error
82+ targetResults []entity.PurgeResult
83+ sessionResults []sessionPurgeResult
84+ err error
4485 }
4586
4687 // Init implements tea.Model.
4788 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...)
4994 }
5095
5196 func (m PurgeModel) loadTargets() tea.Cmd {
@@ -55,68 +100,172 @@ func (m PurgeModel) loadTargets() tea.Cmd {
55100 }
56101 }
57102
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+
58116 // Update implements tea.Model.
59117 func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
60118 switch msg := msg.(type) {
61119 case purgeTargetsLoadedMsg:
62- m.loading = false
63- if msg.err != nil {
64- m.err = msg.err
65- m.done = true
66- return m, nil
67- }
68-
69- m.selector = styles.NewPurge(m.theme, msg.targets)
70- return m, nil
71-
120+ return m.handleTargetsLoaded(msg), nil
121+ case purgeSessionsLoadedMsg:
122+ return m.handleSessionsLoaded(msg), nil
72123 case purgeCompleteMsg:
73- m.purging = false
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-
124+ return m.handlePurgeComplete(msg), nil
81125 case tea.KeyMsg:
82126 if m.done {
83127 return m, tea.Quit
84128 }
85- if m.loading || m.purging {
129+ if m.isBlocked() {
86130 return m, nil
87131 }
132+ // Fall through to updateSelector
88133 }
89134
90- if m.loading || m.purging {
135+ if m.isBlocked() {
91136 return m, nil
92137 }
93138
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) {
94192 selector, cmd := m.selector.Update(msg)
95193 m.selector = selector
96194
97- if m.selector.Done() {
98- if m.selector.Canceled {
99- return m, tea.Quit
100- }
101- if m.selector.Confirmed {
102- targetTypes := m.selector.SelectedTypes()
103- if len(targetTypes) == 0 {
104- m.done = true
105- m.info = "Nothing selected"
106- return m, nil
107- }
108- m.purging = true
109- return m, m.performPurge(targetTypes)
195+ if !m.selector.Done() {
196+ return m, cmd
197+ }
198+
199+ if m.selector.Canceled {
200+ return m, tea.Quit
201+ }
202+
203+ if m.selector.Confirmed {
204+ targetTypes := m.selector.SelectedTypes()
205+ sessionIDs := m.selector.SelectedSessionIDs()
206+ if len(targetTypes) == 0 && len(sessionIDs) == 0 {
207+ m.done = true
208+ m.info = "Nothing selected"
209+ return m, nil
110210 }
211+ m.purging = true
212+ return m, m.performPurge(targetTypes, sessionIDs)
111213 }
112214
113215 return m, cmd
114216 }
115217
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 {
117230 return func() tea.Msg {
118- out, err := m.purgeUC.Execute(context.Background(), usecase.PurgeInput{TargetTypes: targetTypes})
119- return purgeCompleteMsg{out: out, err: err}
231+ var targetResults []entity.PurgeResult
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+ }
120269 }
121270 }
122271
@@ -124,7 +273,7 @@ func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd {
124273 func (m PurgeModel) View() string {
125274 t := m.theme
126275
127- if m.loading {
276+ if m.loading || m.loadingSessions {
128277 spinner := styles.NewLoading(t, "Scanning purge targets...")
129278 return t.Box.Render(spinner.View())
130279 }
@@ -145,7 +294,7 @@ func (m PurgeModel) View() string {
145294 return t.Box.Render(content)
146295 }
147296
148- if m.err != nil && m.results == nil {
297+ if m.err != nil && m.results == nil && len(m.sessionResults) == 0 {
149298 content := lipgloss.JoinVertical(
150299 lipgloss.Left,
151300 t.ErrorStyle.Render("Error: "+m.err.Error()),
@@ -155,28 +304,52 @@ func (m PurgeModel) View() string {
155304 return t.Box.Render(content)
156305 }
157306
158- contentLines := []string{t.Title.Render("Purge complete")}
159- if m.results != nil {
160- for _, r := range m.results.Results {
161- if r.Success {
162- contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path))
163- } else {
164- contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error))
165- }
307+ return m.renderResults()
308+ }
309+
310+ return m.selector.View()
311+}
312+
313+func (m PurgeModel) renderResults() string {
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++
166329 }
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- )
174330 }
175- contentLines = append(contentLines, "", t.Subtle.Render("Press any key to exit"))
176- return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...))
177331 }
178332
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...))
180353 }
181354
182355 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 (
4040
4141 // UI
4242 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)
4361 )
M internal/cli/styles/purge.go +224 -34
@@ -17,13 +17,19 @@ type PurgeItem struct {
1717 Selected bool
1818 }
1919
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.
2122 type PurgeModel struct {
22- Items []PurgeItem
23- Cursor int
24- Confirmed bool
25- Canceled bool
26- theme *Theme
23+ Items []PurgeItem
24+ Sessions []entity.SessionPurgeItem
25+ SessionsSize int64 // Total size of all session snapshots
26+ Cursor int // Flat cursor index across all rows
27+ Confirmed bool
28+ Canceled bool
29+ theme *Theme
30+
31+ // allSessionsSelected indicates if all sessions should be purged
32+ allSessionsSelected bool
2733 }
2834
2935 // PurgeKeyMap defines keybindings for purge modal.
@@ -48,6 +54,34 @@ func DefaultPurgeKeyMap() PurgeKeyMap {
4854 }
4955 }
5056
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+
5185 // NewPurge creates a new purge modal with the given targets.
5286 func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {
5387 items := make([]PurgeItem, 0, len(targets))
@@ -60,6 +94,14 @@ func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {
6094 return m
6195 }
6296
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+
63105 // Init implements tea.Model.
64106 func (m PurgeModel) Init() tea.Cmd {
65107 _ = m
@@ -91,24 +133,63 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
91133 return m, nil
92134 }
93135
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+
94157 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 {
96172 return
97173 }
98174
99- idx := m.Cursor
100- for i := 0; i < len(m.Items); i++ {
101- idx = (idx + delta + len(m.Items)) % len(m.Items)
102- if m.Items[idx].Exists {
103- m.Cursor = idx
104- return
175+ // Find current position in selectable list
176+ currentPos := 0
177+ for i, idx := range selectableIndices {
178+ if idx == m.Cursor {
179+ currentPos = i
180+ break
105181 }
106182 }
183+
184+ // Move
185+ newPos := (currentPos + delta + len(selectableIndices)) % len(selectableIndices)
186+ m.Cursor = selectableIndices[newPos]
107187 }
108188
109189 func (m PurgeModel) firstSelectableIndex() int {
110- for i, it := range m.Items {
111- if it.Exists {
190+ rows := m.buildRows()
191+ for i, r := range rows {
192+ if r.Exists {
112193 return i
113194 }
114195 }
@@ -116,16 +197,26 @@ func (m PurgeModel) firstSelectableIndex() int {
116197 }
117198
118199 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) {
120202 return
121203 }
122- if !m.Items[m.Cursor].Exists {
204+
205+ r := rows[m.Cursor]
206+ if !r.Exists {
123207 return
124208 }
125- m.Items[m.Cursor].Selected = !m.Items[m.Cursor].Selected
209+
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+ }
126216 }
127217
128218 func (m *PurgeModel) toggleAll() {
219+ // Check if any selectable item is unselected
129220 anyUnselected := false
130221 for _, it := range m.Items {
131222 if it.Exists && !it.Selected {
@@ -133,13 +224,27 @@ func (m *PurgeModel) toggleAll() {
133224 break
134225 }
135226 }
227+ if m.hasSessionsItem() && m.SessionsEnabled() && !m.allSessionsSelected {
228+ anyUnselected = true
229+ }
136230
231+ // Set all to the opposite state
137232 for i := range m.Items {
138- if !m.Items[i].Exists {
139- continue
233+ if m.Items[i].Exists {
234+ m.Items[i].Selected = anyUnselected
140235 }
141- m.Items[i].Selected = anyUnselected
142236 }
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
143248 }
144249
145250 // View implements tea.Model.
@@ -149,12 +254,20 @@ func (m PurgeModel) View() string {
149254 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))
150255 subtitle := t.Subtle.Render("Select items to remove")
151256
152- var rows []string
153- for i, it := range m.Items {
154- rows = append(rows, m.renderRow(i, it))
257+ // Build rows and render
258+ rows := m.buildRows()
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+ }
155268 }
156269
157- list := lipgloss.JoinVertical(lipgloss.Left, rows...)
270+ itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...)
158271
159272 selectedCount := m.SelectedCount()
160273 size := m.SelectedSize()
@@ -172,28 +285,30 @@ func (m PurgeModel) View() string {
172285 summary = t.Subtle.Render("0 selected")
173286 }
174287
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)
176290
177- content := lipgloss.JoinVertical(
178- lipgloss.Left,
291+ contentParts := []string{
179292 header,
180293 subtitle,
181294 "",
182- list,
295+ itemsList,
183296 "",
184297 summary,
185298 "",
186299 help,
187- )
300+ }
301+
302+ content := lipgloss.JoinVertical(lipgloss.Left, contentParts...)
188303
189304 return t.Box.Render(content)
190305 }
191306
192-func (m PurgeModel) renderRow(i int, it PurgeItem) string {
307+func (m PurgeModel) renderItemRow(_ int, it PurgeItem, isCursor bool) string {
193308 t := m.theme
194309
195310 cursor := " "
196- if i == m.Cursor {
311+ if isCursor && it.Exists {
197312 cursor = IconCursor + " "
198313 }
199314
@@ -242,6 +357,51 @@ func (m PurgeModel) renderRow(i int, it PurgeItem) string {
242357 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)
243358 }
244359
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+
245405 func purgeLabel(t entity.PurgeTargetType) string {
246406 switch t {
247407 case entity.PurgeTargetConfig:
@@ -309,7 +469,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType {
309469 return out
310470 }
311471
312-// SelectedCount returns the number of selected, existing items.
472+// SelectedCount returns the number of selected items (targets + sessions as 1 if selected).
313473 func (m PurgeModel) SelectedCount() int {
314474 count := 0
315475 for _, it := range m.Items {
@@ -317,10 +477,15 @@ func (m PurgeModel) SelectedCount() int {
317477 count++
318478 }
319479 }
480+ // Count sessions as 1 item if selected (and enabled)
481+ if m.hasSessionsItem() && m.SessionsEnabled() && m.allSessionsSelected {
482+ count++
483+ }
320484 return count
321485 }
322486
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.
324489 func (m PurgeModel) SelectedSize() int64 {
325490 var total int64
326491 for _, it := range m.Items {
@@ -328,5 +493,30 @@ func (m PurgeModel) SelectedSize() int64 {
328493 total += it.Size
329494 }
330495 }
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+ }
331503 return total
332504 }
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 {
3030 Success bool
3131 Error error
3232 }
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 {
7979 tl.Tabs[j].Position = j
8080 }
8181 // Update active tab if needed
82- if tl.ActiveTabID == id && len(tl.Tabs) > 0 {
83- if i < len(tl.Tabs) {
84- tl.ActiveTabID = tl.Tabs[i].ID
82+ if tl.ActiveTabID == id {
83+ if len(tl.Tabs) > 0 {
84+ if i < len(tl.Tabs) {
85+ tl.ActiveTabID = tl.Tabs[i].ID
86+ } else {
87+ tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID
88+ }
8589 } else {
86- tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID
90+ tl.ActiveTabID = ""
91+ tl.PreviousActiveTabID = ""
8792 }
8893 }
8994 return true
@@ -155,3 +160,17 @@ func (tl *TabList) Move(id TabID, newPos int) bool {
155160 }
156161 return true
157162 }
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 {
2121 // GetRecent retrieves recent history entries with pagination.
2222 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
2323
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+
2438 // IncrementVisitCount increments the visit count for a URL.
2539 IncrementVisitCount(ctx context.Context, url string) error
2640
M internal/domain/repository/mocks/mocks.go +827 -47
@@ -2197,6 +2197,130 @@ func (_c *MockHistoryRepository_FindByURL_Call) RunAndReturn(run func(ctx contex
21972197 return _c
21982198 }
21992199
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+
22002324 // GetDailyVisitCount provides a mock function for the type MockHistoryRepository
22012325 func (_mock *MockHistoryRepository) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) {
22022326 ret := _mock.Called(ctx, daysAgo)
@@ -2395,6 +2519,74 @@ func (_c *MockHistoryRepository_GetHourlyDistribution_Call) RunAndReturn(run fun
23952519 return _c
23962520 }
23972521
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+
23982590 // GetRecent provides a mock function for the type MockHistoryRepository
23992591 func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) {
24002592 ret := _mock.Called(ctx, limit, offset)
@@ -2469,6 +2661,74 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex
24692661 return _c
24702662 }
24712663
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+
24722732 // GetStats provides a mock function for the type MockHistoryRepository
24732733 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
24742734 ret := _mock.Called(ctx)
@@ -2746,47 +3006,36 @@ func (_m *MockSessionRepository) EXPECT() *MockSessionRepository_Expecter {
27463006 return &MockSessionRepository_Expecter{mock: &_m.Mock}
27473007 }
27483008
2749-// FindByID provides a mock function for the type MockSessionRepository
2750-func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
3009+// Delete provides a mock function for the type MockSessionRepository
3010+func (_mock *MockSessionRepository) Delete(ctx context.Context, id entity.SessionID) error {
27513011 ret := _mock.Called(ctx, id)
27523012
27533013 if len(ret) == 0 {
2754- panic("no return value specified for FindByID")
3014+ panic("no return value specified for Delete")
27553015 }
27563016
2757- var r0 *entity.Session
2758- var r1 error
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 {
3017+ var r0 error
3018+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) error); ok {
27633019 r0 = returnFunc(ctx, id)
27643020 } else {
2765- if ret.Get(0) != nil {
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)
3021+ r0 = ret.Error(0)
27733022 }
2774- return r0, r1
3023+ return r0
27753024 }
27763025
2777-// MockSessionRepository_FindByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByID'
2778-type MockSessionRepository_FindByID_Call struct {
3026+// MockSessionRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
3027+type MockSessionRepository_Delete_Call struct {
27793028 *mock.Call
27803029 }
27813030
2782-// FindByID is a helper method to define mock.On call
3031+// Delete is a helper method to define mock.On call
27833032 // - ctx context.Context
27843033 // - id entity.SessionID
2785-func (_e *MockSessionRepository_Expecter) FindByID(ctx interface{}, id interface{}) *MockSessionRepository_FindByID_Call {
2786- return &MockSessionRepository_FindByID_Call{Call: _e.mock.On("FindByID", ctx, id)}
3034+func (_e *MockSessionRepository_Expecter) Delete(ctx interface{}, id interface{}) *MockSessionRepository_Delete_Call {
3035+ return &MockSessionRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, id)}
27873036 }
27883037
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 {
27903039 _c.Call.Run(func(args mock.Arguments) {
27913040 var arg0 context.Context
27923041 if args[0] != nil {
@@ -2804,56 +3053,256 @@ func (_c *MockSessionRepository_FindByID_Call) Run(run func(ctx context.Context,
28043053 return _c
28053054 }
28063055
2807-func (_c *MockSessionRepository_FindByID_Call) Return(session *entity.Session, err error) *MockSessionRepository_FindByID_Call {
2808- _c.Call.Return(session, err)
3056+func (_c *MockSessionRepository_Delete_Call) Return(err error) *MockSessionRepository_Delete_Call {
3057+ _c.Call.Return(err)
28093058 return _c
28103059 }
28113060
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 {
28133062 _c.Call.Return(run)
28143063 return _c
28153064 }
28163065
2817-// GetActive provides a mock function for the type MockSessionRepository
2818-func (_mock *MockSessionRepository) GetActive(ctx context.Context) (*entity.Session, error) {
2819- ret := _mock.Called(ctx)
3066+// DeleteExitedBefore provides a mock function for the type MockSessionRepository
3067+func (_mock *MockSessionRepository) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) {
3068+ ret := _mock.Called(ctx, cutoff)
28203069
28213070 if len(ret) == 0 {
2822- panic("no return value specified for GetActive")
3071+ panic("no return value specified for DeleteExitedBefore")
28233072 }
28243073
2825- var r0 *entity.Session
3074+ var r0 int64
28263075 var r1 error
2827- if returnFunc, ok := ret.Get(0).(func(context.Context) (*entity.Session, error)); ok {
2828- return returnFunc(ctx)
3076+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (int64, error)); ok {
3077+ return returnFunc(ctx, cutoff)
28293078 }
2830- if returnFunc, ok := ret.Get(0).(func(context.Context) *entity.Session); ok {
2831- r0 = returnFunc(ctx)
3079+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) int64); ok {
3080+ r0 = returnFunc(ctx, cutoff)
28323081 } else {
2833- if ret.Get(0) != nil {
2834- r0 = ret.Get(0).(*entity.Session)
2835- }
3082+ r0 = ret.Get(0).(int64)
28363083 }
2837- if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2838- r1 = returnFunc(ctx)
3084+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok {
3085+ r1 = returnFunc(ctx, cutoff)
28393086 } else {
28403087 r1 = ret.Error(1)
28413088 }
28423089 return r0, r1
28433090 }
28443091
2845-// MockSessionRepository_GetActive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActive'
2846-type MockSessionRepository_GetActive_Call struct {
3092+// MockSessionRepository_DeleteExitedBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteExitedBefore'
3093+type MockSessionRepository_DeleteExitedBefore_Call struct {
28473094 *mock.Call
28483095 }
28493096
2850-// GetActive is a helper method to define mock.On call
3097+// DeleteExitedBefore is a helper method to define mock.On call
28513098 // - ctx context.Context
2852-func (_e *MockSessionRepository_Expecter) GetActive(ctx interface{}) *MockSessionRepository_GetActive_Call {
2853- return &MockSessionRepository_GetActive_Call{Call: _e.mock.On("GetActive", ctx)}
3099+// - cutoff time.Time
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)}
28543102 }
28553103
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 {
28573306 _c.Call.Run(func(args mock.Arguments) {
28583307 var arg0 context.Context
28593308 if args[0] != nil {
@@ -3064,6 +3513,337 @@ func (_c *MockSessionRepository_Save_Call) RunAndReturn(run func(ctx context.Con
30643513 return _c
30653514 }
30663515
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+
30673847 // 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.
30683848 // The first argument is typically a *testing.T value.
30693849 func NewMockZoomRepository(t interface {
M internal/domain/repository/session.go +11 -0
@@ -16,4 +16,15 @@ type SessionRepository interface {
1616 GetActive(ctx context.Context) (*entity.Session, error)
1717 GetRecent(ctx context.Context, limit int) ([]*entity.Session, error)
1818 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)
1930 }
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
44 import (
55 "fmt"
66 "net/url"
7+ "os"
8+ "path/filepath"
79 "strings"
810 )
911
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+
1039 // Normalize adds https:// prefix if missing for URL-like inputs.
1140 // 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.
1242 func Normalize(input string) string {
1343 if input == "" {
1444 return ""
@@ -28,6 +58,29 @@ func Normalize(input string) string {
2858 return input
2959 }
3060
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+
3184 // Looks like a URL (contains . and no spaces)
3285 if strings.Contains(input, ".") && !strings.Contains(input, " ") {
3386 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 (
77 defaultRetentionDays = 365 // 1 year
88
99 // Dmenu defaults
10- defaultMaxHistoryItems = 20 // items
10+ defaultMaxHistoryDays = 30 // days
1111
1212 // Logging defaults
1313 defaultMaxLogAgeDays = 7 // days
@@ -15,33 +15,60 @@ const (
1515 // Appearance defaults
1616 defaultFontSize = 16 // points
1717
18+ // Workspace defaults
19+ defaultNewPaneURL = "about:blank"
20+
1821 // Omnibox defaults
19- defaultOmniboxInitialBehavior = "recent"
22+ defaultOmniboxInitialBehavior = "recent"
23+ defaultOmniboxAutoOpenOnNewPane = false
2024
2125 // Workspace defaults
22- defaultPaneActivationShortcut = "ctrl+p"
23- defaultPaneTimeoutMilliseconds = 3000
24- defaultTabActivationShortcut = "ctrl+t"
25- defaultTabTimeoutMilliseconds = 3000
26- defaultTabBarPosition = "bottom"
27- defaultPopupPlacement = "right"
26+ defaultPaneActivationShortcut = "ctrl+p"
27+ defaultPaneTimeoutMilliseconds = 3000
28+ defaultTabActivationShortcut = "ctrl+t"
29+ defaultTabTimeoutMilliseconds = 3000
30+ defaultResizeActivationShortcut = "ctrl+n"
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
2843
2944 // Workspace styling defaults
3045 // Active pane border (overlay)
3146 defaultBorderWidth = 1
3247 defaultBorderColor = "@theme_selected_bg_color"
3348
34- // Pane mode border (Ctrl+P N - overlay)
35- defaultPaneModeBorderWidth = 4
36- defaultPaneModeBorderColor = "#4A90E2" // Blue for pane mode indicator
49+ // Mode border width (consolidated for all modes)
50+ defaultModeBorderWidth = 4
3751
38- // Tab mode border (Ctrl+P T - overlay)
39- defaultTabModeBorderWidth = 4
40- defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator
52+ // Mode colors (used for both borders and toaster)
53+ defaultPaneModeColor = "#4A90E2" // Blue for pane mode
54+ defaultTabModeColor = "#FFA500" // Orange for tab mode
55+ defaultSessionModeColor = "#9B59B6" // Purple for session mode
56+ defaultResizeModeColor = "#00D4AA" // Cyan/teal for resize mode
57+
58+ // Mode indicator toaster
59+ defaultModeIndicatorToasterEnabled = true
4160
4261 // Other styling
4362 defaultTransitionDuration = 120
4463 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
4572 )
4673
4774 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -67,7 +94,7 @@ func DefaultConfig() *Config {
6794 SearchShortcuts: GetDefaultSearchShortcuts(),
6895 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
6996 Dmenu: DmenuConfig{
70- MaxHistoryItems: defaultMaxHistoryItems,
97+ MaxHistoryDays: defaultMaxHistoryDays,
7198 ShowVisitCount: true,
7299 ShowLastVisited: true,
73100 HistoryPrefix: "🕒",
@@ -117,7 +144,7 @@ func DefaultConfig() *Config {
117144 DisableDMABufRenderer: false,
118145 ForceCompositingMode: false,
119146 DisableCompositingMode: false,
120- GSKRenderer: GSKRendererVulkan,
147+ GSKRenderer: GSKRendererAuto, // Let GTK choose - Vulkan can conflict with WebKit's DMA-BUF
121148 DisableMipmaps: false,
122149 PreferGL: false,
123150 DrawCompositingIndicators: false,
@@ -128,16 +155,26 @@ func DefaultConfig() *Config {
128155 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
129156 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
130157 Workspace: WorkspaceConfig{
158+ NewPaneURL: defaultNewPaneURL,
159+ SwitchToTabOnMove: true,
131160 PaneMode: PaneModeConfig{
132161 ActivationShortcut: defaultPaneActivationShortcut,
133162 TimeoutMilliseconds: defaultPaneTimeoutMilliseconds,
134163 Actions: map[string][]string{
135- "split-right": {"arrowright", "r"},
136- "split-left": {"arrowleft", "l"},
137- "split-up": {"arrowup", "u"},
138- "split-down": {"arrowdown", "d"},
139- "stack-pane": {"s"},
140- "close-pane": {"x"},
164+ "split-right": {"arrowright", "r"},
165+ "split-left": {"arrowleft", "l"},
166+ "split-up": {"arrowup", "u"},
167+ "split-down": {"arrowdown", "d"},
168+ "stack-pane": {"s"},
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+
141178 "focus-right": {"shift+arrowright", "shift+l"},
142179 "focus-left": {"shift+arrowleft", "shift+h"},
143180 "focus-up": {"shift+arrowup", "shift+k"},
@@ -159,10 +196,35 @@ func DefaultConfig() *Config {
159196 "cancel": {"escape"},
160197 },
161198 },
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+ },
162219 Shortcuts: GlobalShortcutsConfig{
163220 ClosePane: "ctrl+w",
164221 NextTab: "ctrl+tab",
165222 PreviousTab: "ctrl+shift+tab",
223+
224+ ConsumeOrExpelLeft: "alt+bracketleft",
225+ ConsumeOrExpelRight: "alt+bracketright",
226+ ConsumeOrExpelUp: "alt+shift+bracketleft",
227+ ConsumeOrExpelDown: "alt+shift+bracketright",
166228 },
167229 TabBarPosition: defaultTabBarPosition,
168230 HideTabBarWhenSingleTab: true,
@@ -176,13 +238,15 @@ func DefaultConfig() *Config {
176238 OAuthAutoClose: true, // Auto-close OAuth popups on success
177239 },
178240 Styling: WorkspaceStylingConfig{
179- BorderWidth: defaultBorderWidth,
180- BorderColor: defaultBorderColor,
181- PaneModeBorderWidth: defaultPaneModeBorderWidth,
182- PaneModeBorderColor: defaultPaneModeBorderColor,
183- TabModeBorderWidth: defaultTabModeBorderWidth,
184- TabModeBorderColor: defaultTabModeBorderColor,
185- TransitionDuration: defaultTransitionDuration,
241+ BorderWidth: defaultBorderWidth,
242+ BorderColor: defaultBorderColor,
243+ ModeBorderWidth: defaultModeBorderWidth,
244+ PaneModeColor: defaultPaneModeColor,
245+ TabModeColor: defaultTabModeColor,
246+ SessionModeColor: defaultSessionModeColor,
247+ ResizeModeColor: defaultResizeModeColor,
248+ ModeIndicatorToasterEnabled: defaultModeIndicatorToasterEnabled,
249+ TransitionDuration: defaultTransitionDuration,
186250 },
187251 },
188252 ContentFiltering: ContentFilteringConfig{
@@ -190,21 +254,62 @@ func DefaultConfig() *Config {
190254 AutoUpdate: true, // Auto-update filters from GitHub releases
191255 },
192256 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+ },
194274 },
195275 Media: MediaConfig{
196276 HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback
197- PreferAV1: true, // AV1 is most efficient codec
198- ShowDiagnosticsOnStartup: true, // Warn users if HW accel unavailable
277+ PreferAV1: false, // Don't force codec preference, let site choose
278+ ShowDiagnosticsOnStartup: false, // Disabled - diagnostics can be noisy
199279 ForceVSync: false, // Let compositor handle VSync
200280 GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API
201281 GStreamerDebugLevel: 0, // Disabled by default
202- VideoBufferSizeMB: 64, // Larger buffer for bursty streams (Twitch, YouTube)
203- QueueBufferTimeSec: 20, // More prebuffering for smooth playback
282+ VideoBufferSizeMB: 0, // Not a valid GStreamer env var, removed
283+ QueueBufferTimeSec: 0, // Not a valid GStreamer env var, removed
204284 },
205285 Runtime: RuntimeConfig{
206286 Prefix: "",
207287 },
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+ },
208313 }
209314 }
210315
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) {
212212 }
213213
214214 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+ }
215229 }
216230
217231 // Get returns the current configuration (thread-safe).
@@ -252,6 +266,9 @@ func (m *Manager) Save(cfg *Config) error {
252266 m.viper.Set("default_ui_scale", cfg.DefaultUIScale)
253267 m.viper.Set("default_search_engine", cfg.DefaultSearchEngine)
254268
269+ // Performance profile (requires browser restart to take effect)
270+ m.viper.Set("performance.profile", string(cfg.Performance.Profile))
271+
255272 if err := m.viper.WriteConfig(); err != nil {
256273 return fmt.Errorf("failed to write config: %w", err)
257274 }
@@ -314,6 +331,9 @@ func (m *Manager) setDefaults() {
314331 m.setOmniboxDefaults(defaults)
315332 m.setMediaDefaults(defaults)
316333 m.setRuntimeDefaults(defaults)
334+ m.setSessionDefaults(defaults)
335+ m.setUpdateDefaults(defaults)
336+ m.setPerformanceDefaults(defaults)
317337 }
318338
319339 func (m *Manager) setHistoryDefaults(defaults *Config) {
@@ -328,7 +348,7 @@ func (m *Manager) setSearchDefaults(defaults *Config) {
328348 }
329349
330350 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)
332352 m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
333353 m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
334354 m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
@@ -378,17 +398,28 @@ func (m *Manager) setRenderingDefaults(defaults *Config) {
378398 }
379399
380400 func (m *Manager) setWorkspaceDefaults(defaults *Config) {
401+ m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)
381402 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
382403 m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds)
383404 m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions)
384405 m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut)
385406 m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds)
386407 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)
387413 m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane)
388414 m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab)
389415 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)
390420 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
391421 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)
392423 m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
393424 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
394425 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
@@ -398,10 +429,12 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
398429 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
399430 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
400431 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)
402- m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
403- m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)
404- m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)
432+ m.viper.SetDefault("workspace.styling.mode_border_width", defaults.Workspace.Styling.ModeBorderWidth)
433+ m.viper.SetDefault("workspace.styling.pane_mode_color", defaults.Workspace.Styling.PaneModeColor)
434+ m.viper.SetDefault("workspace.styling.tab_mode_color", defaults.Workspace.Styling.TabModeColor)
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)
405438 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
406439 }
407440
@@ -412,6 +445,7 @@ func (m *Manager) setContentFilteringDefaults(defaults *Config) {
412445
413446 func (m *Manager) setOmniboxDefaults(defaults *Config) {
414447 m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior)
448+ m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane)
415449 }
416450
417451 func (m *Manager) setMediaDefaults(defaults *Config) {
@@ -429,6 +463,43 @@ func (m *Manager) setRuntimeDefaults(defaults *Config) {
429463 m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix)
430464 }
431465
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+
432503 // New returns a new default configuration instance.
433504 // This is a convenience function for getting default config without the full manager.
434505 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 {
1717 DefaultUIScale float64 `mapstructure:"default_ui_scale" yaml:"default_ui_scale" toml:"default_ui_scale"`
1818 // Workspace defines workspace, pane, and tab handling behavior.
1919 Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"`
20+ // Session controls session persistence and restoration.
21+ Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"`
2022 // ContentFiltering controls ad blocking and content filtering
2123 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`
2224 // Omnibox controls the omnibox behavior (initial history display)
@@ -27,6 +29,10 @@ type Config struct {
2729 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`
2830 // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).
2931 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"`
3036 }
3137
3238 // RenderingMode selects GPU vs CPU rendering.
@@ -119,6 +125,20 @@ const (
119125 GLRenderingModeNone GLRenderingMode = "none"
120126 )
121127
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+
122142 // ThemeDefault is the default theme setting (follows system).
123143 const ThemeDefault = "default"
124144
@@ -172,6 +192,74 @@ type HistoryConfig struct {
172192 CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days" toml:"cleanup_interval_days"`
173193 }
174194
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+
175263 // SearchShortcut represents a search shortcut configuration.
176264 type SearchShortcut struct {
177265 URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"`
@@ -190,7 +278,7 @@ func (c *Config) ShortcutURLs() map[string]string {
190278
191279 // DmenuConfig holds dmenu/rofi integration configuration.
192280 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"`
194282 ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"`
195283 ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"`
196284 HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"`
@@ -254,6 +342,9 @@ type OmniboxConfig struct {
254342 // InitialBehavior controls what to show when omnibox opens with empty input
255343 // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing)
256344 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"`
257348 }
258349
259350 // DebugConfig holds debug and troubleshooting options
@@ -262,18 +353,40 @@ type DebugConfig struct {
262353 EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"`
263354 }
264355
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+
265370 // WorkspaceConfig captures layout, pane, and tab behavior preferences.
266371 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"`
267375 // PaneMode defines modal pane behavior and bindings.
268376 PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
269377 // TabMode defines modal tab behavior and bindings (Alt+T).
270378 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"`
271381 // Shortcuts holds global (non-modal) keyboard shortcuts.
272382 Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
273383 // TabBarPosition determines tab bar placement: "top" or "bottom".
274384 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
275385 // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
276386 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
277390 // Popups configures default popup placement rules.
278391 Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
279392 // Styling configures workspace visual appearance.
@@ -318,11 +431,37 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string {
318431 return keyToAction
319432 }
320433
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+
321455 // GlobalShortcutsConfig defines global shortcuts (always active, not modal).
322456 type GlobalShortcutsConfig struct {
323457 ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"`
324458 NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"`
325459 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
326465 }
327466
328467 // PopupBehavior defines how popup windows should be opened
@@ -373,18 +512,64 @@ type WorkspaceStylingConfig struct {
373512 // BorderColor for focused panes (CSS color value or theme variable)
374513 BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
375514
376- // PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay)
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 line
378- // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)
379- // Defaults to "#4A90E2" (blue) if not set
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 line
515+ // ModeBorderWidth in pixels for all mode indicator borders (pane, tab, session, resize)
516+ 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
517+
518+ // PaneModeColor for pane mode indicator (border and toaster). Defaults to "#4A90E2" (blue)
519+ 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
381526
382- // TabModeBorderWidth in pixels for tab mode indicator border (Ctrl+P T overlay)
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 line
384- // TabModeBorderColor for the tab mode indicator border (CSS color value or theme variable)
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
527+ // ModeIndicatorToasterEnabled shows a toaster notification when modal modes are active.
528+ // Default: true
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
387530
388531 // TransitionDuration in milliseconds for border animations
389532 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
390533 }
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
22
33 import (
44 "fmt"
5+ "net/url"
56 "strings"
67
8+ domainurl "github.com/bnema/dumber/internal/domain/url"
79 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
810 )
911
@@ -21,9 +23,12 @@ func validateConfig(config *Config) error {
2123 validationErrors = append(validationErrors, validateTabBar(config)...)
2224 validationErrors = append(validationErrors, validateTabMode(config)...)
2325 validationErrors = append(validationErrors, validateLogging(config)...)
26+ validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
2427 validationErrors = append(validationErrors, validateOmnibox(config)...)
2528 validationErrors = append(validationErrors, validateRendering(config)...)
2629 validationErrors = append(validationErrors, validateColorScheme(config)...)
30+ validationErrors = append(validationErrors, validateSession(config)...)
31+ validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
2732
2833 // If there are validation errors, return them
2934 if len(validationErrors) > 0 {
@@ -48,8 +53,8 @@ func validateHistory(config *Config) []string {
4853 }
4954
5055 func validateDmenu(config *Config) []string {
51- if config.Dmenu.MaxHistoryItems < 0 {
52- return []string{"dmenu.max_history_items must be non-negative"}
56+ if config.Dmenu.MaxHistoryDays < 0 {
57+ return []string{"dmenu.max_history_days must be non-negative"}
5358 }
5459 return nil
5560 }
@@ -142,11 +147,8 @@ func validateWorkspaceStyling(config *Config) []string {
142147 if config.Workspace.Styling.BorderWidth < 0 {
143148 validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
144149 }
145- if config.Workspace.Styling.PaneModeBorderWidth < 0 {
146- validationErrors = append(validationErrors, "workspace.styling.pane_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+ if config.Workspace.Styling.ModeBorderWidth < 0 {
151+ validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative")
150152 }
151153 if config.Workspace.Styling.TransitionDuration < 0 {
152154 validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
@@ -245,6 +247,37 @@ func validateLogging(config *Config) []string {
245247 return validationErrors
246248 }
247249
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+
248281 func validateOmnibox(config *Config) []string {
249282 switch config.Omnibox.InitialBehavior {
250283 case "recent", "most_visited", "none":
@@ -301,3 +334,61 @@ func validateColorScheme(config *Config) []string {
301334 )}
302335 }
303336 }
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
@@ -48,6 +48,9 @@ type MessageRouter struct {
4848 mu sync.RWMutex
4949 callbacks []interface{}
5050 signals []uint32
51+
52+ idMu sync.Mutex
53+ syncedIDs map[WebViewID]bool
5154 }
5255
5356 // NewMessageRouter creates a new message router.
@@ -59,7 +62,19 @@ func NewMessageRouter(ctx context.Context) *MessageRouter {
5962 return &MessageRouter{
6063 handlers: make(map[string]handlerEntry),
6164 baseCtx: ctx,
65+ syncedIDs: make(map[WebViewID]bool),
66+ }
67+}
68+
69+// SetBaseContext updates the base context used for logging and handler execution.
70+func (r *MessageRouter) SetBaseContext(ctx context.Context) {
71+ if r == nil {
72+ return
73+ }
74+ if ctx == nil {
75+ ctx = context.Background()
6276 }
77+ r.baseCtx = ctx
6378 }
6479
6580 // RegisterHandler registers a handler for a message type.
@@ -120,9 +135,9 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
120135
121136 // Connect to signal BEFORE registering handler to avoid race conditions
122137 // (as recommended by WebKit documentation)
123- cb := func(_ webkit.UserContentManager, valuePtr uintptr) {
138+ cb := func(ucm webkit.UserContentManager, valuePtr uintptr) {
124139 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")
125- r.handleScriptMessage(valuePtr)
140+ r.handleScriptMessage(ucm, valuePtr)
126141 }
127142
128143 r.mu.Lock()
@@ -156,7 +171,7 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
156171 }
157172
158173 // handleScriptMessage decodes the JSC value and routes it to the correct handler.
159-func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
174+func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) {
160175 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
161176
162177 if valuePtr == 0 {
@@ -184,6 +199,12 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
184199 if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
185200 msg.WebViewID = msg.WebViewIDAlt
186201 }
202+ if msg.WebViewID == 0 {
203+ if senderWV := lookupSenderWebView(senderUCM); senderWV != nil {
204+ msg.WebViewID = uint64(senderWV.ID())
205+ r.syncWebViewID(senderWV)
206+ }
207+ }
187208
188209 if msg.Type == "" {
189210 log.Warn().Msg("script message missing type")
@@ -223,6 +244,35 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
223244 }
224245 }
225246
247+func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
248+ ptr := senderUCM.GoPointer()
249+ if ptr == 0 {
250+ return nil
251+ }
252+ return LookupWebViewByUCMPointer(ptr)
253+}
254+
255+func (r *MessageRouter) syncWebViewID(wv *WebView) {
256+ if wv == nil {
257+ return
258+ }
259+ if !r.markWebViewIDSynced(wv.ID()) {
260+ return
261+ }
262+ script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID()))
263+ wv.RunJavaScript(r.baseCtx, script, "")
264+}
265+
266+func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool {
267+ r.idMu.Lock()
268+ defer r.idMu.Unlock()
269+ if r.syncedIDs[id] {
270+ return false
271+ }
272+ r.syncedIDs[id] = true
273+ return true
274+}
275+
226276 // getHandler retrieves a handler by message type.
227277 func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
228278 r.mu.RLock()
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(
7676 Int("stack_size_before", stackedView.Count()).
7777 Msg("StackedPaneManager: inserting pane after active position")
7878
79- stackedView.InsertPaneAfter(ctx, currentActiveIndex, title, "", widget)
79+ stackedView.InsertPaneAfter(ctx, currentActiveIndex, string(newPaneView.PaneID()), title, "", widget)
8080
8181 // Register the new pane in the TreeRenderer's tracking
8282 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