Merge pull request #19 from bnema/dev
Fix navigation perf, Wayland dialogs, idle inhibition, and stacked pane updates
227 files · +35406 -1500
M .github/workflows/ci.yml +8 -3
| @@ -2,7 +2,7 @@ name: CI | |||
| 2 | 2 | ||
| 3 | 3 | on: | |
| 4 | 4 | push: | |
| 5 | - | branches: [dev, main] | |
| 5 | + | branches: [main] | |
| 6 | 6 | paths: | |
| 7 | 7 | - '**.go' | |
| 8 | 8 | - 'go.mod' | |
| @@ -65,7 +65,9 @@ jobs: | |||
| 65 | 65 | run: | | |
| 66 | 66 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin v2.6.2 | |
| 67 | 67 | - 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 | |
| 69 | 71 | ||
| 70 | 72 | test: | |
| 71 | 73 | runs-on: ubuntu-latest | |
| @@ -75,7 +77,7 @@ jobs: | |||
| 75 | 77 | - name: Install dependencies | |
| 76 | 78 | run: | | |
| 77 | 79 | 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 | |
| 79 | 81 | - uses: actions/checkout@v6 | |
| 80 | 82 | - name: Cache Go modules | |
| 81 | 83 | uses: actions/cache@v4 | |
| @@ -101,4 +103,7 @@ jobs: | |||
| 101 | 103 | - name: Build frontend assets | |
| 102 | 104 | run: make build-frontend | |
| 103 | 105 | - name: Run tests | |
| 106 | + | env: | |
| 107 | + | GDK_BACKEND: wayland | |
| 108 | + | WAYLAND_DISPLAY: wayland-0 | |
| 104 | 109 | run: go test -v ./... | |
M .goreleaser.yaml +12 -5
| @@ -33,14 +33,14 @@ archives: | |||
| 33 | 33 | - id: default | |
| 34 | 34 | name_template: >- | |
| 35 | 35 | {{ .ProjectName }}_ | |
| 36 | - | {{- .Version }}_ | |
| 37 | 36 | {{- .Os }}_ | |
| 38 | 37 | {{- if eq .Arch "amd64" }}x86_64 | |
| 39 | 38 | {{- else if eq .Arch "386" }}i386 | |
| 40 | 39 | {{- else }}{{ .Arch }}{{ end }} | |
| 40 | + | wrap_in_directory: "{{ .ProjectName }}_{{ .Version }}" | |
| 41 | 41 | format_overrides: | |
| 42 | 42 | - goos: linux | |
| 43 | - | format: tar.gz | |
| 43 | + | formats: ['tar.gz'] | |
| 44 | 44 | files: | |
| 45 | 45 | - LICENSE | |
| 46 | 46 | - README.md | |
| @@ -109,9 +109,16 @@ release: | |||
| 109 | 109 | ||
| 110 | 110 | ### Linux (x86_64/amd64) | |
| 111 | 111 | ```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" | |
| 115 | 122 | ``` | |
| 116 | 123 | ||
| 117 | 124 | ### Dependencies | |
M .mockery.yaml +13 -0
| @@ -8,10 +8,21 @@ packages: | |||
| 8 | 8 | filename: mocks.go | |
| 9 | 9 | pkgname: mocks | |
| 10 | 10 | interfaces: | |
| 11 | + | ConfigMigrator: {} | |
| 12 | + | ConfigSchemaProvider: {} | |
| 13 | + | DatabaseProvider: {} | |
| 14 | + | DiffFormatter: {} | |
| 11 | 15 | SessionLogger: {} | |
| 16 | + | SessionSpawner: {} | |
| 17 | + | TabListProvider: {} | |
| 12 | 18 | WebView: {} | |
| 13 | 19 | WebViewFactory: {} | |
| 14 | 20 | WebViewPool: {} | |
| 21 | + | MemoryPressureApplier: {} | |
| 22 | + | WebKitContextProvider: {} | |
| 23 | + | HardwareSurveyor: {} | |
| 24 | + | ColorSchemeDetector: {} | |
| 25 | + | ColorSchemeResolver: {} | |
| 15 | 26 | github.com/bnema/dumber/internal/domain/repository: | |
| 16 | 27 | config: | |
| 17 | 28 | dir: '{{.InterfaceDir}}/mocks' | |
| @@ -23,6 +34,7 @@ packages: | |||
| 23 | 34 | FolderRepository: {} | |
| 24 | 35 | HistoryRepository: {} | |
| 25 | 36 | SessionRepository: {} | |
| 37 | + | SessionStateRepository: {} | |
| 26 | 38 | TagRepository: {} | |
| 27 | 39 | ZoomRepository: {} | |
| 28 | 40 | github.com/bnema/dumber/internal/infrastructure/filtering: | |
| @@ -48,5 +60,6 @@ packages: | |||
| 48 | 60 | PaneViewFactory: {} | |
| 49 | 61 | PanedWidget: {} | |
| 50 | 62 | ProgressBarWidget: {} | |
| 63 | + | SpinnerWidget: {} | |
| 51 | 64 | Widget: {} | |
| 52 | 65 | WidgetFactory: {} | |
M CHANGELOG.md +107 -2
| @@ -4,13 +4,118 @@ All notable changes to this project will be documented in this file. | |||
| 4 | 4 | ||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | + | ### Fixed | |
| 8 | + | - **Dark mode detection on internal pages**: Fixed `dumb://home` and other internal pages showing light theme despite system being in dark mode. Refactored color scheme detection to use a priority-based resolver with proper initialization ordering. The libadwaita detector (highest priority) is now correctly enabled only after `adw.Init()` completes, ensuring WebViews created during bootstrap get the correct theme preference. | |
| 9 | + | ||
| 10 | + | ## [0.22.0] - 2025-12-28 | |
| 11 | + | ||
| 12 | + | ### Added | |
| 13 | + | - **Omnibox bang shortcuts UI**: Typing `!` in the omnibox now shows all configured bangs (search shortcuts) with filtering and Enter-to-autocomplete, plus a header badge when a bang is recognized. | |
| 14 | + | - **Loading skeleton with app logo**: New panes show a centered 512x512 faded app logo with a spinner while loading, replacing the blank/white flash. Uses `--muted` theme color for the spinner. | |
| 15 | + | - **App logo favicon for internal pages**: `dumb://` URLs now display the dumber logo as favicon in history, favorites, omnibox suggestions, and dmenu/rofi. WebUI and CLI tools share consistent branding. | |
| 16 | + | - **Omnibox favorite indicator**: History items that are also bookmarked now display a yellowish left border and subtle background tint in the omnibox. Uses parallel async loading to avoid blocking browser startup. | |
| 17 | + | - **Mode indicator toaster**: Visual notification showing current modal mode (PANE MODE, TAB MODE, SESSION MODE, RESIZE MODE) at bottom-left corner with mode-specific colors. Configurable via `workspace.styling.mode_indicator_toaster_enabled` (default: true). | |
| 18 | + | - **Config migration system**: Detects missing config keys when new settings are added in updates and provides tools to add them. | |
| 19 | + | - `dumber config status`: Shows config file path and number of new settings available. | |
| 20 | + | - `dumber config migrate`: Lists missing keys with types/defaults and adds them to config file. | |
| 21 | + | - GUI toast notification on startup when new settings are available (configurable via `update.notify_on_new_settings`). | |
| 22 | + | - **CLI self-update command**: New `dumber update` command to check for and install updates from the command line. | |
| 23 | + | - Interactive spinner animation during check and download phases. | |
| 24 | + | - `--force` / `-f` flag to skip version check and reinstall the current version. | |
| 25 | + | - Reuses existing auto-update infrastructure (check, download, stage, apply on exit). | |
| 26 | + | - Theme-aware styling consistent with other CLI commands. | |
| 27 | + | - **Config open command**: New `dumber config open` command to open the config file in your preferred editor. Uses `$EDITOR`, then `$VISUAL`, with fallback to nano/vim/vi. | |
| 28 | + | - **Config schema command**: New `dumber config schema` command that displays all configuration keys with their types, defaults, descriptions, and valid values. Supports `--json` flag for machine-readable output. | |
| 29 | + | - **CLI gen-docs command**: New `dumber gen-docs` command to generate documentation from CLI command definitions. Supports man pages (installed to `~/.local/share/man/man1/`) and markdown formats. Use `--format markdown` for markdown output or `--output` for custom directory. | |
| 30 | + | - **Move pane to tab**: New pane-mode actions to move the active pane to another tab. | |
| 31 | + | - `Ctrl+P → m`: Opens a tab picker modal. | |
| 32 | + | - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed). | |
| 33 | + | - New config: `workspace.switch_to_tab_on_move` (default: true). | |
| 34 | + | ||
| 35 | + | ### Changed | |
| 36 | + | - **Cold start optimization**: Reduced startup time by ~150ms (29% faster) through parallel initialization. | |
| 37 | + | - New `internal/bootstrap` package encapsulates all startup orchestration. | |
| 38 | + | - `RunParallelInit()` runs 5 goroutines concurrently: directory resolution, pkg-config check, GStreamer check, theme manager creation, and SQLite WASM pre-compilation. | |
| 39 | + | - WebView pool prewarming creates one WebView synchronously so first `Acquire()` is instant. | |
| 40 | + | - Async session cleanup: stale session marking runs in background, doesn't block startup. | |
| 41 | + | - `StartupTimer` logs timing for each bootstrap phase (parallel init, DB/WebKit, app creation). | |
| 42 | + | - Database initialization runs in background goroutine while WebKit stack initializes on main thread. | |
| 43 | + | - Database path now resolved via `config.GetDatabaseFile()` following clean architecture. | |
| 44 | + | - Added connection pool settings optimized for SQLite (`SetMaxOpenConns(1)`, keep-alive). | |
| 45 | + | - Reduced `mmap_size` from 30GB to 256MB (reasonable for browser history DB). | |
| 46 | + | - Added `PRAGMA foreign_keys = ON` for referential integrity. | |
| 47 | + | - **Startup deferrals**: Runtime/media checks and SQLite WASM precompile now run after first paint, and DB initialization uses lazy repositories when auto-restore is off to cut cold-start latency further. | |
| 48 | + | - **Console logging**: Standardized console timestamps to `HH:MM:SS` across bootstrap and session logging. | |
| 49 | + | ||
| 50 | + | ### Fixed | |
| 51 | + | - **Omnibox favorite toggle reactivity**: Fixed space key toggle in omnibox not updating UI reactively. Now properly toggles favorites (add if not favorite, remove if favorite) with immediate visual feedback (yellow indicator) and toast notifications. Business logic moved to `ManageFavoritesUseCase.Toggle()` for testability. | |
| 52 | + | - **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping. | |
| 53 | + | - **Config migrate removes deprecated settings**: Fixed `dumber config migrate` not removing deprecated settings from the config file. Previously deprecated keys were preserved but unused; now they are properly deleted. Supports TOML, YAML, and JSON config formats. | |
| 54 | + | - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting. | |
| 55 | + | - **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode. | |
| 56 | + | - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes. | |
| 57 | + | - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews. | |
| 58 | + | - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency. | |
| 59 | + | - README CLI examples: updated to match current commands/flags (`purge --force` only, `logs` usage, `sessions list --limit`, removed deprecated `--dmenu` root flag). | |
| 60 | + | ||
| 61 | + | ## [0.21.0] - 2025-12-26 | |
| 62 | + | ||
| 63 | + | ### Added | |
| 64 | + | - **Configurable new-pane defaults**: New `workspace.new_pane_url` (default: `about:blank`, supports `dumb://`) and `omnibox.auto_open_on_new_pane` (default: false). | |
| 65 | + | - **Consume-or-expel panes (very alpha)**: Niri-style pane consume/expel operation to merge a leaf into a sibling stack or expel a stacked pane into a split. Default global shortcuts: `Alt+[` / `Alt+]` (and `Alt+Shift+[` / `Alt+Shift+]`). | |
| 66 | + | - **Zellij-style pane resize mode**: New modal resize mode (`Ctrl+N`) to adjust split sizes with keyboard (`h/j/k/l` or arrow keys, `+/-` smart resize). Shows a per-pane border indicator and keeps the timeout alive while you resize. | |
| 67 | + | - **Mouse-driven pane resizing**: Drag split dividers with the mouse; split ratios are persisted in session snapshots (rounded to 2 decimals) and restored on resurrection. | |
| 68 | + | - **Dynamic window title**: Window title now displays the active pane's page title in format `<Page Title> - Dumber`. Updates when switching tabs/panes or navigating. Truncated at 255 characters. | |
| 69 | + | - **Link URL status overlay**: Shows destination URL in bottom-left corner when hovering over links, images, or media elements. Standard browser UX pattern with 100ms show delay to avoid flicker and 150ms CSS fade transitions. Configurable via CSS. | |
| 70 | + | - **Local file path support**: `dumber browse test.html` now correctly opens local HTML files by converting existing file paths to `file://` URLs. Supports absolute paths, relative paths, and `~/` home directory expansion. | |
| 71 | + | - **Auto-update system**: Automatic update checking and self-updating binary replacement following clean architecture. | |
| 72 | + | - **Update check on startup**: Async check against GitHub releases API (configurable via `update.enable_on_startup`, default: true). | |
| 73 | + | - **Toast notifications**: Non-intrusive auto-dismiss notifications for update availability. | |
| 74 | + | - **Auto-download**: Optional background download with binary staging (configurable via `update.auto_download`, default: false). | |
| 75 | + | - **Binary self-update**: Atomic binary replacement on clean exit with `.old` backup. Only works if binary is user-writable (e.g., `~/.local/bin/`). | |
| 76 | + | - **CLI update check**: `dumber about` now displays update availability. | |
| 77 | + | - **Version-less release archives**: Goreleaser now produces `dumber_linux_x86_64.tar.gz` (version in directory inside) enabling stable GitHub `/releases/latest/download/` URLs. | |
| 78 | + | - **Session management & resurrection**: Zellij-inspired session management system allowing users to save, list, and restore browser sessions. | |
| 79 | + | - **Automatic session snapshots**: Debounced state saving (configurable interval, default 5s) captures tabs, panes, splits, stacks, URLs, titles, and zoom levels. | |
| 80 | + | - **Session Manager modal**: Access via `Ctrl+O → s/w` or `Ctrl+Shift+S` to browse and restore sessions with inline preview of tabs and panes. | |
| 81 | + | - **Session mode**: New modal mode (`Ctrl+O`) with purple border indicator, similar to pane/tab modes. | |
| 82 | + | - **CLI session commands**: `dumber sessions` opens interactive TUI browser; `dumber sessions list` shows saved sessions; `dumber sessions restore <id>` resurrects a session in a new window; `dumber sessions delete <id>` removes a session. | |
| 83 | + | - **Session purging**: Integrated into `dumber purge` command for cleaning up old session data. Simplified UI with single "Sessions" toggle instead of individual selection. | |
| 84 | + | - **Automatic session cleanup**: Sessions are automatically cleaned up on startup based on `max_exited_sessions` (default: 50) and `max_exited_session_age_days` (default: 7 days). | |
| 85 | + | - **Configuration options**: `session.auto_restore`, `session.snapshot_interval_ms`, `session.max_exited_sessions`, `session.max_exited_session_age_days`, plus session mode shortcuts and styling. | |
| 86 | + | ||
| 87 | + | ### Fixed | |
| 88 | + | - **Alt/Ctrl+number shortcuts on non-QWERTY keyboards**: Fixed Alt+1-9/0 tab switching and Ctrl+1-9/0 omnibox shortcuts not working on AZERTY, QWERTZ, Dvorak, and other non-QWERTY keyboard layouts. Uses hardware keycodes as fallback when keyval lookup fails, enabling shortcuts to work based on physical key position regardless of layout. | |
| 89 | + | - **Idle inhibition leak on pane close**: Fixed D-Bus idle inhibit request not being released when closing a pane/tab while in fullscreen or playing audio. The inhibition would remain active until the app exited. Now properly tracks fullscreen and audio state and releases inhibition before destroying WebView. | |
| 90 | + | - **Audio-based idle inhibition**: Extended idle inhibition to also activate when a page is playing audio (e.g., video/music playback), not just in fullscreen mode. Uses WebKit's `notify::is-playing-audio` signal. The inhibitor uses refcounting, so both fullscreen and audio can be active simultaneously. | |
| 91 | + | ||
| 92 | + | ## [0.20.1] - 2025-12-25 | |
| 93 | + | ||
| 94 | + | ### Fixed | |
| 95 | + | - **Stacked pane favicon and title updates**: Fixed favicon and title not updating in stacked pane title bars by tracking pane IDs in StackedView and properly routing update events to the correct pane. | |
| 96 | + | - **WebKit process leak on pane close**: Fixed zombie WebKit web processes not being terminated when panes were closed. `WebView.Destroy()` now properly calls `TerminateWebProcess()` and unparents GTK widgets, freeing GPU resources (VA-API, DMA-BUF, GL contexts). This should significantly improve video playback stability and reduce memory usage when opening/closing many panes. | |
| 97 | + | - **White flash on navigation**: Eliminated white page flash when navigating in dark mode by setting WebView background color to match theme and hiding WebView until content commits. | |
| 98 | + | - **Dark mode detection on websites**: Fixed `prefers-color-scheme: dark` detection by improving matchMedia query normalization and adding proper event listener stubs. Sites like Reddit now correctly detect dark mode preference. | |
| 99 | + | - **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey. | |
| 100 | + | - **System locking during fullscreen video**: Added idle inhibition via XDG Desktop Portal when WebView enters fullscreen mode. Prevents system from locking/sleeping during fullscreen video playback on Wayland (works with all compositors: GNOME, KDE, sway, hyprland, etc.). | |
| 101 | + | - **Navigation performance**: Significantly faster page navigation through multiple optimizations: | |
| 102 | + | - Async history recording via background worker (SQLite writes no longer block GTK main thread) | |
| 103 | + | - LRU cache for per-domain zoom levels (repeat visits skip database queries) | |
| 104 | + | - Async favicon preload (cache lookup doesn't block navigation start) | |
| 105 | + | - Progress callback throttling at ~60fps (reduces UI callback overhead) | |
| 106 | + | - Increased WebView pool prewarm from 2 to 4 for faster initial tab creation | |
| 107 | + | ||
| 108 | + | ### Changed | |
| 109 | + | - **Custom color picker and confirm dialogs**: Replaced native GTK color picker and confirm dialogs with custom Svelte components using bits-ui AlertDialog. Fixes Wayland compatibility issues where native dialogs would not display properly (fixes #12). | |
| 110 | + | - **GSK renderer default**: Changed default from `vulkan` to `auto` to let GTK choose the best renderer and avoid potential DMA-BUF synchronization conflicts with WebKit. | |
| 111 | + | - **Removed invalid GStreamer config**: Removed `video_buffer_size_mb` and `queue_buffer_time_sec` config options as these were not valid GStreamer environment variables (they are element properties that cannot be set via env vars). | |
| 112 | + | ||
| 7 | 113 | ### Added | |
| 114 | + | - **Performance config section**: New `performance` config section with `zoom_cache_size` (default: 256) and `webview_pool_prewarm_count` (default: 4) for tuning. Not exposed in UI but can be set in config file. | |
| 8 | 115 | - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers. | |
| 9 | 116 | - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI. | |
| 10 | 117 | - **WebUI config improvements**: Search shortcut management (CRUD) and reset-to-defaults support. | |
| 11 | 118 | - **Find-in-page**: New find UI and shortcut handling. | |
| 12 | - | ||
| 13 | - | ### Changed | |
| 14 | 119 | - Migrated GUI stack to `puregotk` + `puregotk-webkit` and disabled CGO for pure Go compilation. | |
| 15 | 120 | - **Omnibox rewrite**: Replaced injected Svelte/JS omnibox with a native GTK4 widget overlay. | |
| 16 | 121 | - Startup defers non-critical work, adds DNS prefetching and a warmed WebView pool for faster tab/pane creation. | |
M Makefile +6 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Makefile for dumber (Clean Architecture - puregotk) | |
| 2 | 2 | ||
| 3 | - | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs | |
| 3 | + | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man | |
| 4 | 4 | ||
| 5 | 5 | # Load local overrides from .env.local if present (Makefile syntax) | |
| 6 | 6 | ifneq (,$(wildcard .env.local)) | |
| @@ -137,6 +137,11 @@ check: ## Check that all tools and dependencies are working | |||
| 137 | 137 | @$(MAKE) test | |
| 138 | 138 | @echo "\nAll checks passed!" | |
| 139 | 139 | ||
| 140 | + | # Documentation | |
| 141 | + | man: build-quick ## Install man pages to ~/.local/share/man/man1/ | |
| 142 | + | @echo "Installing man pages..." | |
| 143 | + | $(DIST_DIR)/$(BINARY_NAME) gen-docs | |
| 144 | + | ||
| 140 | 145 | # Native release targets | |
| 141 | 146 | .PHONY: release-snapshot release | |
| 142 | 147 | ||
M README.md +98 -36
| @@ -23,6 +23,7 @@ A dumb browser that works like your favorite terminal multiplexer. | |||
| 23 | 23 | - **Customizable themes**: Light and dark palettes with semantic color tokens. | |
| 24 | 24 | - **Persistent storage**: SQLite for history, zoom levels, and settings. | |
| 25 | 25 | - **Live configuration**: Single config file with hot reload when possible. | |
| 26 | + | - **Session management**: Zellij-style session save/restore with automatic snapshots and resurrection. | |
| 26 | 27 | ||
| 27 | 28 | ## Status | |
| 28 | 29 | Dumber recently completed a full rewrite in pure Go using the `puregotk` and `puregotk-webkit` libraries. | |
| @@ -31,6 +32,8 @@ The project is now structured around clean architecture, which makes it much eas | |||
| 31 | 32 | ||
| 32 | 33 | Early development with regular releases. Core features work well for daily use but expect some rough edges. | |
| 33 | 34 | ||
| 35 | + | Some features are very alpha/experimental (notably consume-or-expel panes) and may change behavior between releases. | |
| 36 | + | ||
| 34 | 37 | ## Controls & Shortcuts | |
| 35 | 38 | ||
| 36 | 39 | ### Keyboard Shortcuts | |
| @@ -74,14 +77,37 @@ Early development with regular releases. Core features work well for daily use b | |||
| 74 | 77 | | **↓** / **D** (in pane mode) | Split Down | Create new pane below | | |
| 75 | 78 | | **S** (in pane mode) | Stack Pane | Create stacked pane (Zellij-style) | | |
| 76 | 79 | | **X** (in pane mode) | Close Pane | Close current pane | | |
| 80 | + | | **m** (in pane mode) | Move Pane To Tab | Opens a tab picker modal | | |
| 81 | + | | **M** (in pane mode) | Move Pane To Next Tab | Moves to the next tab, creates new tab if needed | | |
| 77 | 82 | | **Enter** (in pane mode) | Confirm Action | Confirm pane operation | | |
| 78 | 83 | | **Escape** (in pane mode) | Exit Pane Mode | Return to normal navigation | | |
| 79 | 84 | | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes | | |
| 80 | 85 | | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes | | |
| 86 | + | | **Alt+[** / **Alt+]** | Consume / Expel Pane | Experimental (alpha): merge into sibling stack, or expel out of stack | | |
| 87 | + | | **Alt+Shift+[** / **Alt+Shift+]** | Consume / Expel Pane (Vertical) | Experimental (alpha): up/down variants | | |
| 88 | + | ||
| 89 | + | #### Resize Mode | |
| 90 | + | | Shortcut | Action | Notes | | |
| 91 | + | |----------|--------|-------| | |
| 92 | + | | **Ctrl+N** | Enter Resize Mode | Modal mode for pane resizing | | |
| 93 | + | | **←/↓/↑/→** / **h/j/k/l** (in resize mode) | Move Divider | Resizes the nearest split by moving the divider | | |
| 94 | + | | **H/J/K/L** (in resize mode) | Move Divider (Inverse) | Inverts the direction | | |
| 95 | + | | **+ / -** (in resize mode) | Smart Resize | Grow/shrink the active pane (best-effort) | | |
| 96 | + | | **Enter** (in resize mode) | Confirm | Exit resize mode | | |
| 97 | + | | **Escape** (in resize mode) | Cancel | Exit resize mode | | |
| 98 | + | ||
| 99 | + | #### Session Management | |
| 100 | + | | Shortcut | Action | Notes | | |
| 101 | + | |----------|--------|-------| | |
| 102 | + | | **Ctrl+O** | Enter Session Mode | Modal mode for session operations | | |
| 103 | + | | **Ctrl+Shift+S** | Open Session Manager | Direct access to session browser | | |
| 104 | + | | **S** / **W** (in session mode) | Open Session Manager | Browse and restore sessions | | |
| 105 | + | | **Escape** (in session mode) | Exit Session Mode | Return to normal navigation | | |
| 81 | 106 | ||
| 82 | 107 | ### Mouse Controls | |
| 83 | 108 | | Action | Result | Notes | | |
| 84 | 109 | |--------|---------|-------| | |
| 110 | + | | **Drag pane divider** | Resize panes | Updates split ratio and persists in session snapshots | | |
| 85 | 111 | | **Ctrl+Scroll Up** | Zoom In | Smooth zoom control | | |
| 86 | 112 | | **Ctrl+Scroll Down** | Zoom Out | Smooth zoom control | | |
| 87 | 113 | | **Mouse Button 8** | Navigate Back | Side button (back) | | |
| @@ -108,12 +134,19 @@ All zoom changes are automatically persisted per-domain and restored on next vis | |||
| 108 | 134 | ||
| 109 | 135 | Download the latest release from the [releases page](https://github.com/bnema/dumber/releases): | |
| 110 | 136 | ```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" | |
| 114 | 147 | ``` | |
| 115 | 148 | ||
| 116 | - | Then install runtime dependencies (see [Install](#install) section for details). | |
| 149 | + | Then install runtime dependencies (see [Dependencies](#dependencies) section below). | |
| 117 | 150 | ||
| 118 | 151 | **Option 2: Build From Source** | |
| 119 | 152 | ||
| @@ -142,18 +175,11 @@ Development: | |||
| 142 | 175 | ||
| 143 | 176 | ### Pre-built Binaries (Recommended) | |
| 144 | 177 | ||
| 145 | - | Pre-built binaries are available for Linux (x86_64/amd64) in the [releases page](https://github.com/bnema/dumber/releases). These include all frontend assets and are ready to use: | |
| 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. | |
| 153 | 179 | ||
| 154 | - | Replace `<version>` with the desired version (e.g., `0.14.1`), or use the direct link from the releases page. | |
| 180 | + | ### Dependencies | |
| 155 | 181 | ||
| 156 | - | **Dependencies** (required for both pre-built binaries and source builds): | |
| 182 | + | Required for both pre-built binaries and source builds: | |
| 157 | 183 | ||
| 158 | 184 | **Arch Linux:** | |
| 159 | 185 | ```bash | |
| @@ -210,38 +236,61 @@ sudo apt install va-driver-all | |||
| 210 | 236 | - The resulting `./dist/dumber-no-gui` binary runs CLI flows with GUI code stubbed | |
| 211 | 237 | ||
| 212 | 238 | ## Usage | |
| 213 | - | - Open a URL or search: | |
| 239 | + | - Open a URL, local file, or search: | |
| 214 | 240 | - `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 | |
| 218 | 245 | - 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) | |
| 220 | 250 | - Launcher integration (dmenu‑style examples with favicon support): | |
| 221 | 251 | - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select` | |
| 222 | 252 | - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select` | |
| 253 | + | - `dumber dmenu --interactive` # built-in TUI fuzzy finder | |
| 254 | + | - `dumber dmenu --days 7` # show history from last 7 days (default: 30) | |
| 255 | + | - `dumber dmenu --most-visited` # sort by visit count instead of recency | |
| 223 | 256 | - 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 | |
| 234 | 276 | - 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 | |
| 238 | 287 | ||
| 239 | 288 | ### Dmenu mode invocation | |
| 240 | 289 | 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` | |
| 243 | 292 | ||
| 244 | - | Note: The root flag path only generates options; for processing a selection (`--select`), use the `dmenu` subcommand as the receiving command. | |
| 293 | + | For a built-in fuzzy finder UI, use: `dumber dmenu --interactive` | |
| 245 | 294 | ||
| 246 | 295 | In GUI mode the app serves an embedded home page via `dumb://home`, and frontend assets under `dumb://app/...`. | |
| 247 | 296 | ||
| @@ -259,7 +308,7 @@ Each tab holds its own workspace. Each workspace can contain multiple panes (spl | |||
| 259 | 308 | 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode | |
| 260 | 309 | 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout | |
| 261 | 310 | ||
| 262 | - | Tab mode provides visual feedback with an orange border indicator. | |
| 311 | + | ||
| 263 | 312 | ||
| 264 | 313 | ## Pane Management | |
| 265 | 314 | ||
| @@ -276,6 +325,19 @@ Split your browser window into multiple panes, each running independent web sess | |||
| 276 | 325 | 3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes | |
| 277 | 326 | 4. **Close**: Press `X` in pane mode to close the current pane | |
| 278 | 327 | ||
| 328 | + | ## Session Management | |
| 329 | + | ||
| 330 | + | Dumber automatically saves your browser session (tabs, panes, split ratios, URLs) and allows you to restore previous sessions. | |
| 331 | + | ||
| 332 | + | ### How it Works | |
| 333 | + | 1. **Automatic Snapshots**: Session state is saved automatically (debounced, every 5s by default) and on graceful shutdown | |
| 334 | + | 2. **Session Manager**: Press `Ctrl+O → s` or `Ctrl+Shift+S` to open the session manager modal | |
| 335 | + | 3. **Browse Sessions**: View active and exited sessions with tab/pane previews | |
| 336 | + | 4. **Restore**: Select an exited session to restore it in a new browser window | |
| 337 | + | 5. **CLI Access**: Use `dumber sessions` for an interactive TUI, or `dumber sessions list/restore/delete` for scripting | |
| 338 | + | ||
| 339 | + | ||
| 340 | + | ||
| 279 | 341 | ## Configuration | |
| 280 | 342 | ||
| 281 | 343 | Configuration file: `~/.config/dumber/config.toml` (supports TOML, JSON, YAML) | |
A assets/logo-32.png +0 -0 binary file: no text diff
binary file: no text diff
M assets/logo.svg +1 -1
| @@ -1,5 +1,5 @@ | |||
| 1 | 1 | <?xml version="1.0" encoding="UTF-8"?> | |
| 2 | - | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> | |
| 2 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512"> | |
| 3 | 3 | <defs> | |
| 4 | 4 | <!-- Background gradient - deeper dark --> | |
| 5 | 5 | <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> | |
M assets/webui_embed.go +5 -0
| @@ -14,3 +14,8 @@ var WebUIAssets embed.FS | |||
| 14 | 14 | // | |
| 15 | 15 | //go:embed logo.svg | |
| 16 | 16 | var LogoSVG []byte | |
| 17 | + | ||
| 18 | + | // LogoPNG32 contains the dumber logo as 32x32 PNG for CLI tools like dmenu/rofi. | |
| 19 | + | // | |
| 20 | + | //go:embed logo-32.png | |
| 21 | + | var LogoPNG32 []byte | |
M cmd/dumber/main.go +307 -156
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "os/signal" | |
| 9 | - | "path/filepath" | |
| 10 | 9 | "runtime" | |
| 11 | 10 | "syscall" | |
| 12 | 11 | ||
| @@ -15,20 +14,23 @@ import ( | |||
| 15 | 14 | "github.com/bnema/dumber/internal/bootstrap" | |
| 16 | 15 | "github.com/bnema/dumber/internal/cli/cmd" | |
| 17 | 16 | "github.com/bnema/dumber/internal/domain/build" | |
| 17 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 18 | 18 | "github.com/bnema/dumber/internal/domain/repository" | |
| 19 | + | "github.com/bnema/dumber/internal/infrastructure/cache" | |
| 19 | 20 | "github.com/bnema/dumber/internal/infrastructure/clipboard" | |
| 21 | + | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | |
| 20 | 22 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 21 | 23 | "github.com/bnema/dumber/internal/infrastructure/deps" | |
| 22 | 24 | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 23 | - | "github.com/bnema/dumber/internal/infrastructure/media" | |
| 25 | + | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| 24 | 26 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 27 | + | "github.com/bnema/dumber/internal/infrastructure/updater" | |
| 25 | 28 | "github.com/bnema/dumber/internal/logging" | |
| 26 | 29 | "github.com/bnema/dumber/internal/ui" | |
| 27 | 30 | "github.com/bnema/dumber/internal/ui/theme" | |
| 28 | - | "github.com/rs/zerolog" | |
| 29 | 31 | ) | |
| 30 | 32 | ||
| 31 | - | // Build-time variables (set via ldflags) | |
| 33 | + | // Build-time variables (set via ldflags). | |
| 32 | 34 | var ( | |
| 33 | 35 | version = "dev" | |
| 34 | 36 | commit = "unknown" | |
| @@ -38,14 +40,16 @@ var ( | |||
| 38 | 40 | // initialURL holds the URL to open on startup (from browse command). | |
| 39 | 41 | var initialURL string | |
| 40 | 42 | ||
| 43 | + | // restoreSessionID holds the session ID to restore on startup. | |
| 44 | + | var restoreSessionID string | |
| 45 | + | ||
| 41 | 46 | func main() { | |
| 42 | 47 | // Run GUI mode for browse command | |
| 43 | 48 | if len(os.Args) > 1 && os.Args[1] == "browse" { | |
| 44 | - | // Extract URL if provided | |
| 45 | 49 | if len(os.Args) > 2 { | |
| 46 | 50 | initialURL = os.Args[2] | |
| 47 | 51 | } | |
| 48 | - | // Strip "browse" and URL from args so GTK doesn't see them | |
| 52 | + | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | |
| 49 | 53 | os.Args = os.Args[:1] | |
| 50 | 54 | os.Exit(runGUI()) | |
| 51 | 55 | return | |
| @@ -64,190 +68,305 @@ func main() { | |||
| 64 | 68 | } | |
| 65 | 69 | ||
| 66 | 70 | func runGUI() int { | |
| 67 | - | // GTK requires all GTK calls to be made from the main thread | |
| 68 | 71 | runtime.LockOSThread() | |
| 72 | + | timer := bootstrap.NewStartupTimer() | |
| 69 | 73 | ||
| 70 | 74 | cfg := initConfig() | |
| 75 | + | timer.Mark("config") | |
| 71 | 76 | ||
| 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) | |
| 74 | 80 | ||
| 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) | |
| 83 | 91 | ||
| 84 | - | dataDir, cacheDir := resolveWebKitDirs(bootstrapLogger) | |
| 92 | + | needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore | |
| 85 | 93 | ||
| 86 | - | db, dbCleanup := openDatabase(ctx, bootstrapLogger, dataDir) | |
| 94 | + | stack, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB) | |
| 95 | + | if err != nil { | |
| 96 | + | bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit") | |
| 97 | + | } | |
| 87 | 98 | if dbCleanup != nil { | |
| 88 | 99 | defer dbCleanup() | |
| 89 | 100 | } | |
| 101 | + | timer.Mark("db_webkit_parallel") | |
| 90 | 102 | ||
| 91 | - | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db) | |
| 103 | + | browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, repos.session, !needsEagerDB) | |
| 92 | 104 | if err != nil { | |
| 93 | - | bootstrapLogger.Fatal().Err(err).Msg("failed to start session") | |
| 105 | + | bootstrapLog.Fatal().Err(err).Msg("failed to start session") | |
| 94 | 106 | } | |
| 95 | 107 | if browserSession.LogCleanup != nil { | |
| 96 | 108 | defer browserSession.LogCleanup() | |
| 97 | 109 | } | |
| 98 | - | defer func() { | |
| 99 | - | _ = browserSession.End(sessionCtx) | |
| 100 | - | }() | |
| 110 | + | defer func() { _ = browserSession.End(sessionCtx) }() | |
| 111 | + | timer.Mark("session") | |
| 101 | 112 | ||
| 102 | - | logger := browserSession.Logger | |
| 103 | 113 | ctx = sessionCtx | |
| 114 | + | log := logging.FromContext(ctx) | |
| 115 | + | if stack.MessageRouter != nil { | |
| 116 | + | stack.MessageRouter.SetBaseContext(ctx) | |
| 117 | + | } | |
| 104 | 118 | ||
| 105 | - | checkRuntimeRequirements(ctx, cfg, logger) | |
| 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 | + | } | |
| 113 | 124 | ||
| 125 | + | idleInhibitor := idle.NewPortalInhibitor(ctx) | |
| 126 | + | defer func() { | |
| 127 | + | if idleInhibitor != nil { | |
| 128 | + | _ = idleInhibitor.Close() | |
| 129 | + | } | |
| 130 | + | }() | |
| 131 | + | timer.Mark("use_cases") | |
| 132 | + | ||
| 133 | + | // Build UI | |
| 134 | + | uiDeps := buildUIDependencies( | |
| 135 | + | ctx, cfg, initResult.ThemeManager, | |
| 136 | + | initResult.ColorResolver, initResult.AdwaitaDetector, | |
| 137 | + | &stack, repos, useCases, idleInhibitor, browserSession.Session.ID, | |
| 138 | + | ) | |
| 139 | + | configureDeferredInit(uiDeps, cfg, browserSession) | |
| 114 | 140 | app, err := ui.New(uiDeps) | |
| 115 | 141 | if err != nil { | |
| 116 | - | logger.Error().Err(err).Msg("failed to create application") | |
| 142 | + | log.Error().Err(err).Msg("failed to create application") | |
| 117 | 143 | return 1 | |
| 118 | 144 | } | |
| 145 | + | timer.Mark("ui_deps") | |
| 146 | + | timer.Log(ctx) | |
| 119 | 147 | ||
| 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) | |
| 131 | 150 | ||
| 132 | - | // Run the application | |
| 133 | - | exitCode := app.Run(ctx, os.Args) | |
| 134 | - | return exitCode | |
| 151 | + | return app.Run(ctx, os.Args) | |
| 135 | 152 | } | |
| 136 | 153 | ||
| 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 | |
| 143 | 168 | } | |
| 144 | 169 | ||
| 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, | |
| 150 | 201 | }) | |
| 202 | + | ||
| 203 | + | lazyDB, err := bootstrap.CreateLazyDatabase() | |
| 151 | 204 | if err != nil { | |
| 152 | - | logger.Fatal().Err(err).Msg("runtime requirements check failed") | |
| 205 | + | return stack, nil, nil, err | |
| 153 | 206 | } | |
| 154 | - | if runtimeOut.OK { | |
| 207 | + | dbCleanup := func() { _ = lazyDB.Close() } | |
| 208 | + | return stack, createLazyRepositories(lazyDB), dbCleanup, nil | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func configureDeferredInit( | |
| 212 | + | uiDeps *ui.Dependencies, | |
| 213 | + | cfg *config.Config, | |
| 214 | + | session *bootstrap.BrowserSession, | |
| 215 | + | ) { | |
| 216 | + | if uiDeps == nil { | |
| 155 | 217 | return | |
| 156 | 218 | } | |
| 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 | + | }() | |
| 170 | 235 | } | |
| 171 | 236 | } | |
| 172 | - | logger.Fatal(). | |
| 173 | - | Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)"). | |
| 174 | - | Msg("runtime requirements not met") | |
| 175 | 237 | } | |
| 176 | 238 | ||
| 177 | - | func checkMediaRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) { | |
| 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") | |
| 184 | 254 | } | |
| 255 | + | log.Fatal().Err(err).Msg("initialization failed") | |
| 185 | 256 | } | |
| 186 | 257 | ||
| 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") | |
| 192 | 262 | } | |
| 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 | + | } | |
| 196 | 272 | } | |
| 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") | |
| 200 | 275 | } | |
| 201 | - | return dataDir, cacheDir | |
| 276 | + | log.Debug().Dur("duration", result.Duration).Msg("deferred init complete") | |
| 202 | 277 | } | |
| 203 | 278 | ||
| 204 | - | func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (*sql.DB, func()) { | |
| 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 | + | }) | |
| 207 | 292 | if err != nil { | |
| 208 | - | logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database") | |
| 293 | + | log.Warn().Err(err).Msg("auto-restore: failed to find restorable session") | |
| 294 | + | return | |
| 209 | 295 | } | |
| 210 | - | cleanup := func() { | |
| 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") | |
| 214 | 302 | } | |
| 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 | + | }() | |
| 216 | 315 | } | |
| 217 | 316 | ||
| 218 | 317 | // repositories groups infrastructure layer repository implementations. | |
| 219 | 318 | 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 | |
| 225 | 326 | } | |
| 226 | 327 | ||
| 227 | 328 | func createRepositories(db *sql.DB) *repositories { | |
| 228 | 329 | 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), | |
| 234 | 349 | } | |
| 235 | 350 | } | |
| 236 | 351 | ||
| 237 | 352 | // useCases groups application layer use case implementations. | |
| 238 | 353 | 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 | |
| 248 | 367 | } | |
| 249 | 368 | ||
| 250 | - | func createUseCases(repos *repositories, defaultZoom float64) *useCases { | |
| 369 | + | func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |
| 251 | 370 | const idAlphabetSize = 26 | |
| 252 | 371 | idCounter := uint64(0) | |
| 253 | 372 | idGenerator := func() string { | |
| @@ -257,17 +376,35 @@ func createUseCases(repos *repositories, defaultZoom float64) *useCases { | |||
| 257 | 376 | ||
| 258 | 377 | clipboardAdapter := clipboard.New() | |
| 259 | 378 | faviconCacheDir, _ := config.GetFaviconCacheDir() | |
| 379 | + | xdgDirs, _ := config.GetXDGDirs() | |
| 380 | + | stateDir, _ := config.GetStateDir() | |
| 381 | + | defaultZoom := cfg.DefaultWebpageZoom | |
| 382 | + | zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize) | |
| 383 | + | ||
| 384 | + | buildInfo := build.Info{ | |
| 385 | + | Version: version, | |
| 386 | + | Commit: commit, | |
| 387 | + | BuildDate: buildDate, | |
| 388 | + | GoVersion: runtime.Version(), | |
| 389 | + | } | |
| 390 | + | updateChecker := updater.NewGitHubChecker() | |
| 391 | + | updateDownloader := updater.NewGitHubDownloader() | |
| 392 | + | updateApplier := updater.NewApplier(stateDir) | |
| 260 | 393 | ||
| 261 | 394 | 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), | |
| 271 | 408 | } | |
| 272 | 409 | } | |
| 273 | 410 | ||
| @@ -275,32 +412,46 @@ func buildUIDependencies( | |||
| 275 | 412 | ctx context.Context, | |
| 276 | 413 | cfg *config.Config, | |
| 277 | 414 | themeManager *theme.Manager, | |
| 415 | + | colorResolver port.ColorSchemeResolver, | |
| 416 | + | adwaitaDetector *colorscheme.AdwaitaDetector, | |
| 278 | 417 | stack *bootstrap.WebKitStack, | |
| 279 | 418 | repos *repositories, | |
| 280 | 419 | uc *useCases, | |
| 420 | + | idleInhibitor port.IdleInhibitor, | |
| 421 | + | currentSessionID entity.SessionID, | |
| 281 | 422 | ) *ui.Dependencies { | |
| 282 | 423 | 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, | |
| 305 | 456 | } | |
| 306 | 457 | } | |
M docs/CONFIG.md +246 -22
| @@ -83,14 +83,14 @@ These settings control the `dumber dmenu` CLI command for rofi/fuzzel integratio | |||
| 83 | 83 | ||
| 84 | 84 | | Key | Type | Default | Description | | |
| 85 | 85 | |-----|------|---------|-------------| | |
| 86 | - | | `dmenu.max_history_items` | int | `20` | Max items shown in launcher | | |
| 86 | + | | `dmenu.max_history_days` | int | `30` | Number of days of history to show (0 = all) | | |
| 87 | 87 | | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output | | |
| 88 | 88 | | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output | | |
| 89 | 89 | | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items | | |
| 90 | 90 | | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts | | |
| 91 | 91 | | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs | | |
| 92 | 92 | | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string | | |
| 93 | - | | `dmenu.sort_by_visit_count` | bool | `true` | Sort by popularity | | |
| 93 | + | | `dmenu.sort_by_visit_count` | bool | `true` | Sort by visit count instead of recency | | |
| 94 | 94 | ||
| 95 | 95 | **CLI Usage:** | |
| 96 | 96 | ```bash | |
| @@ -101,8 +101,14 @@ dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select | |||
| 101 | 101 | # Interactive TUI mode | |
| 102 | 102 | dumber dmenu --interactive | |
| 103 | 103 | ||
| 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 | |
| 106 | 112 | ``` | |
| 107 | 113 | ||
| 108 | 114 | ## Omnibox | |
| @@ -110,11 +116,13 @@ dumber dmenu --max 50 | |||
| 110 | 116 | | Key | Type | Default | Valid Values | Description | | |
| 111 | 117 | |-----|------|---------|--------------|-------------| | |
| 112 | 118 | | `omnibox.initial_behavior` | string | `"recent"` | `recent`, `most_visited`, `none` | Initial history display behavior | | |
| 119 | + | | `omnibox.auto_open_on_new_pane` | bool | `false` | - | Automatically open the omnibox after creating a new pane | | |
| 113 | 120 | ||
| 114 | 121 | **Example:** | |
| 115 | 122 | ```toml | |
| 116 | 123 | [omnibox] | |
| 117 | 124 | initial_behavior = "recent" # Show recent history when omnibox opens | |
| 125 | + | auto_open_on_new_pane = false | |
| 118 | 126 | ||
| 119 | 127 | # Alternative options: | |
| 120 | 128 | # initial_behavior = "most_visited" # Show most visited sites | |
| @@ -182,7 +190,7 @@ border = "#363636" | |||
| 182 | 190 | | `rendering.disable_dmabuf_renderer` | bool | `false` | - | Disable WebKit DMA-BUF renderer (may fix flicker on Wayland; slower) | | |
| 183 | 191 | | `rendering.force_compositing_mode` | bool | `false` | - | Force WebKit compositing mode (`WEBKIT_FORCE_COMPOSITING_MODE`) | | |
| 184 | 192 | | `rendering.disable_compositing_mode` | bool | `false` | - | Disable WebKit compositing mode (`WEBKIT_DISABLE_COMPOSITING_MODE`) | | |
| 185 | - | | `rendering.gsk_renderer` | string | `"vulkan"` | `auto`, `opengl`, `vulkan`, `cairo` | GTK renderer selection (`GSK_RENDERER`) | | |
| 193 | + | | `rendering.gsk_renderer` | string | `"auto"` | `auto`, `opengl`, `vulkan`, `cairo` | GTK renderer selection (`GSK_RENDERER`) | | |
| 186 | 194 | | `rendering.disable_mipmaps` | bool | `false` | - | Disable GTK mipmaps (`GSK_GPU_DISABLE=mipmap`) | | |
| 187 | 195 | | `rendering.prefer_gl` | bool | `false` | - | Prefer OpenGL over GLES (`GDK_DEBUG=gl-prefer-gl`) | | |
| 188 | 196 | | `rendering.draw_compositing_indicators` | bool | `false` | - | Draw WebKit compositing indicators (debug) | | |
| @@ -194,6 +202,19 @@ border = "#363636" | |||
| 194 | 202 | ||
| 195 | 203 | ## Workspace Configuration | |
| 196 | 204 | ||
| 205 | + | ### General | |
| 206 | + | ||
| 207 | + | | Key | Type | Default | Description | | |
| 208 | + | |-----|------|---------|-------------| | |
| 209 | + | | `workspace.new_pane_url` | string | `"about:blank"` | URL loaded for new panes/tabs (supports `http(s)://`, `dumb://`, `file://`, `about:`) | | |
| 210 | + | | `workspace.switch_to_tab_on_move` | bool | `true` | When moving a pane to another tab, automatically switch to the destination tab | | |
| 211 | + | ||
| 212 | + | **Example:** | |
| 213 | + | ```toml | |
| 214 | + | [workspace] | |
| 215 | + | new_pane_url = "dumb://home" | |
| 216 | + | ``` | |
| 217 | + | ||
| 197 | 218 | ### Pane Mode | |
| 198 | 219 | ||
| 199 | 220 | | Key | Type | Default | Description | | |
| @@ -211,6 +232,15 @@ split-up = ["arrowup", "u"] | |||
| 211 | 232 | split-down = ["arrowdown", "d"] | |
| 212 | 233 | stack-pane = ["s"] | |
| 213 | 234 | close-pane = ["x"] | |
| 235 | + | move-pane-to-tab = ["m"] | |
| 236 | + | move-pane-to-next-tab = ["M", "shift+m"] | |
| 237 | + | ||
| 238 | + | # Consume-or-expel (niri-style) - very alpha | |
| 239 | + | consume-or-expel-left = ["bracketleft", "["] | |
| 240 | + | consume-or-expel-right = ["bracketright", "]"] | |
| 241 | + | consume-or-expel-up = ["shift+bracketleft", "braceleft", "{"] | |
| 242 | + | consume-or-expel-down = ["shift+bracketright", "braceright", "}"] | |
| 243 | + | ||
| 214 | 244 | focus-right = ["shift+arrowright", "shift+l"] | |
| 215 | 245 | focus-left = ["shift+arrowleft", "shift+h"] | |
| 216 | 246 | focus-up = ["shift+arrowup", "shift+k"] | |
| @@ -243,6 +273,38 @@ cancel = ["escape"] | |||
| 243 | 273 | ||
| 244 | 274 | > **Note:** Actions are inverted to key→action map in memory for O(1) lookup performance during navigation. | |
| 245 | 275 | ||
| 276 | + | ### Resize Mode | |
| 277 | + | ||
| 278 | + | | Key | Type | Default | Description | | |
| 279 | + | |-----|------|---------|-------------| | |
| 280 | + | | `workspace.resize_mode.activation_shortcut` | string | `"ctrl+n"` | Resize mode activation key | | |
| 281 | + | | `workspace.resize_mode.timeout_ms` | int | `3000` | Resize mode timeout (ms) | | |
| 282 | + | | `workspace.resize_mode.step_percent` | float | `5.0` | Split ratio step per keystroke (percent) | | |
| 283 | + | | `workspace.resize_mode.min_pane_percent` | float | `10.0` | Minimum pane size (percent) | | |
| 284 | + | | `workspace.resize_mode.actions` | map | See below | Action→keys mappings | | |
| 285 | + | ||
| 286 | + | **Default resize mode actions:** | |
| 287 | + | ```toml | |
| 288 | + | [workspace.resize_mode.actions] | |
| 289 | + | resize-increase-left = ["h", "arrowleft"] | |
| 290 | + | resize-increase-down = ["j", "arrowdown"] | |
| 291 | + | resize-increase-up = ["k", "arrowup"] | |
| 292 | + | resize-increase-right = ["l", "arrowright"] | |
| 293 | + | resize-decrease-left = ["H"] | |
| 294 | + | resize-decrease-down = ["J"] | |
| 295 | + | resize-decrease-up = ["K"] | |
| 296 | + | resize-decrease-right = ["L"] | |
| 297 | + | resize-increase = ["+", "="] | |
| 298 | + | resize-decrease = ["-"] | |
| 299 | + | confirm = ["enter"] | |
| 300 | + | cancel = ["escape"] | |
| 301 | + | ``` | |
| 302 | + | ||
| 303 | + | Notes: | |
| 304 | + | - Directional actions (`resize-increase-*/resize-decrease-*`) move the split divider. | |
| 305 | + | - Smart actions (`resize-increase` / `resize-decrease`) grow/shrink the active pane (best-effort) by picking a direction automatically. | |
| 306 | + | - Timeout is refreshed on each resize keypress so you can keep adjusting without re-entering the mode. | |
| 307 | + | ||
| 246 | 308 | ### Global Shortcuts | |
| 247 | 309 | ||
| 248 | 310 | | Key | Type | Default | Description | | |
| @@ -250,8 +312,14 @@ cancel = ["escape"] | |||
| 250 | 312 | | `workspace.shortcuts.close_pane` | string | `"ctrl+w"` | Close active pane (closes tab if last pane) | | |
| 251 | 313 | | `workspace.shortcuts.next_tab` | string | `"ctrl+tab"` | Next tab shortcut | | |
| 252 | 314 | | `workspace.shortcuts.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut | | |
| 315 | + | | `workspace.shortcuts.consume_or_expel_left` | string | `"alt+bracketleft"` | Consume into left sibling stack, or expel left if stacked (very alpha) | | |
| 316 | + | | `workspace.shortcuts.consume_or_expel_right` | string | `"alt+bracketright"` | Consume into right sibling stack, or expel right if stacked (very alpha) | | |
| 317 | + | | `workspace.shortcuts.consume_or_expel_up` | string | `"alt+shift+bracketleft"` | Consume into upper sibling stack, or expel up if stacked (very alpha) | | |
| 318 | + | | `workspace.shortcuts.consume_or_expel_down` | string | `"alt+shift+bracketright"` | Consume into lower sibling stack, or expel down if stacked (very alpha) | | |
| 253 | 319 | ||
| 254 | 320 | > **Note:** New tab creation uses modal tab mode (Ctrl+T then n/c). This follows the Zellij-style modal keyboard interface. | |
| 321 | + | > | |
| 322 | + | > **Note:** Consume-or-expel is experimental and may change behavior between releases. | |
| 255 | 323 | ||
| 256 | 324 | ### Popup Behavior | |
| 257 | 325 | ||
| @@ -271,24 +339,72 @@ cancel = ["escape"] | |||
| 271 | 339 | |-----|------|---------|-------------| | |
| 272 | 340 | | `workspace.styling.border_width` | int | `1` | Active pane border width (px) - overlay | | |
| 273 | 341 | | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Active pane border color | | |
| 274 | - | | `workspace.styling.pane_mode_border_width` | int | `4` | Pane mode border width (px) - Ctrl+P N overlay | | |
| 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 | | |
| 278 | 348 | | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) | | |
| 279 | 349 | ||
| 350 | + | **Example:** | |
| 351 | + | ```toml | |
| 352 | + | [workspace.styling] | |
| 353 | + | border_width = 1 | |
| 354 | + | border_color = "@theme_selected_bg_color" | |
| 355 | + | mode_border_width = 4 | |
| 356 | + | pane_mode_color = "#4A90E2" # Blue for pane mode | |
| 357 | + | tab_mode_color = "#FFA500" # Orange for tab mode | |
| 358 | + | session_mode_color = "#9B59B6" # Purple for session mode | |
| 359 | + | resize_mode_color = "#00D4AA" # Teal for resize mode | |
| 360 | + | mode_indicator_toaster_enabled = true | |
| 361 | + | transition_duration = 120 | |
| 362 | + | ``` | |
| 363 | + | ||
| 364 | + | ## Session | |
| 365 | + | ||
| 366 | + | | Key | Type | Default | Description | | |
| 367 | + | |-----|------|---------|-------------| | |
| 368 | + | | `session.auto_restore` | bool | `false` | Automatically restore the last session on startup | | |
| 369 | + | | `session.snapshot_interval_ms` | int | `5000` | Minimum interval between snapshots in milliseconds | | |
| 370 | + | | `session.max_exited_sessions` | int | `50` | Maximum number of exited sessions to keep | | |
| 371 | + | | `session.max_exited_session_age_days` | int | `7` | Maximum age in days for exited sessions (auto-deleted on startup) | | |
| 372 | + | ||
| 373 | + | ### Session Mode | |
| 374 | + | ||
| 375 | + | | Key | Type | Default | Description | | |
| 376 | + | |-----|------|---------|-------------| | |
| 377 | + | | `session.session_mode.activation_shortcut` | string | `"ctrl+o"` | Session mode activation key | | |
| 378 | + | | `session.session_mode.timeout_ms` | int | `3000` | Session mode timeout (ms) | | |
| 379 | + | | `session.session_mode.actions` | map | See below | Action to keys mappings | | |
| 380 | + | ||
| 381 | + | **Default session mode actions:** | |
| 382 | + | ```toml | |
| 383 | + | [session.session_mode.actions] | |
| 384 | + | session-manager = ["s", "w"] | |
| 385 | + | confirm = ["enter"] | |
| 386 | + | cancel = ["escape"] | |
| 387 | + | ``` | |
| 388 | + | ||
| 389 | + | **Example:** | |
| 390 | + | ```toml | |
| 391 | + | [session] | |
| 392 | + | auto_restore = false # Don't auto-restore on startup | |
| 393 | + | snapshot_interval_ms = 5000 # Save state every 5 seconds (debounced) | |
| 394 | + | max_exited_sessions = 50 # Keep last 50 exited sessions | |
| 395 | + | max_exited_session_age_days = 7 # Delete sessions older than 7 days on startup | |
| 396 | + | ``` | |
| 397 | + | ||
| 280 | 398 | ## Media | |
| 281 | 399 | ||
| 282 | 400 | | Key | Type | Default | Valid Values | Description | | |
| 283 | 401 | |-----|------|---------|--------------|-------------| | |
| 284 | 402 | | `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 | | |
| 287 | 405 | | `media.force_vsync` | bool | `false` | - | Force VSync for video playback (may help with tearing) | | |
| 288 | 406 | | `media.gl_rendering_mode` | string | `"auto"` | `auto`, `gles2`, `gl3`, `none` | OpenGL API selection for video rendering | | |
| 289 | 407 | | `media.gstreamer_debug_level` | int | `0` | `0-5` | GStreamer debug verbosity (0=off) | | |
| 290 | - | | `media.video_buffer_size_mb` | int | `64` | > 0 | Video buffer size in MB for smoother streaming | | |
| 291 | - | | `media.queue_buffer_time_sec` | int | `20` | > 0 | Queue prebuffer time in seconds | | |
| 292 | 408 | ||
| 293 | 409 | **Hardware decoding modes:** | |
| 294 | 410 | - `auto` (recommended): Hardware preferred with software fallback - fixes Twitch Error #4000 | |
| @@ -307,21 +423,15 @@ Dumber automatically detects your GPU vendor (AMD/Intel/NVIDIA) and sets optimal | |||
| 307 | 423 | - **Intel**: Uses `iHD` driver (modern, for Broadwell+) | |
| 308 | 424 | - **NVIDIA**: Uses `nvidia` driver with EGL platform | |
| 309 | 425 | ||
| 310 | - | **Buffering settings:** | |
| 311 | - | - `video_buffer_size_mb`: Controls GStreamer buffer size. Larger buffers reduce rebuffering on bursty streams (Twitch, YouTube). Uses more memory. | |
| 312 | - | - `queue_buffer_time_sec`: Controls prebuffer duration. Higher values allow more data to be buffered ahead of playback. | |
| 313 | - | ||
| 314 | 426 | **Example:** | |
| 315 | 427 | ```toml | |
| 316 | 428 | [media] | |
| 317 | 429 | 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 | |
| 320 | 432 | force_vsync = false # Let compositor handle VSync | |
| 321 | 433 | gl_rendering_mode = "auto" # GStreamer picks best GL API | |
| 322 | 434 | gstreamer_debug_level = 0 # Increase to 3-5 for debugging | |
| 323 | - | video_buffer_size_mb = 64 # 64 MB buffer for smooth streaming | |
| 324 | - | queue_buffer_time_sec = 20 # 20 seconds prebuffer | |
| 325 | 435 | ``` | |
| 326 | 436 | ||
| 327 | 437 | **Diagnostics CLI:** | |
| @@ -359,6 +469,120 @@ Notes: | |||
| 359 | 469 | - Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases. | |
| 360 | 470 | - Domain whitelist is managed via the database (`content_whitelist` table), not the config file. | |
| 361 | 471 | ||
| 472 | + | ## Update | |
| 473 | + | ||
| 474 | + | | Key | Type | Default | Description | | |
| 475 | + | |-----|------|---------|-------------| | |
| 476 | + | | `update.enable_on_startup` | bool | `true` | Check for updates when browser starts | | |
| 477 | + | | `update.auto_download` | bool | `false` | Automatically download updates in background | | |
| 478 | + | | `update.notify_on_new_settings` | bool | `true` | Show toast notification when new config settings are available | | |
| 479 | + | ||
| 480 | + | **Example:** | |
| 481 | + | ```toml | |
| 482 | + | [update] | |
| 483 | + | enable_on_startup = true # Check for updates on startup | |
| 484 | + | auto_download = false # Don't auto-download (prompt instead) | |
| 485 | + | notify_on_new_settings = true # Show toast when config migration available | |
| 486 | + | ``` | |
| 487 | + | ||
| 488 | + | **CLI Commands:** | |
| 489 | + | ```bash | |
| 490 | + | # Check config status and available migrations | |
| 491 | + | dumber config status | |
| 492 | + | ||
| 493 | + | # Add missing settings with default values | |
| 494 | + | dumber config migrate | |
| 495 | + | ||
| 496 | + | # Skip confirmation prompt | |
| 497 | + | dumber config migrate --yes | |
| 498 | + | ``` | |
| 499 | + | ||
| 500 | + | ## Performance Profiles | |
| 501 | + | ||
| 502 | + | Performance profiles provide preset configurations for WebKitGTK tuning. These settings affect Skia rendering threads, memory pressure handling, and WebView pool behavior. | |
| 503 | + | ||
| 504 | + | > **Note:** Performance settings are applied at browser startup. Changes require a restart to take effect. | |
| 505 | + | ||
| 506 | + | | Key | Type | Default | Valid Values | Description | | |
| 507 | + | |-----|------|---------|--------------|-------------| | |
| 508 | + | | `performance.profile` | string | `"default"` | `default`, `lite`, `max`, `custom` | Performance profile selection | | |
| 509 | + | ||
| 510 | + | ### Profiles | |
| 511 | + | ||
| 512 | + | | Profile | Description | Use Case | | |
| 513 | + | |---------|-------------|----------| | |
| 514 | + | | `default` | No tuning, uses WebKit defaults | Normal browsing, most users | | |
| 515 | + | | `lite` | Reduced resource usage | Low-RAM systems (< 4GB), battery saving | | |
| 516 | + | | `max` | Maximum responsiveness | Heavy pages (GitHub PRs, complex SPAs), high-end systems | | |
| 517 | + | | `custom` | Manual control over all settings | Advanced users who want fine-grained control | | |
| 518 | + | ||
| 519 | + | ### Profile Settings Matrix | |
| 520 | + | ||
| 521 | + | | Setting | default | lite | max | | |
| 522 | + | |---------|---------|------|-----| | |
| 523 | + | | Skia CPU threads | unset | 2 | `NumCPU()/2` (min 4) | | |
| 524 | + | | Skia GPU threads | unset | unset | scales with VRAM | | |
| 525 | + | | Web process memory (MB) | unset | 768 | unset | | |
| 526 | + | | Network process memory (MB) | unset | 384 | unset | | |
| 527 | + | | Conservative threshold | unset | 0.25 | unset | | |
| 528 | + | | Strict threshold | unset | 0.4 | unset | | |
| 529 | + | | WebView pool prewarm | 4 | 2 | scales with RAM | | |
| 530 | + | ||
| 531 | + | **Example:** | |
| 532 | + | ```toml | |
| 533 | + | [performance] | |
| 534 | + | profile = "default" # Most users - no tuning needed | |
| 535 | + | ||
| 536 | + | # For low-RAM systems: | |
| 537 | + | # profile = "lite" | |
| 538 | + | ||
| 539 | + | # For heavy pages (GitHub PRs, complex web apps): | |
| 540 | + | # profile = "max" | |
| 541 | + | ||
| 542 | + | # For manual control: | |
| 543 | + | # profile = "custom" | |
| 544 | + | ``` | |
| 545 | + | ||
| 546 | + | ### Custom Profile Settings | |
| 547 | + | ||
| 548 | + | When `profile = "custom"`, you can configure individual tuning options: | |
| 549 | + | ||
| 550 | + | | Key | Type | Default | Description | | |
| 551 | + | |-----|------|---------|-------------| | |
| 552 | + | | `performance.skia_cpu_painting_threads` | int | `0` | Skia CPU rendering threads (0=unset) | | |
| 553 | + | | `performance.skia_gpu_painting_threads` | int | `-1` | Skia GPU rendering threads (-1=unset, 0=disable) | | |
| 554 | + | | `performance.skia_enable_cpu_rendering` | bool | `false` | Force CPU rendering | | |
| 555 | + | | `performance.web_process_memory_limit_mb` | int | `0` | Web process memory limit in MB (0=unset) | | |
| 556 | + | | `performance.web_process_memory_poll_interval_sec` | float | `0` | Memory check interval (0=WebKit default: 30s) | | |
| 557 | + | | `performance.web_process_memory_conservative_threshold` | float | `0` | Conservative cleanup threshold (0=unset) | | |
| 558 | + | | `performance.web_process_memory_strict_threshold` | float | `0` | Strict cleanup threshold (0=unset) | | |
| 559 | + | | `performance.network_process_memory_limit_mb` | int | `0` | Network process memory limit in MB | | |
| 560 | + | | `performance.network_process_memory_poll_interval_sec` | float | `0` | Network memory check interval | | |
| 561 | + | | `performance.network_process_memory_conservative_threshold` | float | `0` | Network conservative threshold | | |
| 562 | + | | `performance.network_process_memory_strict_threshold` | float | `0` | Network strict threshold | | |
| 563 | + | | `performance.webview_pool_prewarm_count` | int | `4` | WebViews to pre-create at startup | | |
| 564 | + | | `performance.zoom_cache_size` | int | `256` | Domain zoom levels to cache | | |
| 565 | + | ||
| 566 | + | **Custom profile example:** | |
| 567 | + | ```toml | |
| 568 | + | [performance] | |
| 569 | + | profile = "custom" | |
| 570 | + | ||
| 571 | + | # Skia threading - tune for your CPU | |
| 572 | + | skia_cpu_painting_threads = 4 | |
| 573 | + | skia_gpu_painting_threads = 2 | |
| 574 | + | ||
| 575 | + | # Web process memory pressure | |
| 576 | + | web_process_memory_limit_mb = 1024 | |
| 577 | + | web_process_memory_conservative_threshold = 0.4 | |
| 578 | + | web_process_memory_strict_threshold = 0.6 | |
| 579 | + | ||
| 580 | + | # WebView pool | |
| 581 | + | webview_pool_prewarm_count = 6 | |
| 582 | + | ``` | |
| 583 | + | ||
| 584 | + | > **Important:** Individual tuning fields are ignored unless `profile = "custom"`. Setting individual fields with any other profile will produce a validation warning. | |
| 585 | + | ||
| 362 | 586 | ## Environment Variables | |
| 363 | 587 | ||
| 364 | 588 | All config values can be overridden via environment variables with the prefix `DUMBER_`: | |
| @@ -368,7 +592,7 @@ All config values can be overridden via environment variables with the prefix `D | |||
| 368 | 592 | DUMBER_DATABASE_PATH=/custom/path/db.sqlite | |
| 369 | 593 | ||
| 370 | 594 | # Rendering (examples) | |
| 371 | - | # Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="vulkan" | |
| 595 | + | # Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="auto" | |
| 372 | 596 | DUMBER_RENDERING_MODE=cpu | |
| 373 | 597 | # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true | |
| 374 | 598 | # DUMBER_RENDERING_GSK_RENDERER=opengl | |
M go.mod +7 -4
| @@ -3,19 +3,23 @@ module github.com/bnema/dumber | |||
| 3 | 3 | go 1.25.3 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/puregotk-webkit v0.0.7 | |
| 6 | + | github.com/bnema/puregotk-webkit v0.0.9 | |
| 7 | 7 | github.com/charmbracelet/bubbles v0.21.0 | |
| 8 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 9 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | |
| 10 | 10 | github.com/fsnotify/fsnotify v1.9.0 | |
| 11 | + | github.com/godbus/dbus/v5 v5.2.1 | |
| 11 | 12 | github.com/jwijenbergh/puregotk v0.0.0-20251222174431-73930527faa3 | |
| 12 | 13 | github.com/ncruces/go-sqlite3 v0.30.3 | |
| 14 | + | github.com/pelletier/go-toml/v2 v2.2.4 | |
| 13 | 15 | github.com/pressly/goose/v3 v3.26.0 | |
| 14 | 16 | github.com/rs/zerolog v1.33.0 | |
| 15 | 17 | github.com/spf13/cobra v1.10.2 | |
| 16 | 18 | github.com/spf13/viper v1.21.0 | |
| 17 | 19 | github.com/stretchr/testify v1.11.1 | |
| 18 | 20 | golang.org/x/image v0.34.0 | |
| 21 | + | golang.org/x/sys v0.38.0 | |
| 22 | + | gopkg.in/yaml.v3 v3.0.1 | |
| 19 | 23 | ) | |
| 20 | 24 | ||
| 21 | 25 | replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3 | |
| @@ -27,6 +31,7 @@ require ( | |||
| 27 | 31 | github.com/charmbracelet/x/ansi v0.10.1 // indirect | |
| 28 | 32 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect | |
| 29 | 33 | github.com/charmbracelet/x/term v0.2.1 // indirect | |
| 34 | + | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect | |
| 30 | 35 | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 31 | 36 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 32 | 37 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | |
| @@ -42,9 +47,9 @@ require ( | |||
| 42 | 47 | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 43 | 48 | github.com/muesli/termenv v0.16.0 // indirect | |
| 44 | 49 | github.com/ncruces/julianday v1.0.0 // indirect | |
| 45 | - | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | |
| 46 | 50 | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 47 | 51 | github.com/rivo/uniseg v0.4.7 // indirect | |
| 52 | + | github.com/russross/blackfriday/v2 v2.1.0 // indirect | |
| 48 | 53 | github.com/sagikazarmark/locafero v0.11.0 // indirect | |
| 49 | 54 | github.com/sahilm/fuzzy v0.1.1 // indirect | |
| 50 | 55 | github.com/sethvargo/go-retry v0.3.0 // indirect | |
| @@ -59,7 +64,5 @@ require ( | |||
| 59 | 64 | go.uber.org/multierr v1.11.0 // indirect | |
| 60 | 65 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 61 | 66 | golang.org/x/sync v0.19.0 // indirect | |
| 62 | - | golang.org/x/sys v0.38.0 // indirect | |
| 63 | 67 | golang.org/x/text v0.32.0 // indirect | |
| 64 | - | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 65 | 68 | ) | |
M go.sum +6 -2 generated file: collapsed
A internal/application/port/cache.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | // Cache is a generic cache interface for storing key-value pairs. | |
| 4 | + | // Implementations should be thread-safe. | |
| 5 | + | type Cache[K comparable, V any] interface { | |
| 6 | + | // Get retrieves a value by key. Returns the value and true if found, | |
| 7 | + | // or the zero value and false if not found. | |
| 8 | + | Get(key K) (V, bool) | |
| 9 | + | ||
| 10 | + | // Set stores a value for the given key. If the cache is at capacity, | |
| 11 | + | // the least recently used entry may be evicted. | |
| 12 | + | Set(key K, value V) | |
| 13 | + | ||
| 14 | + | // Remove deletes a key from the cache. | |
| 15 | + | Remove(key K) | |
| 16 | + | ||
| 17 | + | // Len returns the number of items currently in the cache. | |
| 18 | + | Len() int | |
| 19 | + | } | |
A internal/application/port/color_scheme.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | // ColorSchemePreference represents the resolved color scheme preference. | |
| 4 | + | type ColorSchemePreference struct { | |
| 5 | + | // PrefersDark indicates whether dark mode is preferred. | |
| 6 | + | PrefersDark bool | |
| 7 | + | ||
| 8 | + | // Source identifies which detector provided this preference. | |
| 9 | + | // Empty string means fallback was used. | |
| 10 | + | Source string | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | // ColorSchemeDetector detects the system's color scheme preference. | |
| 14 | + | // Multiple detectors can be registered with different priorities. | |
| 15 | + | type ColorSchemeDetector interface { | |
| 16 | + | // Name returns a human-readable name for this detector. | |
| 17 | + | Name() string | |
| 18 | + | ||
| 19 | + | // Priority returns the detector's priority. | |
| 20 | + | // Higher values = higher priority (checked first). | |
| 21 | + | // Recommended ranges: | |
| 22 | + | // - 100+: Runtime detectors (libadwaita, GTK Settings) | |
| 23 | + | // - 50+: Config file detectors | |
| 24 | + | // - 10+: Fallback detectors (gsettings, env vars) | |
| 25 | + | Priority() int | |
| 26 | + | ||
| 27 | + | // Available returns true if this detector can be used. | |
| 28 | + | // For example, libadwaita detector returns false before adw.Init(). | |
| 29 | + | Available() bool | |
| 30 | + | ||
| 31 | + | // Detect returns the detected preference and whether detection succeeded. | |
| 32 | + | // Returns (preference, true) on success, (_, false) if unavailable or detection failed. | |
| 33 | + | Detect() (prefersDark bool, ok bool) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // ColorSchemeResolver resolves the effective color scheme preference. | |
| 37 | + | // It manages multiple detectors and respects config overrides. | |
| 38 | + | type ColorSchemeResolver interface { | |
| 39 | + | // Resolve returns the current color scheme preference. | |
| 40 | + | // It checks config for explicit overrides, then queries detectors by priority. | |
| 41 | + | // If all detectors fail, defaults to dark mode. | |
| 42 | + | Resolve() ColorSchemePreference | |
| 43 | + | ||
| 44 | + | // RegisterDetector adds a detector to the resolver. | |
| 45 | + | // Safe to call at any time; the resolver re-evaluates on next Resolve(). | |
| 46 | + | RegisterDetector(detector ColorSchemeDetector) | |
| 47 | + | ||
| 48 | + | // Refresh forces re-evaluation of the color scheme. | |
| 49 | + | // Call this after registering new detectors or when system preferences change. | |
| 50 | + | // Returns the new preference. | |
| 51 | + | Refresh() ColorSchemePreference | |
| 52 | + | ||
| 53 | + | // OnChange registers a callback for color scheme changes. | |
| 54 | + | // The callback is invoked when Refresh() results in a different preference. | |
| 55 | + | // Returns a function to unregister the callback. | |
| 56 | + | OnChange(callback func(ColorSchemePreference)) func() | |
| 57 | + | } | |
A internal/application/port/config_migrator.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | // MigrationResult contains the result of a config migration check. | |
| 4 | + | type MigrationResult struct { | |
| 5 | + | // MissingKeys contains the keys that exist in defaults but not in user config. | |
| 6 | + | MissingKeys []string | |
| 7 | + | // ConfigFile is the path to the user's config file. | |
| 8 | + | ConfigFile string | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | // KeyInfo contains metadata about a config key for display purposes. | |
| 12 | + | type KeyInfo struct { | |
| 13 | + | // Key is the dot-notation key path (e.g., "update.enable_on_startup"). | |
| 14 | + | Key string | |
| 15 | + | // Type is the Go type of the value (e.g., "bool", "int", "string"). | |
| 16 | + | Type string | |
| 17 | + | // DefaultValue is a string representation of the default value. | |
| 18 | + | DefaultValue string | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // KeyChangeType indicates the type of config key change. | |
| 22 | + | type KeyChangeType int | |
| 23 | + | ||
| 24 | + | const ( | |
| 25 | + | // KeyChangeAdded indicates a new key was added to defaults. | |
| 26 | + | KeyChangeAdded KeyChangeType = iota | |
| 27 | + | // KeyChangeRemoved indicates a key in user config no longer exists in defaults. | |
| 28 | + | KeyChangeRemoved | |
| 29 | + | // KeyChangeRenamed indicates a key was renamed (detected via similarity). | |
| 30 | + | KeyChangeRenamed | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | // String returns a display symbol for the change type. | |
| 34 | + | func (t KeyChangeType) String() string { | |
| 35 | + | switch t { | |
| 36 | + | case KeyChangeAdded: | |
| 37 | + | return "+" | |
| 38 | + | case KeyChangeRemoved: | |
| 39 | + | return "-" | |
| 40 | + | case KeyChangeRenamed: | |
| 41 | + | return "~" | |
| 42 | + | default: | |
| 43 | + | return "?" | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // KeyChange represents a detected change between user config and defaults. | |
| 48 | + | type KeyChange struct { | |
| 49 | + | Type KeyChangeType // Type of change | |
| 50 | + | OldKey string // Old key name (for removed/renamed) | |
| 51 | + | NewKey string // New key name (for added/renamed) | |
| 52 | + | OldValue string // Old value (for renamed/consolidated) | |
| 53 | + | NewValue string // New/default value | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // ConfigMigrator checks for and applies config migrations. | |
| 57 | + | type ConfigMigrator interface { | |
| 58 | + | // CheckMigration checks if user config is missing any default keys. | |
| 59 | + | // Returns nil if no migration is needed (config file doesn't exist or is complete). | |
| 60 | + | CheckMigration() (*MigrationResult, error) | |
| 61 | + | ||
| 62 | + | // DetectChanges analyzes user config and returns all detected changes. | |
| 63 | + | // This provides a detailed diff-like view of what migration would do. | |
| 64 | + | DetectChanges() ([]KeyChange, error) | |
| 65 | + | ||
| 66 | + | // Migrate adds missing default keys and removes deprecated keys from the user's config file. | |
| 67 | + | // Returns the list of keys that were added/renamed/removed. | |
| 68 | + | Migrate() ([]string, error) | |
| 69 | + | ||
| 70 | + | // GetKeyInfo returns detailed information about a config key. | |
| 71 | + | GetKeyInfo(key string) KeyInfo | |
| 72 | + | ||
| 73 | + | // GetConfigFile returns the path to the user's config file. | |
| 74 | + | GetConfigFile() (string, error) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // DiffFormatter formats config changes for display. | |
| 78 | + | type DiffFormatter interface { | |
| 79 | + | // FormatChangesAsDiff returns changes formatted as a diff-like string for display. | |
| 80 | + | FormatChangesAsDiff(changes []KeyChange) string | |
| 81 | + | } | |
A internal/application/port/config_schema.go +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |
| 4 | + | ||
| 5 | + | // ConfigSchemaProvider provides configuration schema information. | |
| 6 | + | type ConfigSchemaProvider interface { | |
| 7 | + | // GetSchema returns all configuration keys with their metadata. | |
| 8 | + | GetSchema() []entity.ConfigKeyInfo | |
| 9 | + | } | |
A internal/application/port/database.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | // Package port defines interfaces for infrastructure adapters. | |
| 2 | + | package port | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "database/sql" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // DatabaseProvider provides access to the database connection. | |
| 10 | + | // Implementations may initialize the database lazily on first access. | |
| 11 | + | // | |
| 12 | + | // Currently used by the lazy repository infrastructure (sqlite.LazyDB and lazy_repos.go), | |
| 13 | + | // which is kept for potential future optimization scenarios where deferring database | |
| 14 | + | // initialization past first paint provides latency benefits. | |
| 15 | + | type DatabaseProvider interface { | |
| 16 | + | // DB returns the database connection, initializing it if necessary. | |
| 17 | + | // Returns an error if initialization fails. | |
| 18 | + | DB(ctx context.Context) (*sql.DB, error) | |
| 19 | + | ||
| 20 | + | // Close closes the database connection if it was initialized. | |
| 21 | + | Close() error | |
| 22 | + | ||
| 23 | + | // IsInitialized returns true if the database has been initialized. | |
| 24 | + | IsInitialized() bool | |
| 25 | + | } | |
A internal/application/port/hardware.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | // Package port defines interfaces for external dependencies. | |
| 2 | + | package port | |
| 3 | + | ||
| 4 | + | import "context" | |
| 5 | + | ||
| 6 | + | // HardwareInfo contains detected system hardware specifications. | |
| 7 | + | // Used for performance profile auto-tuning. | |
| 8 | + | type HardwareInfo struct { | |
| 9 | + | // CPU information | |
| 10 | + | CPUCores int // Number of physical CPU cores | |
| 11 | + | CPUThreads int // Number of logical CPU threads (with hyperthreading) | |
| 12 | + | ||
| 13 | + | // Memory information (in bytes) | |
| 14 | + | TotalRAM uint64 // Total system RAM | |
| 15 | + | AvailableRAM uint64 // Currently available RAM | |
| 16 | + | ||
| 17 | + | // GPU information | |
| 18 | + | GPUVendor GPUVendor // AMD, Intel, NVIDIA, or Unknown | |
| 19 | + | GPUName string // Human-readable GPU name (e.g., "AMD Radeon RX 9070 XT") | |
| 20 | + | VRAM uint64 // Video memory in bytes (0 if unknown) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // CPUCoresOrDefault returns CPUCores if > 0, otherwise returns the fallback. | |
| 24 | + | func (h HardwareInfo) CPUCoresOrDefault(fallback int) int { | |
| 25 | + | if h.CPUCores > 0 { | |
| 26 | + | return h.CPUCores | |
| 27 | + | } | |
| 28 | + | return fallback | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // CPUThreadsOrDefault returns CPUThreads if > 0, otherwise returns the fallback. | |
| 32 | + | func (h HardwareInfo) CPUThreadsOrDefault(fallback int) int { | |
| 33 | + | if h.CPUThreads > 0 { | |
| 34 | + | return h.CPUThreads | |
| 35 | + | } | |
| 36 | + | return fallback | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // TotalRAMMB returns total RAM in megabytes. | |
| 40 | + | // Returns 0 if RAM exceeds int range (unlikely on any real system). | |
| 41 | + | func (h HardwareInfo) TotalRAMMB() int { | |
| 42 | + | mb := h.TotalRAM / (1024 * 1024) | |
| 43 | + | if mb > uint64(^uint(0)>>1) { // Check against max int | |
| 44 | + | return 0 | |
| 45 | + | } | |
| 46 | + | return int(mb) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // VRAMMB returns VRAM in megabytes. | |
| 50 | + | // Returns 0 if VRAM exceeds int range (unlikely on any real system). | |
| 51 | + | func (h HardwareInfo) VRAMMB() int { | |
| 52 | + | mb := h.VRAM / (1024 * 1024) | |
| 53 | + | if mb > uint64(^uint(0)>>1) { // Check against max int | |
| 54 | + | return 0 | |
| 55 | + | } | |
| 56 | + | return int(mb) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // HasDedicatedGPU returns true if a discrete GPU with VRAM was detected. | |
| 60 | + | func (h HardwareInfo) HasDedicatedGPU() bool { | |
| 61 | + | return h.VRAM > 0 && h.GPUVendor != GPUVendorUnknown | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // HardwareSurveyor detects system hardware specifications. | |
| 65 | + | type HardwareSurveyor interface { | |
| 66 | + | // Survey detects and returns hardware information. | |
| 67 | + | // This should be called once at startup and cached. | |
| 68 | + | Survey(ctx context.Context) HardwareInfo | |
| 69 | + | } | |
A internal/application/port/idle_inhibit.go +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // IdleInhibitor prevents system idle/screensaver during fullscreen video playback. | |
| 6 | + | // Implementations use refcounting internally - multiple Inhibit calls require | |
| 7 | + | // matching Uninhibit calls before inhibition is released. | |
| 8 | + | type IdleInhibitor interface { | |
| 9 | + | // Inhibit increments the inhibit refcount. First call activates inhibition. | |
| 10 | + | // Safe to call multiple times (refcounted). | |
| 11 | + | Inhibit(ctx context.Context, reason string) error | |
| 12 | + | ||
| 13 | + | // Uninhibit decrements the refcount. When zero, releases inhibition. | |
| 14 | + | // Safe to call even if not currently inhibited (no-op). | |
| 15 | + | Uninhibit(ctx context.Context) error | |
| 16 | + | ||
| 17 | + | // IsInhibited returns true if currently inhibiting idle. | |
| 18 | + | IsInhibited() bool | |
| 19 | + | ||
| 20 | + | // Close releases any held resources. Should be called on application shutdown. | |
| 21 | + | Close() error | |
| 22 | + | } | |
M internal/application/port/mocks/mocks.go +1861 -47
| @@ -6,6 +6,7 @@ package mocks | |||
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | + | "database/sql" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -13,13 +14,1589 @@ import ( | |||
| 13 | 14 | mock "github.com/stretchr/testify/mock" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 17 | + | // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 18 | + | // The first argument is typically a *testing.T value. | |
| 19 | + | func NewMockColorSchemeDetector(t interface { | |
| 20 | + | mock.TestingT | |
| 21 | + | Cleanup(func()) | |
| 22 | + | }) *MockColorSchemeDetector { | |
| 23 | + | mock := &MockColorSchemeDetector{} | |
| 24 | + | mock.Mock.Test(t) | |
| 25 | + | ||
| 26 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 27 | + | ||
| 28 | + | return mock | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // MockColorSchemeDetector is an autogenerated mock type for the ColorSchemeDetector type | |
| 32 | + | type MockColorSchemeDetector struct { | |
| 33 | + | mock.Mock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | type MockColorSchemeDetector_Expecter struct { | |
| 37 | + | mock *mock.Mock | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (_m *MockColorSchemeDetector) EXPECT() *MockColorSchemeDetector_Expecter { | |
| 41 | + | return &MockColorSchemeDetector_Expecter{mock: &_m.Mock} | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Available provides a mock function for the type MockColorSchemeDetector | |
| 45 | + | func (_mock *MockColorSchemeDetector) Available() bool { | |
| 46 | + | ret := _mock.Called() | |
| 47 | + | ||
| 48 | + | if len(ret) == 0 { | |
| 49 | + | panic("no return value specified for Available") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | var r0 bool | |
| 53 | + | if returnFunc, ok := ret.Get(0).(func() bool); ok { | |
| 54 | + | r0 = returnFunc() | |
| 55 | + | } else { | |
| 56 | + | r0 = ret.Get(0).(bool) | |
| 57 | + | } | |
| 58 | + | return r0 | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // MockColorSchemeDetector_Available_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Available' | |
| 62 | + | type MockColorSchemeDetector_Available_Call struct { | |
| 63 | + | *mock.Call | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Available is a helper method to define mock.On call | |
| 67 | + | func (_e *MockColorSchemeDetector_Expecter) Available() *MockColorSchemeDetector_Available_Call { | |
| 68 | + | return &MockColorSchemeDetector_Available_Call{Call: _e.mock.On("Available")} | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (_c *MockColorSchemeDetector_Available_Call) Run(run func()) *MockColorSchemeDetector_Available_Call { | |
| 72 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 73 | + | run() | |
| 74 | + | }) | |
| 75 | + | return _c | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (_c *MockColorSchemeDetector_Available_Call) Return(b bool) *MockColorSchemeDetector_Available_Call { | |
| 79 | + | _c.Call.Return(b) | |
| 80 | + | return _c | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (_c *MockColorSchemeDetector_Available_Call) RunAndReturn(run func() bool) *MockColorSchemeDetector_Available_Call { | |
| 84 | + | _c.Call.Return(run) | |
| 85 | + | return _c | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // Detect provides a mock function for the type MockColorSchemeDetector | |
| 89 | + | func (_mock *MockColorSchemeDetector) Detect() (bool, bool) { | |
| 90 | + | ret := _mock.Called() | |
| 91 | + | ||
| 92 | + | if len(ret) == 0 { | |
| 93 | + | panic("no return value specified for Detect") | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | var r0 bool | |
| 97 | + | var r1 bool | |
| 98 | + | if returnFunc, ok := ret.Get(0).(func() (bool, bool)); ok { | |
| 99 | + | return returnFunc() | |
| 100 | + | } | |
| 101 | + | if returnFunc, ok := ret.Get(0).(func() bool); ok { | |
| 102 | + | r0 = returnFunc() | |
| 103 | + | } else { | |
| 104 | + | r0 = ret.Get(0).(bool) | |
| 105 | + | } | |
| 106 | + | if returnFunc, ok := ret.Get(1).(func() bool); ok { | |
| 107 | + | r1 = returnFunc() | |
| 108 | + | } else { | |
| 109 | + | r1 = ret.Get(1).(bool) | |
| 110 | + | } | |
| 111 | + | return r0, r1 | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // MockColorSchemeDetector_Detect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Detect' | |
| 115 | + | type MockColorSchemeDetector_Detect_Call struct { | |
| 116 | + | *mock.Call | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Detect is a helper method to define mock.On call | |
| 120 | + | func (_e *MockColorSchemeDetector_Expecter) Detect() *MockColorSchemeDetector_Detect_Call { | |
| 121 | + | return &MockColorSchemeDetector_Detect_Call{Call: _e.mock.On("Detect")} | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (_c *MockColorSchemeDetector_Detect_Call) Run(run func()) *MockColorSchemeDetector_Detect_Call { | |
| 125 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 126 | + | run() | |
| 127 | + | }) | |
| 128 | + | return _c | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (_c *MockColorSchemeDetector_Detect_Call) Return(prefersDark bool, ok bool) *MockColorSchemeDetector_Detect_Call { | |
| 132 | + | _c.Call.Return(prefersDark, ok) | |
| 133 | + | return _c | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (_c *MockColorSchemeDetector_Detect_Call) RunAndReturn(run func() (bool, bool)) *MockColorSchemeDetector_Detect_Call { | |
| 137 | + | _c.Call.Return(run) | |
| 138 | + | return _c | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Name provides a mock function for the type MockColorSchemeDetector | |
| 142 | + | func (_mock *MockColorSchemeDetector) Name() string { | |
| 143 | + | ret := _mock.Called() | |
| 144 | + | ||
| 145 | + | if len(ret) == 0 { | |
| 146 | + | panic("no return value specified for Name") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | var r0 string | |
| 150 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |
| 151 | + | r0 = returnFunc() | |
| 152 | + | } else { | |
| 153 | + | r0 = ret.Get(0).(string) | |
| 154 | + | } | |
| 155 | + | return r0 | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // MockColorSchemeDetector_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' | |
| 159 | + | type MockColorSchemeDetector_Name_Call struct { | |
| 160 | + | *mock.Call | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // Name is a helper method to define mock.On call | |
| 164 | + | func (_e *MockColorSchemeDetector_Expecter) Name() *MockColorSchemeDetector_Name_Call { | |
| 165 | + | return &MockColorSchemeDetector_Name_Call{Call: _e.mock.On("Name")} | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (_c *MockColorSchemeDetector_Name_Call) Run(run func()) *MockColorSchemeDetector_Name_Call { | |
| 169 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 170 | + | run() | |
| 171 | + | }) | |
| 172 | + | return _c | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func (_c *MockColorSchemeDetector_Name_Call) Return(s string) *MockColorSchemeDetector_Name_Call { | |
| 176 | + | _c.Call.Return(s) | |
| 177 | + | return _c | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (_c *MockColorSchemeDetector_Name_Call) RunAndReturn(run func() string) *MockColorSchemeDetector_Name_Call { | |
| 181 | + | _c.Call.Return(run) | |
| 182 | + | return _c | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // Priority provides a mock function for the type MockColorSchemeDetector | |
| 186 | + | func (_mock *MockColorSchemeDetector) Priority() int { | |
| 187 | + | ret := _mock.Called() | |
| 188 | + | ||
| 189 | + | if len(ret) == 0 { | |
| 190 | + | panic("no return value specified for Priority") | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | var r0 int | |
| 194 | + | if returnFunc, ok := ret.Get(0).(func() int); ok { | |
| 195 | + | r0 = returnFunc() | |
| 196 | + | } else { | |
| 197 | + | r0 = ret.Get(0).(int) | |
| 198 | + | } | |
| 199 | + | return r0 | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // MockColorSchemeDetector_Priority_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Priority' | |
| 203 | + | type MockColorSchemeDetector_Priority_Call struct { | |
| 204 | + | *mock.Call | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // Priority is a helper method to define mock.On call | |
| 208 | + | func (_e *MockColorSchemeDetector_Expecter) Priority() *MockColorSchemeDetector_Priority_Call { | |
| 209 | + | return &MockColorSchemeDetector_Priority_Call{Call: _e.mock.On("Priority")} | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (_c *MockColorSchemeDetector_Priority_Call) Run(run func()) *MockColorSchemeDetector_Priority_Call { | |
| 213 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 214 | + | run() | |
| 215 | + | }) | |
| 216 | + | return _c | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func (_c *MockColorSchemeDetector_Priority_Call) Return(n int) *MockColorSchemeDetector_Priority_Call { | |
| 220 | + | _c.Call.Return(n) | |
| 221 | + | return _c | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func (_c *MockColorSchemeDetector_Priority_Call) RunAndReturn(run func() int) *MockColorSchemeDetector_Priority_Call { | |
| 225 | + | _c.Call.Return(run) | |
| 226 | + | return _c | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | // NewMockColorSchemeResolver creates a new instance of MockColorSchemeResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 230 | + | // The first argument is typically a *testing.T value. | |
| 231 | + | func NewMockColorSchemeResolver(t interface { | |
| 232 | + | mock.TestingT | |
| 233 | + | Cleanup(func()) | |
| 234 | + | }) *MockColorSchemeResolver { | |
| 235 | + | mock := &MockColorSchemeResolver{} | |
| 236 | + | mock.Mock.Test(t) | |
| 237 | + | ||
| 238 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 239 | + | ||
| 240 | + | return mock | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // MockColorSchemeResolver is an autogenerated mock type for the ColorSchemeResolver type | |
| 244 | + | type MockColorSchemeResolver struct { | |
| 245 | + | mock.Mock | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | type MockColorSchemeResolver_Expecter struct { | |
| 249 | + | mock *mock.Mock | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func (_m *MockColorSchemeResolver) EXPECT() *MockColorSchemeResolver_Expecter { | |
| 253 | + | return &MockColorSchemeResolver_Expecter{mock: &_m.Mock} | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // OnChange provides a mock function for the type MockColorSchemeResolver | |
| 257 | + | func (_mock *MockColorSchemeResolver) OnChange(callback func(port.ColorSchemePreference)) func() { | |
| 258 | + | ret := _mock.Called(callback) | |
| 259 | + | ||
| 260 | + | if len(ret) == 0 { | |
| 261 | + | panic("no return value specified for OnChange") | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | var r0 func() | |
| 265 | + | if returnFunc, ok := ret.Get(0).(func(func(port.ColorSchemePreference)) func()); ok { | |
| 266 | + | r0 = returnFunc(callback) | |
| 267 | + | } else { | |
| 268 | + | if ret.Get(0) != nil { | |
| 269 | + | r0 = ret.Get(0).(func()) | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | return r0 | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // MockColorSchemeResolver_OnChange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnChange' | |
| 276 | + | type MockColorSchemeResolver_OnChange_Call struct { | |
| 277 | + | *mock.Call | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // OnChange is a helper method to define mock.On call | |
| 281 | + | // - callback func(port.ColorSchemePreference) | |
| 282 | + | func (_e *MockColorSchemeResolver_Expecter) OnChange(callback interface{}) *MockColorSchemeResolver_OnChange_Call { | |
| 283 | + | return &MockColorSchemeResolver_OnChange_Call{Call: _e.mock.On("OnChange", callback)} | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func (_c *MockColorSchemeResolver_OnChange_Call) Run(run func(callback func(port.ColorSchemePreference))) *MockColorSchemeResolver_OnChange_Call { | |
| 287 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 288 | + | var arg0 func(port.ColorSchemePreference) | |
| 289 | + | if args[0] != nil { | |
| 290 | + | arg0 = args[0].(func(port.ColorSchemePreference)) | |
| 291 | + | } | |
| 292 | + | run( | |
| 293 | + | arg0, | |
| 294 | + | ) | |
| 295 | + | }) | |
| 296 | + | return _c | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func (_c *MockColorSchemeResolver_OnChange_Call) Return(fn func()) *MockColorSchemeResolver_OnChange_Call { | |
| 300 | + | _c.Call.Return(fn) | |
| 301 | + | return _c | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func (_c *MockColorSchemeResolver_OnChange_Call) RunAndReturn(run func(callback func(port.ColorSchemePreference)) func()) *MockColorSchemeResolver_OnChange_Call { | |
| 305 | + | _c.Call.Return(run) | |
| 306 | + | return _c | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // Refresh provides a mock function for the type MockColorSchemeResolver | |
| 310 | + | func (_mock *MockColorSchemeResolver) Refresh() port.ColorSchemePreference { | |
| 311 | + | ret := _mock.Called() | |
| 312 | + | ||
| 313 | + | if len(ret) == 0 { | |
| 314 | + | panic("no return value specified for Refresh") | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | var r0 port.ColorSchemePreference | |
| 318 | + | if returnFunc, ok := ret.Get(0).(func() port.ColorSchemePreference); ok { | |
| 319 | + | r0 = returnFunc() | |
| 320 | + | } else { | |
| 321 | + | r0 = ret.Get(0).(port.ColorSchemePreference) | |
| 322 | + | } | |
| 323 | + | return r0 | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // MockColorSchemeResolver_Refresh_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Refresh' | |
| 327 | + | type MockColorSchemeResolver_Refresh_Call struct { | |
| 328 | + | *mock.Call | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // Refresh is a helper method to define mock.On call | |
| 332 | + | func (_e *MockColorSchemeResolver_Expecter) Refresh() *MockColorSchemeResolver_Refresh_Call { | |
| 333 | + | return &MockColorSchemeResolver_Refresh_Call{Call: _e.mock.On("Refresh")} | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func (_c *MockColorSchemeResolver_Refresh_Call) Run(run func()) *MockColorSchemeResolver_Refresh_Call { | |
| 337 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 338 | + | run() | |
| 339 | + | }) | |
| 340 | + | return _c | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func (_c *MockColorSchemeResolver_Refresh_Call) Return(colorSchemePreference port.ColorSchemePreference) *MockColorSchemeResolver_Refresh_Call { | |
| 344 | + | _c.Call.Return(colorSchemePreference) | |
| 345 | + | return _c | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func (_c *MockColorSchemeResolver_Refresh_Call) RunAndReturn(run func() port.ColorSchemePreference) *MockColorSchemeResolver_Refresh_Call { | |
| 349 | + | _c.Call.Return(run) | |
| 350 | + | return _c | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | // RegisterDetector provides a mock function for the type MockColorSchemeResolver | |
| 354 | + | func (_mock *MockColorSchemeResolver) RegisterDetector(detector port.ColorSchemeDetector) { | |
| 355 | + | _mock.Called(detector) | |
| 356 | + | return | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // MockColorSchemeResolver_RegisterDetector_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterDetector' | |
| 360 | + | type MockColorSchemeResolver_RegisterDetector_Call struct { | |
| 361 | + | *mock.Call | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | // RegisterDetector is a helper method to define mock.On call | |
| 365 | + | // - detector port.ColorSchemeDetector | |
| 366 | + | func (_e *MockColorSchemeResolver_Expecter) RegisterDetector(detector interface{}) *MockColorSchemeResolver_RegisterDetector_Call { | |
| 367 | + | return &MockColorSchemeResolver_RegisterDetector_Call{Call: _e.mock.On("RegisterDetector", detector)} | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func (_c *MockColorSchemeResolver_RegisterDetector_Call) Run(run func(detector port.ColorSchemeDetector)) *MockColorSchemeResolver_RegisterDetector_Call { | |
| 371 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 372 | + | var arg0 port.ColorSchemeDetector | |
| 373 | + | if args[0] != nil { | |
| 374 | + | arg0 = args[0].(port.ColorSchemeDetector) | |
| 375 | + | } | |
| 376 | + | run( | |
| 377 | + | arg0, | |
| 378 | + | ) | |
| 379 | + | }) | |
| 380 | + | return _c | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func (_c *MockColorSchemeResolver_RegisterDetector_Call) Return() *MockColorSchemeResolver_RegisterDetector_Call { | |
| 384 | + | _c.Call.Return() | |
| 385 | + | return _c | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func (_c *MockColorSchemeResolver_RegisterDetector_Call) RunAndReturn(run func(detector port.ColorSchemeDetector)) *MockColorSchemeResolver_RegisterDetector_Call { | |
| 389 | + | _c.Run(run) | |
| 390 | + | return _c | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | // Resolve provides a mock function for the type MockColorSchemeResolver | |
| 394 | + | func (_mock *MockColorSchemeResolver) Resolve() port.ColorSchemePreference { | |
| 395 | + | ret := _mock.Called() | |
| 396 | + | ||
| 397 | + | if len(ret) == 0 { | |
| 398 | + | panic("no return value specified for Resolve") | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | var r0 port.ColorSchemePreference | |
| 402 | + | if returnFunc, ok := ret.Get(0).(func() port.ColorSchemePreference); ok { | |
| 403 | + | r0 = returnFunc() | |
| 404 | + | } else { | |
| 405 | + | r0 = ret.Get(0).(port.ColorSchemePreference) | |
| 406 | + | } | |
| 407 | + | return r0 | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | // MockColorSchemeResolver_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve' | |
| 411 | + | type MockColorSchemeResolver_Resolve_Call struct { | |
| 412 | + | *mock.Call | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | // Resolve is a helper method to define mock.On call | |
| 416 | + | func (_e *MockColorSchemeResolver_Expecter) Resolve() *MockColorSchemeResolver_Resolve_Call { | |
| 417 | + | return &MockColorSchemeResolver_Resolve_Call{Call: _e.mock.On("Resolve")} | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | func (_c *MockColorSchemeResolver_Resolve_Call) Run(run func()) *MockColorSchemeResolver_Resolve_Call { | |
| 421 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 422 | + | run() | |
| 423 | + | }) | |
| 424 | + | return _c | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func (_c *MockColorSchemeResolver_Resolve_Call) Return(colorSchemePreference port.ColorSchemePreference) *MockColorSchemeResolver_Resolve_Call { | |
| 428 | + | _c.Call.Return(colorSchemePreference) | |
| 429 | + | return _c | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | func (_c *MockColorSchemeResolver_Resolve_Call) RunAndReturn(run func() port.ColorSchemePreference) *MockColorSchemeResolver_Resolve_Call { | |
| 433 | + | _c.Call.Return(run) | |
| 434 | + | return _c | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | // NewMockConfigMigrator creates a new instance of MockConfigMigrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 438 | + | // The first argument is typically a *testing.T value. | |
| 439 | + | func NewMockConfigMigrator(t interface { | |
| 440 | + | mock.TestingT | |
| 441 | + | Cleanup(func()) | |
| 442 | + | }) *MockConfigMigrator { | |
| 443 | + | mock := &MockConfigMigrator{} | |
| 444 | + | mock.Mock.Test(t) | |
| 445 | + | ||
| 446 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 447 | + | ||
| 448 | + | return mock | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | // MockConfigMigrator is an autogenerated mock type for the ConfigMigrator type | |
| 452 | + | type MockConfigMigrator struct { | |
| 453 | + | mock.Mock | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | type MockConfigMigrator_Expecter struct { | |
| 457 | + | mock *mock.Mock | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | func (_m *MockConfigMigrator) EXPECT() *MockConfigMigrator_Expecter { | |
| 461 | + | return &MockConfigMigrator_Expecter{mock: &_m.Mock} | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | // CheckMigration provides a mock function for the type MockConfigMigrator | |
| 465 | + | func (_mock *MockConfigMigrator) CheckMigration() (*port.MigrationResult, error) { | |
| 466 | + | ret := _mock.Called() | |
| 467 | + | ||
| 468 | + | if len(ret) == 0 { | |
| 469 | + | panic("no return value specified for CheckMigration") | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | var r0 *port.MigrationResult | |
| 473 | + | var r1 error | |
| 474 | + | if returnFunc, ok := ret.Get(0).(func() (*port.MigrationResult, error)); ok { | |
| 475 | + | return returnFunc() | |
| 476 | + | } | |
| 477 | + | if returnFunc, ok := ret.Get(0).(func() *port.MigrationResult); ok { | |
| 478 | + | r0 = returnFunc() | |
| 479 | + | } else { | |
| 480 | + | if ret.Get(0) != nil { | |
| 481 | + | r0 = ret.Get(0).(*port.MigrationResult) | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 485 | + | r1 = returnFunc() | |
| 486 | + | } else { | |
| 487 | + | r1 = ret.Error(1) | |
| 488 | + | } | |
| 489 | + | return r0, r1 | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | // MockConfigMigrator_CheckMigration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckMigration' | |
| 493 | + | type MockConfigMigrator_CheckMigration_Call struct { | |
| 494 | + | *mock.Call | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | // CheckMigration is a helper method to define mock.On call | |
| 498 | + | func (_e *MockConfigMigrator_Expecter) CheckMigration() *MockConfigMigrator_CheckMigration_Call { | |
| 499 | + | return &MockConfigMigrator_CheckMigration_Call{Call: _e.mock.On("CheckMigration")} | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | func (_c *MockConfigMigrator_CheckMigration_Call) Run(run func()) *MockConfigMigrator_CheckMigration_Call { | |
| 503 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 504 | + | run() | |
| 505 | + | }) | |
| 506 | + | return _c | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | func (_c *MockConfigMigrator_CheckMigration_Call) Return(migrationResult *port.MigrationResult, err error) *MockConfigMigrator_CheckMigration_Call { | |
| 510 | + | _c.Call.Return(migrationResult, err) | |
| 511 | + | return _c | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | func (_c *MockConfigMigrator_CheckMigration_Call) RunAndReturn(run func() (*port.MigrationResult, error)) *MockConfigMigrator_CheckMigration_Call { | |
| 515 | + | _c.Call.Return(run) | |
| 516 | + | return _c | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | // DetectChanges provides a mock function for the type MockConfigMigrator | |
| 520 | + | func (_mock *MockConfigMigrator) DetectChanges() ([]port.KeyChange, error) { | |
| 521 | + | ret := _mock.Called() | |
| 522 | + | ||
| 523 | + | if len(ret) == 0 { | |
| 524 | + | panic("no return value specified for DetectChanges") | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | var r0 []port.KeyChange | |
| 528 | + | var r1 error | |
| 529 | + | if returnFunc, ok := ret.Get(0).(func() ([]port.KeyChange, error)); ok { | |
| 530 | + | return returnFunc() | |
| 531 | + | } | |
| 532 | + | if returnFunc, ok := ret.Get(0).(func() []port.KeyChange); ok { | |
| 533 | + | r0 = returnFunc() | |
| 534 | + | } else { | |
| 535 | + | if ret.Get(0) != nil { | |
| 536 | + | r0 = ret.Get(0).([]port.KeyChange) | |
| 537 | + | } | |
| 538 | + | } | |
| 539 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 540 | + | r1 = returnFunc() | |
| 541 | + | } else { | |
| 542 | + | r1 = ret.Error(1) | |
| 543 | + | } | |
| 544 | + | return r0, r1 | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | // MockConfigMigrator_DetectChanges_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetectChanges' | |
| 548 | + | type MockConfigMigrator_DetectChanges_Call struct { | |
| 549 | + | *mock.Call | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | // DetectChanges is a helper method to define mock.On call | |
| 553 | + | func (_e *MockConfigMigrator_Expecter) DetectChanges() *MockConfigMigrator_DetectChanges_Call { | |
| 554 | + | return &MockConfigMigrator_DetectChanges_Call{Call: _e.mock.On("DetectChanges")} | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | func (_c *MockConfigMigrator_DetectChanges_Call) Run(run func()) *MockConfigMigrator_DetectChanges_Call { | |
| 558 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 559 | + | run() | |
| 560 | + | }) | |
| 561 | + | return _c | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | func (_c *MockConfigMigrator_DetectChanges_Call) Return(keyChanges []port.KeyChange, err error) *MockConfigMigrator_DetectChanges_Call { | |
| 565 | + | _c.Call.Return(keyChanges, err) | |
| 566 | + | return _c | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | func (_c *MockConfigMigrator_DetectChanges_Call) RunAndReturn(run func() ([]port.KeyChange, error)) *MockConfigMigrator_DetectChanges_Call { | |
| 570 | + | _c.Call.Return(run) | |
| 571 | + | return _c | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | // GetConfigFile provides a mock function for the type MockConfigMigrator | |
| 575 | + | func (_mock *MockConfigMigrator) GetConfigFile() (string, error) { | |
| 576 | + | ret := _mock.Called() | |
| 577 | + | ||
| 578 | + | if len(ret) == 0 { | |
| 579 | + | panic("no return value specified for GetConfigFile") | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | var r0 string | |
| 583 | + | var r1 error | |
| 584 | + | if returnFunc, ok := ret.Get(0).(func() (string, error)); ok { | |
| 585 | + | return returnFunc() | |
| 586 | + | } | |
| 587 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |
| 588 | + | r0 = returnFunc() | |
| 589 | + | } else { | |
| 590 | + | r0 = ret.Get(0).(string) | |
| 591 | + | } | |
| 592 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 593 | + | r1 = returnFunc() | |
| 594 | + | } else { | |
| 595 | + | r1 = ret.Error(1) | |
| 596 | + | } | |
| 597 | + | return r0, r1 | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | // MockConfigMigrator_GetConfigFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetConfigFile' | |
| 601 | + | type MockConfigMigrator_GetConfigFile_Call struct { | |
| 602 | + | *mock.Call | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | // GetConfigFile is a helper method to define mock.On call | |
| 606 | + | func (_e *MockConfigMigrator_Expecter) GetConfigFile() *MockConfigMigrator_GetConfigFile_Call { | |
| 607 | + | return &MockConfigMigrator_GetConfigFile_Call{Call: _e.mock.On("GetConfigFile")} | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | func (_c *MockConfigMigrator_GetConfigFile_Call) Run(run func()) *MockConfigMigrator_GetConfigFile_Call { | |
| 611 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 612 | + | run() | |
| 613 | + | }) | |
| 614 | + | return _c | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | func (_c *MockConfigMigrator_GetConfigFile_Call) Return(s string, err error) *MockConfigMigrator_GetConfigFile_Call { | |
| 618 | + | _c.Call.Return(s, err) | |
| 619 | + | return _c | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | func (_c *MockConfigMigrator_GetConfigFile_Call) RunAndReturn(run func() (string, error)) *MockConfigMigrator_GetConfigFile_Call { | |
| 623 | + | _c.Call.Return(run) | |
| 624 | + | return _c | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | // GetKeyInfo provides a mock function for the type MockConfigMigrator | |
| 628 | + | func (_mock *MockConfigMigrator) GetKeyInfo(key string) port.KeyInfo { | |
| 629 | + | ret := _mock.Called(key) | |
| 630 | + | ||
| 631 | + | if len(ret) == 0 { | |
| 632 | + | panic("no return value specified for GetKeyInfo") | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | var r0 port.KeyInfo | |
| 636 | + | if returnFunc, ok := ret.Get(0).(func(string) port.KeyInfo); ok { | |
| 637 | + | r0 = returnFunc(key) | |
| 638 | + | } else { | |
| 639 | + | r0 = ret.Get(0).(port.KeyInfo) | |
| 640 | + | } | |
| 641 | + | return r0 | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | // MockConfigMigrator_GetKeyInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetKeyInfo' | |
| 645 | + | type MockConfigMigrator_GetKeyInfo_Call struct { | |
| 646 | + | *mock.Call | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | // GetKeyInfo is a helper method to define mock.On call | |
| 650 | + | // - key string | |
| 651 | + | func (_e *MockConfigMigrator_Expecter) GetKeyInfo(key interface{}) *MockConfigMigrator_GetKeyInfo_Call { | |
| 652 | + | return &MockConfigMigrator_GetKeyInfo_Call{Call: _e.mock.On("GetKeyInfo", key)} | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | func (_c *MockConfigMigrator_GetKeyInfo_Call) Run(run func(key string)) *MockConfigMigrator_GetKeyInfo_Call { | |
| 656 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 657 | + | var arg0 string | |
| 658 | + | if args[0] != nil { | |
| 659 | + | arg0 = args[0].(string) | |
| 660 | + | } | |
| 661 | + | run( | |
| 662 | + | arg0, | |
| 663 | + | ) | |
| 664 | + | }) | |
| 665 | + | return _c | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | func (_c *MockConfigMigrator_GetKeyInfo_Call) Return(keyInfo port.KeyInfo) *MockConfigMigrator_GetKeyInfo_Call { | |
| 669 | + | _c.Call.Return(keyInfo) | |
| 670 | + | return _c | |
| 671 | + | } | |
| 672 | + | ||
| 673 | + | func (_c *MockConfigMigrator_GetKeyInfo_Call) RunAndReturn(run func(key string) port.KeyInfo) *MockConfigMigrator_GetKeyInfo_Call { | |
| 674 | + | _c.Call.Return(run) | |
| 675 | + | return _c | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | // Migrate provides a mock function for the type MockConfigMigrator | |
| 679 | + | func (_mock *MockConfigMigrator) Migrate() ([]string, error) { | |
| 680 | + | ret := _mock.Called() | |
| 681 | + | ||
| 682 | + | if len(ret) == 0 { | |
| 683 | + | panic("no return value specified for Migrate") | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | var r0 []string | |
| 687 | + | var r1 error | |
| 688 | + | if returnFunc, ok := ret.Get(0).(func() ([]string, error)); ok { | |
| 689 | + | return returnFunc() | |
| 690 | + | } | |
| 691 | + | if returnFunc, ok := ret.Get(0).(func() []string); ok { | |
| 692 | + | r0 = returnFunc() | |
| 693 | + | } else { | |
| 694 | + | if ret.Get(0) != nil { | |
| 695 | + | r0 = ret.Get(0).([]string) | |
| 696 | + | } | |
| 697 | + | } | |
| 698 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 699 | + | r1 = returnFunc() | |
| 700 | + | } else { | |
| 701 | + | r1 = ret.Error(1) | |
| 702 | + | } | |
| 703 | + | return r0, r1 | |
| 704 | + | } | |
| 705 | + | ||
| 706 | + | // MockConfigMigrator_Migrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Migrate' | |
| 707 | + | type MockConfigMigrator_Migrate_Call struct { | |
| 708 | + | *mock.Call | |
| 709 | + | } | |
| 710 | + | ||
| 711 | + | // Migrate is a helper method to define mock.On call | |
| 712 | + | func (_e *MockConfigMigrator_Expecter) Migrate() *MockConfigMigrator_Migrate_Call { | |
| 713 | + | return &MockConfigMigrator_Migrate_Call{Call: _e.mock.On("Migrate")} | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | func (_c *MockConfigMigrator_Migrate_Call) Run(run func()) *MockConfigMigrator_Migrate_Call { | |
| 717 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 718 | + | run() | |
| 719 | + | }) | |
| 720 | + | return _c | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | func (_c *MockConfigMigrator_Migrate_Call) Return(strings []string, err error) *MockConfigMigrator_Migrate_Call { | |
| 724 | + | _c.Call.Return(strings, err) | |
| 725 | + | return _c | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | func (_c *MockConfigMigrator_Migrate_Call) RunAndReturn(run func() ([]string, error)) *MockConfigMigrator_Migrate_Call { | |
| 729 | + | _c.Call.Return(run) | |
| 730 | + | return _c | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | // NewMockDiffFormatter creates a new instance of MockDiffFormatter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 734 | + | // The first argument is typically a *testing.T value. | |
| 735 | + | func NewMockDiffFormatter(t interface { | |
| 736 | + | mock.TestingT | |
| 737 | + | Cleanup(func()) | |
| 738 | + | }) *MockDiffFormatter { | |
| 739 | + | mock := &MockDiffFormatter{} | |
| 740 | + | mock.Mock.Test(t) | |
| 741 | + | ||
| 742 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 743 | + | ||
| 744 | + | return mock | |
| 745 | + | } | |
| 746 | + | ||
| 747 | + | // MockDiffFormatter is an autogenerated mock type for the DiffFormatter type | |
| 748 | + | type MockDiffFormatter struct { | |
| 749 | + | mock.Mock | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | type MockDiffFormatter_Expecter struct { | |
| 753 | + | mock *mock.Mock | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | func (_m *MockDiffFormatter) EXPECT() *MockDiffFormatter_Expecter { | |
| 757 | + | return &MockDiffFormatter_Expecter{mock: &_m.Mock} | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | // FormatChangesAsDiff provides a mock function for the type MockDiffFormatter | |
| 761 | + | func (_mock *MockDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string { | |
| 762 | + | ret := _mock.Called(changes) | |
| 763 | + | ||
| 764 | + | if len(ret) == 0 { | |
| 765 | + | panic("no return value specified for FormatChangesAsDiff") | |
| 766 | + | } | |
| 767 | + | ||
| 768 | + | var r0 string | |
| 769 | + | if returnFunc, ok := ret.Get(0).(func([]port.KeyChange) string); ok { | |
| 770 | + | r0 = returnFunc(changes) | |
| 771 | + | } else { | |
| 772 | + | r0 = ret.Get(0).(string) | |
| 773 | + | } | |
| 774 | + | return r0 | |
| 775 | + | } | |
| 776 | + | ||
| 777 | + | // MockDiffFormatter_FormatChangesAsDiff_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FormatChangesAsDiff' | |
| 778 | + | type MockDiffFormatter_FormatChangesAsDiff_Call struct { | |
| 779 | + | *mock.Call | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | // FormatChangesAsDiff is a helper method to define mock.On call | |
| 783 | + | // - changes []port.KeyChange | |
| 784 | + | func (_e *MockDiffFormatter_Expecter) FormatChangesAsDiff(changes interface{}) *MockDiffFormatter_FormatChangesAsDiff_Call { | |
| 785 | + | return &MockDiffFormatter_FormatChangesAsDiff_Call{Call: _e.mock.On("FormatChangesAsDiff", changes)} | |
| 786 | + | } | |
| 787 | + | ||
| 788 | + | func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Run(run func(changes []port.KeyChange)) *MockDiffFormatter_FormatChangesAsDiff_Call { | |
| 789 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 790 | + | var arg0 []port.KeyChange | |
| 791 | + | if args[0] != nil { | |
| 792 | + | arg0 = args[0].([]port.KeyChange) | |
| 793 | + | } | |
| 794 | + | run( | |
| 795 | + | arg0, | |
| 796 | + | ) | |
| 797 | + | }) | |
| 798 | + | return _c | |
| 799 | + | } | |
| 800 | + | ||
| 801 | + | func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) Return(s string) *MockDiffFormatter_FormatChangesAsDiff_Call { | |
| 802 | + | _c.Call.Return(s) | |
| 803 | + | return _c | |
| 804 | + | } | |
| 805 | + | ||
| 806 | + | func (_c *MockDiffFormatter_FormatChangesAsDiff_Call) RunAndReturn(run func(changes []port.KeyChange) string) *MockDiffFormatter_FormatChangesAsDiff_Call { | |
| 807 | + | _c.Call.Return(run) | |
| 808 | + | return _c | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | // NewMockConfigSchemaProvider creates a new instance of MockConfigSchemaProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 812 | + | // The first argument is typically a *testing.T value. | |
| 813 | + | func NewMockConfigSchemaProvider(t interface { | |
| 814 | + | mock.TestingT | |
| 815 | + | Cleanup(func()) | |
| 816 | + | }) *MockConfigSchemaProvider { | |
| 817 | + | mock := &MockConfigSchemaProvider{} | |
| 818 | + | mock.Mock.Test(t) | |
| 819 | + | ||
| 820 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 821 | + | ||
| 822 | + | return mock | |
| 823 | + | } | |
| 824 | + | ||
| 825 | + | // MockConfigSchemaProvider is an autogenerated mock type for the ConfigSchemaProvider type | |
| 826 | + | type MockConfigSchemaProvider struct { | |
| 827 | + | mock.Mock | |
| 828 | + | } | |
| 829 | + | ||
| 830 | + | type MockConfigSchemaProvider_Expecter struct { | |
| 831 | + | mock *mock.Mock | |
| 832 | + | } | |
| 833 | + | ||
| 834 | + | func (_m *MockConfigSchemaProvider) EXPECT() *MockConfigSchemaProvider_Expecter { | |
| 835 | + | return &MockConfigSchemaProvider_Expecter{mock: &_m.Mock} | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | // GetSchema provides a mock function for the type MockConfigSchemaProvider | |
| 839 | + | func (_mock *MockConfigSchemaProvider) GetSchema() []entity.ConfigKeyInfo { | |
| 840 | + | ret := _mock.Called() | |
| 841 | + | ||
| 842 | + | if len(ret) == 0 { | |
| 843 | + | panic("no return value specified for GetSchema") | |
| 844 | + | } | |
| 845 | + | ||
| 846 | + | var r0 []entity.ConfigKeyInfo | |
| 847 | + | if returnFunc, ok := ret.Get(0).(func() []entity.ConfigKeyInfo); ok { | |
| 848 | + | r0 = returnFunc() | |
| 849 | + | } else { | |
| 850 | + | if ret.Get(0) != nil { | |
| 851 | + | r0 = ret.Get(0).([]entity.ConfigKeyInfo) | |
| 852 | + | } | |
| 853 | + | } | |
| 854 | + | return r0 | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | // MockConfigSchemaProvider_GetSchema_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSchema' | |
| 858 | + | type MockConfigSchemaProvider_GetSchema_Call struct { | |
| 859 | + | *mock.Call | |
| 860 | + | } | |
| 861 | + | ||
| 862 | + | // GetSchema is a helper method to define mock.On call | |
| 863 | + | func (_e *MockConfigSchemaProvider_Expecter) GetSchema() *MockConfigSchemaProvider_GetSchema_Call { | |
| 864 | + | return &MockConfigSchemaProvider_GetSchema_Call{Call: _e.mock.On("GetSchema")} | |
| 865 | + | } | |
| 866 | + | ||
| 867 | + | func (_c *MockConfigSchemaProvider_GetSchema_Call) Run(run func()) *MockConfigSchemaProvider_GetSchema_Call { | |
| 868 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 869 | + | run() | |
| 870 | + | }) | |
| 871 | + | return _c | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | func (_c *MockConfigSchemaProvider_GetSchema_Call) Return(configKeyInfos []entity.ConfigKeyInfo) *MockConfigSchemaProvider_GetSchema_Call { | |
| 875 | + | _c.Call.Return(configKeyInfos) | |
| 876 | + | return _c | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | func (_c *MockConfigSchemaProvider_GetSchema_Call) RunAndReturn(run func() []entity.ConfigKeyInfo) *MockConfigSchemaProvider_GetSchema_Call { | |
| 880 | + | _c.Call.Return(run) | |
| 881 | + | return _c | |
| 882 | + | } | |
| 883 | + | ||
| 884 | + | // NewMockDatabaseProvider creates a new instance of MockDatabaseProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 885 | + | // The first argument is typically a *testing.T value. | |
| 886 | + | func NewMockDatabaseProvider(t interface { | |
| 887 | + | mock.TestingT | |
| 888 | + | Cleanup(func()) | |
| 889 | + | }) *MockDatabaseProvider { | |
| 890 | + | mock := &MockDatabaseProvider{} | |
| 891 | + | mock.Mock.Test(t) | |
| 892 | + | ||
| 893 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 894 | + | ||
| 895 | + | return mock | |
| 896 | + | } | |
| 897 | + | ||
| 898 | + | // MockDatabaseProvider is an autogenerated mock type for the DatabaseProvider type | |
| 899 | + | type MockDatabaseProvider struct { | |
| 900 | + | mock.Mock | |
| 901 | + | } | |
| 902 | + | ||
| 903 | + | type MockDatabaseProvider_Expecter struct { | |
| 904 | + | mock *mock.Mock | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | func (_m *MockDatabaseProvider) EXPECT() *MockDatabaseProvider_Expecter { | |
| 908 | + | return &MockDatabaseProvider_Expecter{mock: &_m.Mock} | |
| 909 | + | } | |
| 910 | + | ||
| 911 | + | // Close provides a mock function for the type MockDatabaseProvider | |
| 912 | + | func (_mock *MockDatabaseProvider) Close() error { | |
| 913 | + | ret := _mock.Called() | |
| 914 | + | ||
| 915 | + | if len(ret) == 0 { | |
| 916 | + | panic("no return value specified for Close") | |
| 917 | + | } | |
| 918 | + | ||
| 919 | + | var r0 error | |
| 920 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 921 | + | r0 = returnFunc() | |
| 922 | + | } else { | |
| 923 | + | r0 = ret.Error(0) | |
| 924 | + | } | |
| 925 | + | return r0 | |
| 926 | + | } | |
| 927 | + | ||
| 928 | + | // MockDatabaseProvider_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' | |
| 929 | + | type MockDatabaseProvider_Close_Call struct { | |
| 930 | + | *mock.Call | |
| 931 | + | } | |
| 932 | + | ||
| 933 | + | // Close is a helper method to define mock.On call | |
| 934 | + | func (_e *MockDatabaseProvider_Expecter) Close() *MockDatabaseProvider_Close_Call { | |
| 935 | + | return &MockDatabaseProvider_Close_Call{Call: _e.mock.On("Close")} | |
| 936 | + | } | |
| 937 | + | ||
| 938 | + | func (_c *MockDatabaseProvider_Close_Call) Run(run func()) *MockDatabaseProvider_Close_Call { | |
| 939 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 940 | + | run() | |
| 941 | + | }) | |
| 942 | + | return _c | |
| 943 | + | } | |
| 944 | + | ||
| 945 | + | func (_c *MockDatabaseProvider_Close_Call) Return(err error) *MockDatabaseProvider_Close_Call { | |
| 946 | + | _c.Call.Return(err) | |
| 947 | + | return _c | |
| 948 | + | } | |
| 949 | + | ||
| 950 | + | func (_c *MockDatabaseProvider_Close_Call) RunAndReturn(run func() error) *MockDatabaseProvider_Close_Call { | |
| 951 | + | _c.Call.Return(run) | |
| 952 | + | return _c | |
| 953 | + | } | |
| 954 | + | ||
| 955 | + | // DB provides a mock function for the type MockDatabaseProvider | |
| 956 | + | func (_mock *MockDatabaseProvider) DB(ctx context.Context) (*sql.DB, error) { | |
| 957 | + | ret := _mock.Called(ctx) | |
| 958 | + | ||
| 959 | + | if len(ret) == 0 { | |
| 960 | + | panic("no return value specified for DB") | |
| 961 | + | } | |
| 962 | + | ||
| 963 | + | var r0 *sql.DB | |
| 964 | + | var r1 error | |
| 965 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (*sql.DB, error)); ok { | |
| 966 | + | return returnFunc(ctx) | |
| 967 | + | } | |
| 968 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) *sql.DB); ok { | |
| 969 | + | r0 = returnFunc(ctx) | |
| 970 | + | } else { | |
| 971 | + | if ret.Get(0) != nil { | |
| 972 | + | r0 = ret.Get(0).(*sql.DB) | |
| 973 | + | } | |
| 974 | + | } | |
| 975 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 976 | + | r1 = returnFunc(ctx) | |
| 977 | + | } else { | |
| 978 | + | r1 = ret.Error(1) | |
| 979 | + | } | |
| 980 | + | return r0, r1 | |
| 981 | + | } | |
| 982 | + | ||
| 983 | + | // MockDatabaseProvider_DB_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DB' | |
| 984 | + | type MockDatabaseProvider_DB_Call struct { | |
| 985 | + | *mock.Call | |
| 986 | + | } | |
| 987 | + | ||
| 988 | + | // DB is a helper method to define mock.On call | |
| 989 | + | // - ctx context.Context | |
| 990 | + | func (_e *MockDatabaseProvider_Expecter) DB(ctx interface{}) *MockDatabaseProvider_DB_Call { | |
| 991 | + | return &MockDatabaseProvider_DB_Call{Call: _e.mock.On("DB", ctx)} | |
| 992 | + | } | |
| 993 | + | ||
| 994 | + | func (_c *MockDatabaseProvider_DB_Call) Run(run func(ctx context.Context)) *MockDatabaseProvider_DB_Call { | |
| 995 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 996 | + | var arg0 context.Context | |
| 997 | + | if args[0] != nil { | |
| 998 | + | arg0 = args[0].(context.Context) | |
| 999 | + | } | |
| 1000 | + | run( | |
| 1001 | + | arg0, | |
| 1002 | + | ) | |
| 1003 | + | }) | |
| 1004 | + | return _c | |
| 1005 | + | } | |
| 1006 | + | ||
| 1007 | + | func (_c *MockDatabaseProvider_DB_Call) Return(dB *sql.DB, err error) *MockDatabaseProvider_DB_Call { | |
| 1008 | + | _c.Call.Return(dB, err) | |
| 1009 | + | return _c | |
| 1010 | + | } | |
| 1011 | + | ||
| 1012 | + | func (_c *MockDatabaseProvider_DB_Call) RunAndReturn(run func(ctx context.Context) (*sql.DB, error)) *MockDatabaseProvider_DB_Call { | |
| 1013 | + | _c.Call.Return(run) | |
| 1014 | + | return _c | |
| 1015 | + | } | |
| 1016 | + | ||
| 1017 | + | // IsInitialized provides a mock function for the type MockDatabaseProvider | |
| 1018 | + | func (_mock *MockDatabaseProvider) IsInitialized() bool { | |
| 1019 | + | ret := _mock.Called() | |
| 1020 | + | ||
| 1021 | + | if len(ret) == 0 { | |
| 1022 | + | panic("no return value specified for IsInitialized") | |
| 1023 | + | } | |
| 1024 | + | ||
| 1025 | + | var r0 bool | |
| 1026 | + | if returnFunc, ok := ret.Get(0).(func() bool); ok { | |
| 1027 | + | r0 = returnFunc() | |
| 1028 | + | } else { | |
| 1029 | + | r0 = ret.Get(0).(bool) | |
| 1030 | + | } | |
| 1031 | + | return r0 | |
| 1032 | + | } | |
| 1033 | + | ||
| 1034 | + | // MockDatabaseProvider_IsInitialized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsInitialized' | |
| 1035 | + | type MockDatabaseProvider_IsInitialized_Call struct { | |
| 1036 | + | *mock.Call | |
| 1037 | + | } | |
| 1038 | + | ||
| 1039 | + | // IsInitialized is a helper method to define mock.On call | |
| 1040 | + | func (_e *MockDatabaseProvider_Expecter) IsInitialized() *MockDatabaseProvider_IsInitialized_Call { | |
| 1041 | + | return &MockDatabaseProvider_IsInitialized_Call{Call: _e.mock.On("IsInitialized")} | |
| 1042 | + | } | |
| 1043 | + | ||
| 1044 | + | func (_c *MockDatabaseProvider_IsInitialized_Call) Run(run func()) *MockDatabaseProvider_IsInitialized_Call { | |
| 1045 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1046 | + | run() | |
| 1047 | + | }) | |
| 1048 | + | return _c | |
| 1049 | + | } | |
| 1050 | + | ||
| 1051 | + | func (_c *MockDatabaseProvider_IsInitialized_Call) Return(b bool) *MockDatabaseProvider_IsInitialized_Call { | |
| 1052 | + | _c.Call.Return(b) | |
| 1053 | + | return _c | |
| 1054 | + | } | |
| 1055 | + | ||
| 1056 | + | func (_c *MockDatabaseProvider_IsInitialized_Call) RunAndReturn(run func() bool) *MockDatabaseProvider_IsInitialized_Call { | |
| 1057 | + | _c.Call.Return(run) | |
| 1058 | + | return _c | |
| 1059 | + | } | |
| 1060 | + | ||
| 1061 | + | // NewMockHardwareSurveyor creates a new instance of MockHardwareSurveyor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1062 | + | // The first argument is typically a *testing.T value. | |
| 1063 | + | func NewMockHardwareSurveyor(t interface { | |
| 1064 | + | mock.TestingT | |
| 1065 | + | Cleanup(func()) | |
| 1066 | + | }) *MockHardwareSurveyor { | |
| 1067 | + | mock := &MockHardwareSurveyor{} | |
| 1068 | + | mock.Mock.Test(t) | |
| 1069 | + | ||
| 1070 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1071 | + | ||
| 1072 | + | return mock | |
| 1073 | + | } | |
| 1074 | + | ||
| 1075 | + | // MockHardwareSurveyor is an autogenerated mock type for the HardwareSurveyor type | |
| 1076 | + | type MockHardwareSurveyor struct { | |
| 1077 | + | mock.Mock | |
| 1078 | + | } | |
| 1079 | + | ||
| 1080 | + | type MockHardwareSurveyor_Expecter struct { | |
| 1081 | + | mock *mock.Mock | |
| 1082 | + | } | |
| 1083 | + | ||
| 1084 | + | func (_m *MockHardwareSurveyor) EXPECT() *MockHardwareSurveyor_Expecter { | |
| 1085 | + | return &MockHardwareSurveyor_Expecter{mock: &_m.Mock} | |
| 1086 | + | } | |
| 1087 | + | ||
| 1088 | + | // Survey provides a mock function for the type MockHardwareSurveyor | |
| 1089 | + | func (_mock *MockHardwareSurveyor) Survey(ctx context.Context) port.HardwareInfo { | |
| 1090 | + | ret := _mock.Called(ctx) | |
| 1091 | + | ||
| 1092 | + | if len(ret) == 0 { | |
| 1093 | + | panic("no return value specified for Survey") | |
| 1094 | + | } | |
| 1095 | + | ||
| 1096 | + | var r0 port.HardwareInfo | |
| 1097 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) port.HardwareInfo); ok { | |
| 1098 | + | r0 = returnFunc(ctx) | |
| 1099 | + | } else { | |
| 1100 | + | r0 = ret.Get(0).(port.HardwareInfo) | |
| 1101 | + | } | |
| 1102 | + | return r0 | |
| 1103 | + | } | |
| 1104 | + | ||
| 1105 | + | // MockHardwareSurveyor_Survey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Survey' | |
| 1106 | + | type MockHardwareSurveyor_Survey_Call struct { | |
| 1107 | + | *mock.Call | |
| 1108 | + | } | |
| 1109 | + | ||
| 1110 | + | // Survey is a helper method to define mock.On call | |
| 1111 | + | // - ctx context.Context | |
| 1112 | + | func (_e *MockHardwareSurveyor_Expecter) Survey(ctx interface{}) *MockHardwareSurveyor_Survey_Call { | |
| 1113 | + | return &MockHardwareSurveyor_Survey_Call{Call: _e.mock.On("Survey", ctx)} | |
| 1114 | + | } | |
| 1115 | + | ||
| 1116 | + | func (_c *MockHardwareSurveyor_Survey_Call) Run(run func(ctx context.Context)) *MockHardwareSurveyor_Survey_Call { | |
| 1117 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1118 | + | var arg0 context.Context | |
| 1119 | + | if args[0] != nil { | |
| 1120 | + | arg0 = args[0].(context.Context) | |
| 1121 | + | } | |
| 1122 | + | run( | |
| 1123 | + | arg0, | |
| 1124 | + | ) | |
| 1125 | + | }) | |
| 1126 | + | return _c | |
| 1127 | + | } | |
| 1128 | + | ||
| 1129 | + | func (_c *MockHardwareSurveyor_Survey_Call) Return(hardwareInfo port.HardwareInfo) *MockHardwareSurveyor_Survey_Call { | |
| 1130 | + | _c.Call.Return(hardwareInfo) | |
| 1131 | + | return _c | |
| 1132 | + | } | |
| 1133 | + | ||
| 1134 | + | func (_c *MockHardwareSurveyor_Survey_Call) RunAndReturn(run func(ctx context.Context) port.HardwareInfo) *MockHardwareSurveyor_Survey_Call { | |
| 1135 | + | _c.Call.Return(run) | |
| 1136 | + | return _c | |
| 1137 | + | } | |
| 1138 | + | ||
| 16 | 1139 | // NewMockSessionLogger creates a new instance of MockSessionLogger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 17 | 1140 | // The first argument is typically a *testing.T value. | |
| 18 | - | func NewMockSessionLogger(t interface { | |
| 1141 | + | func NewMockSessionLogger(t interface { | |
| 1142 | + | mock.TestingT | |
| 1143 | + | Cleanup(func()) | |
| 1144 | + | }) *MockSessionLogger { | |
| 1145 | + | mock := &MockSessionLogger{} | |
| 1146 | + | mock.Mock.Test(t) | |
| 1147 | + | ||
| 1148 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1149 | + | ||
| 1150 | + | return mock | |
| 1151 | + | } | |
| 1152 | + | ||
| 1153 | + | // MockSessionLogger is an autogenerated mock type for the SessionLogger type | |
| 1154 | + | type MockSessionLogger struct { | |
| 1155 | + | mock.Mock | |
| 1156 | + | } | |
| 1157 | + | ||
| 1158 | + | type MockSessionLogger_Expecter struct { | |
| 1159 | + | mock *mock.Mock | |
| 1160 | + | } | |
| 1161 | + | ||
| 1162 | + | func (_m *MockSessionLogger) EXPECT() *MockSessionLogger_Expecter { | |
| 1163 | + | return &MockSessionLogger_Expecter{mock: &_m.Mock} | |
| 1164 | + | } | |
| 1165 | + | ||
| 1166 | + | // CreateLogger provides a mock function for the type MockSessionLogger | |
| 1167 | + | func (_mock *MockSessionLogger) CreateLogger(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig) (zerolog.Logger, func(), error) { | |
| 1168 | + | ret := _mock.Called(ctx, sessionID, cfg) | |
| 1169 | + | ||
| 1170 | + | if len(ret) == 0 { | |
| 1171 | + | panic("no return value specified for CreateLogger") | |
| 1172 | + | } | |
| 1173 | + | ||
| 1174 | + | var r0 zerolog.Logger | |
| 1175 | + | var r1 func() | |
| 1176 | + | var r2 error | |
| 1177 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, port.SessionLogConfig) (zerolog.Logger, func(), error)); ok { | |
| 1178 | + | return returnFunc(ctx, sessionID, cfg) | |
| 1179 | + | } | |
| 1180 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID, port.SessionLogConfig) zerolog.Logger); ok { | |
| 1181 | + | r0 = returnFunc(ctx, sessionID, cfg) | |
| 1182 | + | } else { | |
| 1183 | + | r0 = ret.Get(0).(zerolog.Logger) | |
| 1184 | + | } | |
| 1185 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID, port.SessionLogConfig) func()); ok { | |
| 1186 | + | r1 = returnFunc(ctx, sessionID, cfg) | |
| 1187 | + | } else { | |
| 1188 | + | if ret.Get(1) != nil { | |
| 1189 | + | r1 = ret.Get(1).(func()) | |
| 1190 | + | } | |
| 1191 | + | } | |
| 1192 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, entity.SessionID, port.SessionLogConfig) error); ok { | |
| 1193 | + | r2 = returnFunc(ctx, sessionID, cfg) | |
| 1194 | + | } else { | |
| 1195 | + | r2 = ret.Error(2) | |
| 1196 | + | } | |
| 1197 | + | return r0, r1, r2 | |
| 1198 | + | } | |
| 1199 | + | ||
| 1200 | + | // MockSessionLogger_CreateLogger_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateLogger' | |
| 1201 | + | type MockSessionLogger_CreateLogger_Call struct { | |
| 1202 | + | *mock.Call | |
| 1203 | + | } | |
| 1204 | + | ||
| 1205 | + | // CreateLogger is a helper method to define mock.On call | |
| 1206 | + | // - ctx context.Context | |
| 1207 | + | // - sessionID entity.SessionID | |
| 1208 | + | // - cfg port.SessionLogConfig | |
| 1209 | + | func (_e *MockSessionLogger_Expecter) CreateLogger(ctx interface{}, sessionID interface{}, cfg interface{}) *MockSessionLogger_CreateLogger_Call { | |
| 1210 | + | return &MockSessionLogger_CreateLogger_Call{Call: _e.mock.On("CreateLogger", ctx, sessionID, cfg)} | |
| 1211 | + | } | |
| 1212 | + | ||
| 1213 | + | func (_c *MockSessionLogger_CreateLogger_Call) Run(run func(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig)) *MockSessionLogger_CreateLogger_Call { | |
| 1214 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1215 | + | var arg0 context.Context | |
| 1216 | + | if args[0] != nil { | |
| 1217 | + | arg0 = args[0].(context.Context) | |
| 1218 | + | } | |
| 1219 | + | var arg1 entity.SessionID | |
| 1220 | + | if args[1] != nil { | |
| 1221 | + | arg1 = args[1].(entity.SessionID) | |
| 1222 | + | } | |
| 1223 | + | var arg2 port.SessionLogConfig | |
| 1224 | + | if args[2] != nil { | |
| 1225 | + | arg2 = args[2].(port.SessionLogConfig) | |
| 1226 | + | } | |
| 1227 | + | run( | |
| 1228 | + | arg0, | |
| 1229 | + | arg1, | |
| 1230 | + | arg2, | |
| 1231 | + | ) | |
| 1232 | + | }) | |
| 1233 | + | return _c | |
| 1234 | + | } | |
| 1235 | + | ||
| 1236 | + | func (_c *MockSessionLogger_CreateLogger_Call) Return(logger zerolog.Logger, fn func(), err error) *MockSessionLogger_CreateLogger_Call { | |
| 1237 | + | _c.Call.Return(logger, fn, err) | |
| 1238 | + | return _c | |
| 1239 | + | } | |
| 1240 | + | ||
| 1241 | + | func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig) (zerolog.Logger, func(), error)) *MockSessionLogger_CreateLogger_Call { | |
| 1242 | + | _c.Call.Return(run) | |
| 1243 | + | return _c | |
| 1244 | + | } | |
| 1245 | + | ||
| 1246 | + | // NewMockTabListProvider creates a new instance of MockTabListProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1247 | + | // The first argument is typically a *testing.T value. | |
| 1248 | + | func NewMockTabListProvider(t interface { | |
| 1249 | + | mock.TestingT | |
| 1250 | + | Cleanup(func()) | |
| 1251 | + | }) *MockTabListProvider { | |
| 1252 | + | mock := &MockTabListProvider{} | |
| 1253 | + | mock.Mock.Test(t) | |
| 1254 | + | ||
| 1255 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1256 | + | ||
| 1257 | + | return mock | |
| 1258 | + | } | |
| 1259 | + | ||
| 1260 | + | // MockTabListProvider is an autogenerated mock type for the TabListProvider type | |
| 1261 | + | type MockTabListProvider struct { | |
| 1262 | + | mock.Mock | |
| 1263 | + | } | |
| 1264 | + | ||
| 1265 | + | type MockTabListProvider_Expecter struct { | |
| 1266 | + | mock *mock.Mock | |
| 1267 | + | } | |
| 1268 | + | ||
| 1269 | + | func (_m *MockTabListProvider) EXPECT() *MockTabListProvider_Expecter { | |
| 1270 | + | return &MockTabListProvider_Expecter{mock: &_m.Mock} | |
| 1271 | + | } | |
| 1272 | + | ||
| 1273 | + | // GetSessionID provides a mock function for the type MockTabListProvider | |
| 1274 | + | func (_mock *MockTabListProvider) GetSessionID() entity.SessionID { | |
| 1275 | + | ret := _mock.Called() | |
| 1276 | + | ||
| 1277 | + | if len(ret) == 0 { | |
| 1278 | + | panic("no return value specified for GetSessionID") | |
| 1279 | + | } | |
| 1280 | + | ||
| 1281 | + | var r0 entity.SessionID | |
| 1282 | + | if returnFunc, ok := ret.Get(0).(func() entity.SessionID); ok { | |
| 1283 | + | r0 = returnFunc() | |
| 1284 | + | } else { | |
| 1285 | + | r0 = ret.Get(0).(entity.SessionID) | |
| 1286 | + | } | |
| 1287 | + | return r0 | |
| 1288 | + | } | |
| 1289 | + | ||
| 1290 | + | // MockTabListProvider_GetSessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSessionID' | |
| 1291 | + | type MockTabListProvider_GetSessionID_Call struct { | |
| 1292 | + | *mock.Call | |
| 1293 | + | } | |
| 1294 | + | ||
| 1295 | + | // GetSessionID is a helper method to define mock.On call | |
| 1296 | + | func (_e *MockTabListProvider_Expecter) GetSessionID() *MockTabListProvider_GetSessionID_Call { | |
| 1297 | + | return &MockTabListProvider_GetSessionID_Call{Call: _e.mock.On("GetSessionID")} | |
| 1298 | + | } | |
| 1299 | + | ||
| 1300 | + | func (_c *MockTabListProvider_GetSessionID_Call) Run(run func()) *MockTabListProvider_GetSessionID_Call { | |
| 1301 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1302 | + | run() | |
| 1303 | + | }) | |
| 1304 | + | return _c | |
| 1305 | + | } | |
| 1306 | + | ||
| 1307 | + | func (_c *MockTabListProvider_GetSessionID_Call) Return(sessionID entity.SessionID) *MockTabListProvider_GetSessionID_Call { | |
| 1308 | + | _c.Call.Return(sessionID) | |
| 1309 | + | return _c | |
| 1310 | + | } | |
| 1311 | + | ||
| 1312 | + | func (_c *MockTabListProvider_GetSessionID_Call) RunAndReturn(run func() entity.SessionID) *MockTabListProvider_GetSessionID_Call { | |
| 1313 | + | _c.Call.Return(run) | |
| 1314 | + | return _c | |
| 1315 | + | } | |
| 1316 | + | ||
| 1317 | + | // GetTabList provides a mock function for the type MockTabListProvider | |
| 1318 | + | func (_mock *MockTabListProvider) GetTabList() *entity.TabList { | |
| 1319 | + | ret := _mock.Called() | |
| 1320 | + | ||
| 1321 | + | if len(ret) == 0 { | |
| 1322 | + | panic("no return value specified for GetTabList") | |
| 1323 | + | } | |
| 1324 | + | ||
| 1325 | + | var r0 *entity.TabList | |
| 1326 | + | if returnFunc, ok := ret.Get(0).(func() *entity.TabList); ok { | |
| 1327 | + | r0 = returnFunc() | |
| 1328 | + | } else { | |
| 1329 | + | if ret.Get(0) != nil { | |
| 1330 | + | r0 = ret.Get(0).(*entity.TabList) | |
| 1331 | + | } | |
| 1332 | + | } | |
| 1333 | + | return r0 | |
| 1334 | + | } | |
| 1335 | + | ||
| 1336 | + | // MockTabListProvider_GetTabList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTabList' | |
| 1337 | + | type MockTabListProvider_GetTabList_Call struct { | |
| 1338 | + | *mock.Call | |
| 1339 | + | } | |
| 1340 | + | ||
| 1341 | + | // GetTabList is a helper method to define mock.On call | |
| 1342 | + | func (_e *MockTabListProvider_Expecter) GetTabList() *MockTabListProvider_GetTabList_Call { | |
| 1343 | + | return &MockTabListProvider_GetTabList_Call{Call: _e.mock.On("GetTabList")} | |
| 1344 | + | } | |
| 1345 | + | ||
| 1346 | + | func (_c *MockTabListProvider_GetTabList_Call) Run(run func()) *MockTabListProvider_GetTabList_Call { | |
| 1347 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1348 | + | run() | |
| 1349 | + | }) | |
| 1350 | + | return _c | |
| 1351 | + | } | |
| 1352 | + | ||
| 1353 | + | func (_c *MockTabListProvider_GetTabList_Call) Return(tabList *entity.TabList) *MockTabListProvider_GetTabList_Call { | |
| 1354 | + | _c.Call.Return(tabList) | |
| 1355 | + | return _c | |
| 1356 | + | } | |
| 1357 | + | ||
| 1358 | + | func (_c *MockTabListProvider_GetTabList_Call) RunAndReturn(run func() *entity.TabList) *MockTabListProvider_GetTabList_Call { | |
| 1359 | + | _c.Call.Return(run) | |
| 1360 | + | return _c | |
| 1361 | + | } | |
| 1362 | + | ||
| 1363 | + | // NewMockSessionSpawner creates a new instance of MockSessionSpawner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1364 | + | // The first argument is typically a *testing.T value. | |
| 1365 | + | func NewMockSessionSpawner(t interface { | |
| 1366 | + | mock.TestingT | |
| 1367 | + | Cleanup(func()) | |
| 1368 | + | }) *MockSessionSpawner { | |
| 1369 | + | mock := &MockSessionSpawner{} | |
| 1370 | + | mock.Mock.Test(t) | |
| 1371 | + | ||
| 1372 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1373 | + | ||
| 1374 | + | return mock | |
| 1375 | + | } | |
| 1376 | + | ||
| 1377 | + | // MockSessionSpawner is an autogenerated mock type for the SessionSpawner type | |
| 1378 | + | type MockSessionSpawner struct { | |
| 1379 | + | mock.Mock | |
| 1380 | + | } | |
| 1381 | + | ||
| 1382 | + | type MockSessionSpawner_Expecter struct { | |
| 1383 | + | mock *mock.Mock | |
| 1384 | + | } | |
| 1385 | + | ||
| 1386 | + | func (_m *MockSessionSpawner) EXPECT() *MockSessionSpawner_Expecter { | |
| 1387 | + | return &MockSessionSpawner_Expecter{mock: &_m.Mock} | |
| 1388 | + | } | |
| 1389 | + | ||
| 1390 | + | // SpawnWithSession provides a mock function for the type MockSessionSpawner | |
| 1391 | + | func (_mock *MockSessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |
| 1392 | + | ret := _mock.Called(sessionID) | |
| 1393 | + | ||
| 1394 | + | if len(ret) == 0 { | |
| 1395 | + | panic("no return value specified for SpawnWithSession") | |
| 1396 | + | } | |
| 1397 | + | ||
| 1398 | + | var r0 error | |
| 1399 | + | if returnFunc, ok := ret.Get(0).(func(entity.SessionID) error); ok { | |
| 1400 | + | r0 = returnFunc(sessionID) | |
| 1401 | + | } else { | |
| 1402 | + | r0 = ret.Error(0) | |
| 1403 | + | } | |
| 1404 | + | return r0 | |
| 1405 | + | } | |
| 1406 | + | ||
| 1407 | + | // MockSessionSpawner_SpawnWithSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SpawnWithSession' | |
| 1408 | + | type MockSessionSpawner_SpawnWithSession_Call struct { | |
| 1409 | + | *mock.Call | |
| 1410 | + | } | |
| 1411 | + | ||
| 1412 | + | // SpawnWithSession is a helper method to define mock.On call | |
| 1413 | + | // - sessionID entity.SessionID | |
| 1414 | + | func (_e *MockSessionSpawner_Expecter) SpawnWithSession(sessionID interface{}) *MockSessionSpawner_SpawnWithSession_Call { | |
| 1415 | + | return &MockSessionSpawner_SpawnWithSession_Call{Call: _e.mock.On("SpawnWithSession", sessionID)} | |
| 1416 | + | } | |
| 1417 | + | ||
| 1418 | + | func (_c *MockSessionSpawner_SpawnWithSession_Call) Run(run func(sessionID entity.SessionID)) *MockSessionSpawner_SpawnWithSession_Call { | |
| 1419 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1420 | + | var arg0 entity.SessionID | |
| 1421 | + | if args[0] != nil { | |
| 1422 | + | arg0 = args[0].(entity.SessionID) | |
| 1423 | + | } | |
| 1424 | + | run( | |
| 1425 | + | arg0, | |
| 1426 | + | ) | |
| 1427 | + | }) | |
| 1428 | + | return _c | |
| 1429 | + | } | |
| 1430 | + | ||
| 1431 | + | func (_c *MockSessionSpawner_SpawnWithSession_Call) Return(err error) *MockSessionSpawner_SpawnWithSession_Call { | |
| 1432 | + | _c.Call.Return(err) | |
| 1433 | + | return _c | |
| 1434 | + | } | |
| 1435 | + | ||
| 1436 | + | func (_c *MockSessionSpawner_SpawnWithSession_Call) RunAndReturn(run func(sessionID entity.SessionID) error) *MockSessionSpawner_SpawnWithSession_Call { | |
| 1437 | + | _c.Call.Return(run) | |
| 1438 | + | return _c | |
| 1439 | + | } | |
| 1440 | + | ||
| 1441 | + | // NewMockMemoryPressureApplier creates a new instance of MockMemoryPressureApplier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1442 | + | // The first argument is typically a *testing.T value. | |
| 1443 | + | func NewMockMemoryPressureApplier(t interface { | |
| 1444 | + | mock.TestingT | |
| 1445 | + | Cleanup(func()) | |
| 1446 | + | }) *MockMemoryPressureApplier { | |
| 1447 | + | mock := &MockMemoryPressureApplier{} | |
| 1448 | + | mock.Mock.Test(t) | |
| 1449 | + | ||
| 1450 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1451 | + | ||
| 1452 | + | return mock | |
| 1453 | + | } | |
| 1454 | + | ||
| 1455 | + | // MockMemoryPressureApplier is an autogenerated mock type for the MemoryPressureApplier type | |
| 1456 | + | type MockMemoryPressureApplier struct { | |
| 1457 | + | mock.Mock | |
| 1458 | + | } | |
| 1459 | + | ||
| 1460 | + | type MockMemoryPressureApplier_Expecter struct { | |
| 1461 | + | mock *mock.Mock | |
| 1462 | + | } | |
| 1463 | + | ||
| 1464 | + | func (_m *MockMemoryPressureApplier) EXPECT() *MockMemoryPressureApplier_Expecter { | |
| 1465 | + | return &MockMemoryPressureApplier_Expecter{mock: &_m.Mock} | |
| 1466 | + | } | |
| 1467 | + | ||
| 1468 | + | // ApplyNetworkProcessSettings provides a mock function for the type MockMemoryPressureApplier | |
| 1469 | + | func (_mock *MockMemoryPressureApplier) ApplyNetworkProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) error { | |
| 1470 | + | ret := _mock.Called(ctx, cfg) | |
| 1471 | + | ||
| 1472 | + | if len(ret) == 0 { | |
| 1473 | + | panic("no return value specified for ApplyNetworkProcessSettings") | |
| 1474 | + | } | |
| 1475 | + | ||
| 1476 | + | var r0 error | |
| 1477 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *port.MemoryPressureConfig) error); ok { | |
| 1478 | + | r0 = returnFunc(ctx, cfg) | |
| 1479 | + | } else { | |
| 1480 | + | r0 = ret.Error(0) | |
| 1481 | + | } | |
| 1482 | + | return r0 | |
| 1483 | + | } | |
| 1484 | + | ||
| 1485 | + | // MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyNetworkProcessSettings' | |
| 1486 | + | type MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call struct { | |
| 1487 | + | *mock.Call | |
| 1488 | + | } | |
| 1489 | + | ||
| 1490 | + | // ApplyNetworkProcessSettings is a helper method to define mock.On call | |
| 1491 | + | // - ctx context.Context | |
| 1492 | + | // - cfg *port.MemoryPressureConfig | |
| 1493 | + | func (_e *MockMemoryPressureApplier_Expecter) ApplyNetworkProcessSettings(ctx interface{}, cfg interface{}) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call { | |
| 1494 | + | return &MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call{Call: _e.mock.On("ApplyNetworkProcessSettings", ctx, cfg)} | |
| 1495 | + | } | |
| 1496 | + | ||
| 1497 | + | func (_c *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call) Run(run func(ctx context.Context, cfg *port.MemoryPressureConfig)) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call { | |
| 1498 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1499 | + | var arg0 context.Context | |
| 1500 | + | if args[0] != nil { | |
| 1501 | + | arg0 = args[0].(context.Context) | |
| 1502 | + | } | |
| 1503 | + | var arg1 *port.MemoryPressureConfig | |
| 1504 | + | if args[1] != nil { | |
| 1505 | + | arg1 = args[1].(*port.MemoryPressureConfig) | |
| 1506 | + | } | |
| 1507 | + | run( | |
| 1508 | + | arg0, | |
| 1509 | + | arg1, | |
| 1510 | + | ) | |
| 1511 | + | }) | |
| 1512 | + | return _c | |
| 1513 | + | } | |
| 1514 | + | ||
| 1515 | + | func (_c *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call) Return(err error) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call { | |
| 1516 | + | _c.Call.Return(err) | |
| 1517 | + | return _c | |
| 1518 | + | } | |
| 1519 | + | ||
| 1520 | + | func (_c *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call) RunAndReturn(run func(ctx context.Context, cfg *port.MemoryPressureConfig) error) *MockMemoryPressureApplier_ApplyNetworkProcessSettings_Call { | |
| 1521 | + | _c.Call.Return(run) | |
| 1522 | + | return _c | |
| 1523 | + | } | |
| 1524 | + | ||
| 1525 | + | // ApplyWebProcessSettings provides a mock function for the type MockMemoryPressureApplier | |
| 1526 | + | func (_mock *MockMemoryPressureApplier) ApplyWebProcessSettings(ctx context.Context, cfg *port.MemoryPressureConfig) (any, error) { | |
| 1527 | + | ret := _mock.Called(ctx, cfg) | |
| 1528 | + | ||
| 1529 | + | if len(ret) == 0 { | |
| 1530 | + | panic("no return value specified for ApplyWebProcessSettings") | |
| 1531 | + | } | |
| 1532 | + | ||
| 1533 | + | var r0 any | |
| 1534 | + | var r1 error | |
| 1535 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *port.MemoryPressureConfig) (any, error)); ok { | |
| 1536 | + | return returnFunc(ctx, cfg) | |
| 1537 | + | } | |
| 1538 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *port.MemoryPressureConfig) any); ok { | |
| 1539 | + | r0 = returnFunc(ctx, cfg) | |
| 1540 | + | } else { | |
| 1541 | + | if ret.Get(0) != nil { | |
| 1542 | + | r0 = ret.Get(0).(any) | |
| 1543 | + | } | |
| 1544 | + | } | |
| 1545 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, *port.MemoryPressureConfig) error); ok { | |
| 1546 | + | r1 = returnFunc(ctx, cfg) | |
| 1547 | + | } else { | |
| 1548 | + | r1 = ret.Error(1) | |
| 1549 | + | } | |
| 1550 | + | return r0, r1 | |
| 1551 | + | } | |
| 1552 | + | ||
| 1553 | + | // MockMemoryPressureApplier_ApplyWebProcessSettings_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyWebProcessSettings' | |
| 1554 | + | type MockMemoryPressureApplier_ApplyWebProcessSettings_Call struct { | |
| 1555 | + | *mock.Call | |
| 1556 | + | } | |
| 1557 | + | ||
| 1558 | + | // ApplyWebProcessSettings is a helper method to define mock.On call | |
| 1559 | + | // - ctx context.Context | |
| 1560 | + | // - cfg *port.MemoryPressureConfig | |
| 1561 | + | func (_e *MockMemoryPressureApplier_Expecter) ApplyWebProcessSettings(ctx interface{}, cfg interface{}) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call { | |
| 1562 | + | return &MockMemoryPressureApplier_ApplyWebProcessSettings_Call{Call: _e.mock.On("ApplyWebProcessSettings", ctx, cfg)} | |
| 1563 | + | } | |
| 1564 | + | ||
| 1565 | + | func (_c *MockMemoryPressureApplier_ApplyWebProcessSettings_Call) Run(run func(ctx context.Context, cfg *port.MemoryPressureConfig)) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call { | |
| 1566 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1567 | + | var arg0 context.Context | |
| 1568 | + | if args[0] != nil { | |
| 1569 | + | arg0 = args[0].(context.Context) | |
| 1570 | + | } | |
| 1571 | + | var arg1 *port.MemoryPressureConfig | |
| 1572 | + | if args[1] != nil { | |
| 1573 | + | arg1 = args[1].(*port.MemoryPressureConfig) | |
| 1574 | + | } | |
| 1575 | + | run( | |
| 1576 | + | arg0, | |
| 1577 | + | arg1, | |
| 1578 | + | ) | |
| 1579 | + | }) | |
| 1580 | + | return _c | |
| 1581 | + | } | |
| 1582 | + | ||
| 1583 | + | func (_c *MockMemoryPressureApplier_ApplyWebProcessSettings_Call) Return(v any, err error) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call { | |
| 1584 | + | _c.Call.Return(v, err) | |
| 1585 | + | return _c | |
| 1586 | + | } | |
| 1587 | + | ||
| 1588 | + | func (_c *MockMemoryPressureApplier_ApplyWebProcessSettings_Call) RunAndReturn(run func(ctx context.Context, cfg *port.MemoryPressureConfig) (any, error)) *MockMemoryPressureApplier_ApplyWebProcessSettings_Call { | |
| 1589 | + | _c.Call.Return(run) | |
| 1590 | + | return _c | |
| 1591 | + | } | |
| 1592 | + | ||
| 1593 | + | // NewMockWebKitContextProvider creates a new instance of MockWebKitContextProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1594 | + | // The first argument is typically a *testing.T value. | |
| 1595 | + | func NewMockWebKitContextProvider(t interface { | |
| 19 | 1596 | mock.TestingT | |
| 20 | 1597 | Cleanup(func()) | |
| 21 | - | }) *MockSessionLogger { | |
| 22 | - | mock := &MockSessionLogger{} | |
| 1598 | + | }) *MockWebKitContextProvider { | |
| 1599 | + | mock := &MockWebKitContextProvider{} | |
| 23 | 1600 | mock.Mock.Test(t) | |
| 24 | 1601 | ||
| 25 | 1602 | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| @@ -27,99 +1604,248 @@ func NewMockSessionLogger(t interface { | |||
| 27 | 1604 | return mock | |
| 28 | 1605 | } | |
| 29 | 1606 | ||
| 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 { | |
| 32 | 1609 | mock.Mock | |
| 33 | 1610 | } | |
| 34 | 1611 | ||
| 35 | - | type MockSessionLogger_Expecter struct { | |
| 1612 | + | type MockWebKitContextProvider_Expecter struct { | |
| 36 | 1613 | mock *mock.Mock | |
| 37 | 1614 | } | |
| 38 | 1615 | ||
| 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} | |
| 41 | 1618 | } | |
| 42 | 1619 | ||
| 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() | |
| 46 | 1623 | ||
| 47 | 1624 | if len(ret) == 0 { | |
| 48 | - | panic("no return value specified for CreateLogger") | |
| 1625 | + | panic("no return value specified for CacheDir") | |
| 49 | 1626 | } | |
| 50 | 1627 | ||
| 51 | - | var r0 zerolog.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) | |
| 56 | 1633 | } | |
| 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() | |
| 59 | 1675 | } else { | |
| 60 | - | r0 = ret.Get(0).(zerolog.Logger) | |
| 1676 | + | r0 = ret.Error(0) | |
| 61 | 1677 | } | |
| 62 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID, port.SessionLogConfig) func()); ok { | |
| 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() | |
| 64 | 1719 | } else { | |
| 65 | - | if ret.Get(1) != nil { | |
| 66 | - | r1 = ret.Get(1).(func()) | |
| 67 | - | } | |
| 1720 | + | r0 = ret.Get(0).(string) | |
| 68 | 1721 | } | |
| 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) | |
| 71 | 1763 | } else { | |
| 72 | - | r2 = ret.Error(2) | |
| 1764 | + | r0 = ret.Error(0) | |
| 73 | 1765 | } | |
| 74 | - | return r0, r1, r2 | |
| 1766 | + | return r0 | |
| 75 | 1767 | } | |
| 76 | 1768 | ||
| 77 | - | // MockSessionLogger_CreateLogger_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateLogger' | |
| 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 { | |
| 79 | 1771 | *mock.Call | |
| 80 | 1772 | } | |
| 81 | 1773 | ||
| 82 | - | // CreateLogger is a helper method to define mock.On call | |
| 1774 | + | // Initialize is a helper method to define mock.On call | |
| 83 | 1775 | // - 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)} | |
| 88 | 1779 | } | |
| 89 | 1780 | ||
| 90 | - | func (_c *MockSessionLogger_CreateLogger_Call) Run(run func(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig)) *MockSessionLogger_CreateLogger_Call { | |
| 1781 | + | func (_c *MockWebKitContextProvider_Initialize_Call) Run(run func(ctx context.Context, opts port.WebKitContextOptions)) *MockWebKitContextProvider_Initialize_Call { | |
| 91 | 1782 | _c.Call.Run(func(args mock.Arguments) { | |
| 92 | 1783 | var arg0 context.Context | |
| 93 | 1784 | if args[0] != nil { | |
| 94 | 1785 | arg0 = args[0].(context.Context) | |
| 95 | 1786 | } | |
| 96 | - | var arg1 entity.SessionID | |
| 1787 | + | var arg1 port.WebKitContextOptions | |
| 97 | 1788 | 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) | |
| 103 | 1790 | } | |
| 104 | 1791 | run( | |
| 105 | 1792 | arg0, | |
| 106 | 1793 | arg1, | |
| 107 | - | arg2, | |
| 108 | 1794 | ) | |
| 109 | 1795 | }) | |
| 110 | 1796 | return _c | |
| 111 | 1797 | } | |
| 112 | 1798 | ||
| 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) | |
| 115 | 1801 | return _c | |
| 116 | 1802 | } | |
| 117 | 1803 | ||
| 118 | - | func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID, cfg port.SessionLogConfig) (zerolog.Logger, func(), error)) *MockSessionLogger_CreateLogger_Call { | |
| 1804 | + | func (_c *MockWebKitContextProvider_Initialize_Call) RunAndReturn(run func(ctx context.Context, opts port.WebKitContextOptions) error) *MockWebKitContextProvider_Initialize_Call { | |
| 119 | 1805 | _c.Call.Return(run) | |
| 120 | 1806 | return _c | |
| 121 | 1807 | } | |
| 122 | 1808 | ||
| 1809 | + | // PrefetchDNS provides a mock function for the type MockWebKitContextProvider | |
| 1810 | + | func (_mock *MockWebKitContextProvider) PrefetchDNS(hostname string) { | |
| 1811 | + | _mock.Called(hostname) | |
| 1812 | + | return | |
| 1813 | + | } | |
| 1814 | + | ||
| 1815 | + | // MockWebKitContextProvider_PrefetchDNS_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrefetchDNS' | |
| 1816 | + | type MockWebKitContextProvider_PrefetchDNS_Call struct { | |
| 1817 | + | *mock.Call | |
| 1818 | + | } | |
| 1819 | + | ||
| 1820 | + | // PrefetchDNS is a helper method to define mock.On call | |
| 1821 | + | // - hostname string | |
| 1822 | + | func (_e *MockWebKitContextProvider_Expecter) PrefetchDNS(hostname interface{}) *MockWebKitContextProvider_PrefetchDNS_Call { | |
| 1823 | + | return &MockWebKitContextProvider_PrefetchDNS_Call{Call: _e.mock.On("PrefetchDNS", hostname)} | |
| 1824 | + | } | |
| 1825 | + | ||
| 1826 | + | func (_c *MockWebKitContextProvider_PrefetchDNS_Call) Run(run func(hostname string)) *MockWebKitContextProvider_PrefetchDNS_Call { | |
| 1827 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1828 | + | var arg0 string | |
| 1829 | + | if args[0] != nil { | |
| 1830 | + | arg0 = args[0].(string) | |
| 1831 | + | } | |
| 1832 | + | run( | |
| 1833 | + | arg0, | |
| 1834 | + | ) | |
| 1835 | + | }) | |
| 1836 | + | return _c | |
| 1837 | + | } | |
| 1838 | + | ||
| 1839 | + | func (_c *MockWebKitContextProvider_PrefetchDNS_Call) Return() *MockWebKitContextProvider_PrefetchDNS_Call { | |
| 1840 | + | _c.Call.Return() | |
| 1841 | + | return _c | |
| 1842 | + | } | |
| 1843 | + | ||
| 1844 | + | func (_c *MockWebKitContextProvider_PrefetchDNS_Call) RunAndReturn(run func(hostname string)) *MockWebKitContextProvider_PrefetchDNS_Call { | |
| 1845 | + | _c.Run(run) | |
| 1846 | + | return _c | |
| 1847 | + | } | |
| 1848 | + | ||
| 123 | 1849 | // NewMockWebView creates a new instance of MockWebView. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 124 | 1850 | // The first argument is typically a *testing.T value. | |
| 125 | 1851 | func NewMockWebView(t interface { | |
| @@ -592,6 +2318,50 @@ func (_c *MockWebView_IsDestroyed_Call) RunAndReturn(run func() bool) *MockWebVi | |||
| 592 | 2318 | return _c | |
| 593 | 2319 | } | |
| 594 | 2320 | ||
| 2321 | + | // IsFullscreen provides a mock function for the type MockWebView | |
| 2322 | + | func (_mock *MockWebView) IsFullscreen() bool { | |
| 2323 | + | ret := _mock.Called() | |
| 2324 | + | ||
| 2325 | + | if len(ret) == 0 { | |
| 2326 | + | panic("no return value specified for IsFullscreen") | |
| 2327 | + | } | |
| 2328 | + | ||
| 2329 | + | var r0 bool | |
| 2330 | + | if returnFunc, ok := ret.Get(0).(func() bool); ok { | |
| 2331 | + | r0 = returnFunc() | |
| 2332 | + | } else { | |
| 2333 | + | r0 = ret.Get(0).(bool) | |
| 2334 | + | } | |
| 2335 | + | return r0 | |
| 2336 | + | } | |
| 2337 | + | ||
| 2338 | + | // MockWebView_IsFullscreen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsFullscreen' | |
| 2339 | + | type MockWebView_IsFullscreen_Call struct { | |
| 2340 | + | *mock.Call | |
| 2341 | + | } | |
| 2342 | + | ||
| 2343 | + | // IsFullscreen is a helper method to define mock.On call | |
| 2344 | + | func (_e *MockWebView_Expecter) IsFullscreen() *MockWebView_IsFullscreen_Call { | |
| 2345 | + | return &MockWebView_IsFullscreen_Call{Call: _e.mock.On("IsFullscreen")} | |
| 2346 | + | } | |
| 2347 | + | ||
| 2348 | + | func (_c *MockWebView_IsFullscreen_Call) Run(run func()) *MockWebView_IsFullscreen_Call { | |
| 2349 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2350 | + | run() | |
| 2351 | + | }) | |
| 2352 | + | return _c | |
| 2353 | + | } | |
| 2354 | + | ||
| 2355 | + | func (_c *MockWebView_IsFullscreen_Call) Return(b bool) *MockWebView_IsFullscreen_Call { | |
| 2356 | + | _c.Call.Return(b) | |
| 2357 | + | return _c | |
| 2358 | + | } | |
| 2359 | + | ||
| 2360 | + | func (_c *MockWebView_IsFullscreen_Call) RunAndReturn(run func() bool) *MockWebView_IsFullscreen_Call { | |
| 2361 | + | _c.Call.Return(run) | |
| 2362 | + | return _c | |
| 2363 | + | } | |
| 2364 | + | ||
| 595 | 2365 | // IsLoading provides a mock function for the type MockWebView | |
| 596 | 2366 | func (_mock *MockWebView) IsLoading() bool { | |
| 597 | 2367 | ret := _mock.Called() | |
| @@ -636,6 +2406,50 @@ func (_c *MockWebView_IsLoading_Call) RunAndReturn(run func() bool) *MockWebView | |||
| 636 | 2406 | return _c | |
| 637 | 2407 | } | |
| 638 | 2408 | ||
| 2409 | + | // IsPlayingAudio provides a mock function for the type MockWebView | |
| 2410 | + | func (_mock *MockWebView) IsPlayingAudio() bool { | |
| 2411 | + | ret := _mock.Called() | |
| 2412 | + | ||
| 2413 | + | if len(ret) == 0 { | |
| 2414 | + | panic("no return value specified for IsPlayingAudio") | |
| 2415 | + | } | |
| 2416 | + | ||
| 2417 | + | var r0 bool | |
| 2418 | + | if returnFunc, ok := ret.Get(0).(func() bool); ok { | |
| 2419 | + | r0 = returnFunc() | |
| 2420 | + | } else { | |
| 2421 | + | r0 = ret.Get(0).(bool) | |
| 2422 | + | } | |
| 2423 | + | return r0 | |
| 2424 | + | } | |
| 2425 | + | ||
| 2426 | + | // MockWebView_IsPlayingAudio_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsPlayingAudio' | |
| 2427 | + | type MockWebView_IsPlayingAudio_Call struct { | |
| 2428 | + | *mock.Call | |
| 2429 | + | } | |
| 2430 | + | ||
| 2431 | + | // IsPlayingAudio is a helper method to define mock.On call | |
| 2432 | + | func (_e *MockWebView_Expecter) IsPlayingAudio() *MockWebView_IsPlayingAudio_Call { | |
| 2433 | + | return &MockWebView_IsPlayingAudio_Call{Call: _e.mock.On("IsPlayingAudio")} | |
| 2434 | + | } | |
| 2435 | + | ||
| 2436 | + | func (_c *MockWebView_IsPlayingAudio_Call) Run(run func()) *MockWebView_IsPlayingAudio_Call { | |
| 2437 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2438 | + | run() | |
| 2439 | + | }) | |
| 2440 | + | return _c | |
| 2441 | + | } | |
| 2442 | + | ||
| 2443 | + | func (_c *MockWebView_IsPlayingAudio_Call) Return(b bool) *MockWebView_IsPlayingAudio_Call { | |
| 2444 | + | _c.Call.Return(b) | |
| 2445 | + | return _c | |
| 2446 | + | } | |
| 2447 | + | ||
| 2448 | + | func (_c *MockWebView_IsPlayingAudio_Call) RunAndReturn(run func() bool) *MockWebView_IsPlayingAudio_Call { | |
| 2449 | + | _c.Call.Return(run) | |
| 2450 | + | return _c | |
| 2451 | + | } | |
| 2452 | + | ||
| 639 | 2453 | // LoadHTML provides a mock function for the type MockWebView | |
| 640 | 2454 | func (_mock *MockWebView) LoadHTML(ctx context.Context, content string, baseURI string) error { | |
| 641 | 2455 | ret := _mock.Called(ctx, content, baseURI) | |
M internal/application/port/rendering_env.go +10 -0
| @@ -46,6 +46,16 @@ type RenderingEnvSettings struct { | |||
| 46 | 46 | ShowFPS bool | |
| 47 | 47 | SampleMemory bool | |
| 48 | 48 | DebugFrames bool | |
| 49 | + | ||
| 50 | + | // --- Skia rendering thread settings (from PerformanceConfig) --- | |
| 51 | + | // SkiaCPUPaintingThreads sets WEBKIT_SKIA_CPU_PAINTING_THREADS. | |
| 52 | + | // 0 means unset (use WebKit default). | |
| 53 | + | SkiaCPUPaintingThreads int | |
| 54 | + | // SkiaGPUPaintingThreads sets WEBKIT_SKIA_GPU_PAINTING_THREADS. | |
| 55 | + | // -1 means unset; 0 disables GPU tile painting. | |
| 56 | + | SkiaGPUPaintingThreads int | |
| 57 | + | // SkiaEnableCPURendering forces CPU rendering via WEBKIT_SKIA_ENABLE_CPU_RENDERING=1. | |
| 58 | + | SkiaEnableCPURendering bool | |
| 49 | 59 | } | |
| 50 | 60 | ||
| 51 | 61 | // RenderingEnvManager configures rendering environment variables | |
A internal/application/port/session_state.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |
| 4 | + | ||
| 5 | + | // TabListProvider provides access to the current tab list state. | |
| 6 | + | // Implemented by the UI layer to allow the snapshot service to read state. | |
| 7 | + | type TabListProvider interface { | |
| 8 | + | // GetTabList returns the current tab list state. | |
| 9 | + | GetTabList() *entity.TabList | |
| 10 | + | // GetSessionID returns the current session ID. | |
| 11 | + | GetSessionID() entity.SessionID | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // SessionSpawner spawns a new dumber instance for resurrection. | |
| 15 | + | type SessionSpawner interface { | |
| 16 | + | // SpawnWithSession starts a new dumber instance to restore a session. | |
| 17 | + | SpawnWithSession(sessionID entity.SessionID) error | |
| 18 | + | } | |
A internal/application/port/updater.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | // Package port defines interfaces for external dependencies. | |
| 2 | + | package port | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // UpdateChecker checks for available updates from a remote source. | |
| 11 | + | type UpdateChecker interface { | |
| 12 | + | // CheckForUpdate compares the current version with the latest available release. | |
| 13 | + | // Returns update info including whether a newer version is available. | |
| 14 | + | CheckForUpdate(ctx context.Context, currentVersion string) (*entity.UpdateInfo, error) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // UpdateDownloader downloads and extracts update archives. | |
| 18 | + | type UpdateDownloader interface { | |
| 19 | + | // Download fetches the update archive to the specified destination directory. | |
| 20 | + | // Returns the path to the downloaded archive file. | |
| 21 | + | Download(ctx context.Context, downloadURL string, destDir string) (archivePath string, err error) | |
| 22 | + | ||
| 23 | + | // Extract extracts the binary from the downloaded archive. | |
| 24 | + | // Returns the path to the extracted binary. | |
| 25 | + | Extract(ctx context.Context, archivePath string, destDir string) (binaryPath string, err error) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // UpdateApplier stages and applies updates. | |
| 29 | + | type UpdateApplier interface { | |
| 30 | + | // CanSelfUpdate checks if the current binary is writable by the current user. | |
| 31 | + | // Returns true if auto-update is possible, false if user lacks write permission. | |
| 32 | + | CanSelfUpdate(ctx context.Context) bool | |
| 33 | + | ||
| 34 | + | // GetBinaryPath returns the path to the currently running binary. | |
| 35 | + | GetBinaryPath() (string, error) | |
| 36 | + | ||
| 37 | + | // StageUpdate prepares the new binary to be applied on exit. | |
| 38 | + | // The staged update will be applied when ApplyOnExit is called. | |
| 39 | + | StageUpdate(ctx context.Context, newBinaryPath string) error | |
| 40 | + | ||
| 41 | + | // HasStagedUpdate checks if there is an update staged and ready to apply. | |
| 42 | + | HasStagedUpdate(ctx context.Context) bool | |
| 43 | + | ||
| 44 | + | // ApplyOnExit replaces the current binary with the staged update. | |
| 45 | + | // This should be called during graceful shutdown. | |
| 46 | + | // Returns the path to the backup of the old binary. | |
| 47 | + | ApplyOnExit(ctx context.Context) (backupPath string, err error) | |
| 48 | + | ||
| 49 | + | // ClearStagedUpdate removes any staged update without applying it. | |
| 50 | + | ClearStagedUpdate(ctx context.Context) error | |
| 51 | + | } | |
A internal/application/port/webkit_context.go +102 -0
| @@ -0,0 +1,102 @@ | |||
| 1 | + | // Package port defines interfaces for external dependencies. | |
| 2 | + | package port | |
| 3 | + | ||
| 4 | + | import "context" | |
| 5 | + | ||
| 6 | + | // MemoryPressureConfig holds memory pressure settings for a WebKit process. | |
| 7 | + | // Zero values mean "use WebKit defaults". | |
| 8 | + | type MemoryPressureConfig struct { | |
| 9 | + | // MemoryLimitMB sets the memory limit in megabytes. | |
| 10 | + | // 0 means unset (uses WebKit default: system RAM capped at 3GB). | |
| 11 | + | MemoryLimitMB int | |
| 12 | + | ||
| 13 | + | // PollIntervalSec sets the interval for memory usage checks. | |
| 14 | + | // 0 means unset (uses WebKit default: 30 seconds). | |
| 15 | + | PollIntervalSec float64 | |
| 16 | + | ||
| 17 | + | // ConservativeThreshold sets threshold for conservative memory release. | |
| 18 | + | // Must be in (0, 1). 0 means unset (uses WebKit default: 0.33). | |
| 19 | + | ConservativeThreshold float64 | |
| 20 | + | ||
| 21 | + | // StrictThreshold sets threshold for strict memory release. | |
| 22 | + | // Must be in (0, 1). 0 means unset (uses WebKit default: 0.5). | |
| 23 | + | StrictThreshold float64 | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // IsConfigured returns true if any memory pressure setting is configured. | |
| 27 | + | func (c *MemoryPressureConfig) IsConfigured() bool { | |
| 28 | + | if c == nil { | |
| 29 | + | return false | |
| 30 | + | } | |
| 31 | + | return c.MemoryLimitMB > 0 || | |
| 32 | + | c.PollIntervalSec > 0 || | |
| 33 | + | c.ConservativeThreshold > 0 || | |
| 34 | + | c.StrictThreshold > 0 | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // WebKitContextOptions configures WebKitContext creation. | |
| 38 | + | type WebKitContextOptions struct { | |
| 39 | + | // DataDir is the directory for persistent data (cookies, storage, etc.). | |
| 40 | + | DataDir string | |
| 41 | + | ||
| 42 | + | // CacheDir is the directory for cache data. | |
| 43 | + | CacheDir string | |
| 44 | + | ||
| 45 | + | // WebProcessMemory configures memory pressure for web processes. | |
| 46 | + | // nil means use WebKit defaults. | |
| 47 | + | WebProcessMemory *MemoryPressureConfig | |
| 48 | + | ||
| 49 | + | // NetworkProcessMemory configures memory pressure for the network process. | |
| 50 | + | // nil means use WebKit defaults. | |
| 51 | + | NetworkProcessMemory *MemoryPressureConfig | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // IsWebProcessMemoryConfigured returns true if web process memory settings are configured. | |
| 55 | + | func (o *WebKitContextOptions) IsWebProcessMemoryConfigured() bool { | |
| 56 | + | if o == nil { | |
| 57 | + | return false | |
| 58 | + | } | |
| 59 | + | return o.WebProcessMemory.IsConfigured() | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // IsNetworkProcessMemoryConfigured returns true if network process memory settings are configured. | |
| 63 | + | func (o *WebKitContextOptions) IsNetworkProcessMemoryConfigured() bool { | |
| 64 | + | if o == nil { | |
| 65 | + | return false | |
| 66 | + | } | |
| 67 | + | return o.NetworkProcessMemory.IsConfigured() | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // MemoryPressureApplier applies memory pressure settings to WebKit processes. | |
| 71 | + | // This interface abstracts the WebKit-specific memory pressure configuration | |
| 72 | + | // to allow testing without WebKit dependencies. | |
| 73 | + | type MemoryPressureApplier interface { | |
| 74 | + | // ApplyNetworkProcessSettings applies memory pressure settings to the network process. | |
| 75 | + | // Must be called BEFORE creating any NetworkSession. | |
| 76 | + | ApplyNetworkProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) error | |
| 77 | + | ||
| 78 | + | // ApplyWebProcessSettings applies memory pressure settings to web processes. | |
| 79 | + | // Returns an opaque settings object that should be passed to WebContext creation. | |
| 80 | + | // Returns nil if no settings are configured. | |
| 81 | + | ApplyWebProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) (any, error) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // WebKitContextProvider creates and manages WebKit contexts. | |
| 85 | + | // This interface abstracts WebKit context creation for testability. | |
| 86 | + | type WebKitContextProvider interface { | |
| 87 | + | // Initialize sets up the WebKit context with the given options. | |
| 88 | + | // Must be called before any other methods. | |
| 89 | + | Initialize(ctx context.Context, opts WebKitContextOptions) error | |
| 90 | + | ||
| 91 | + | // DataDir returns the data directory path. | |
| 92 | + | DataDir() string | |
| 93 | + | ||
| 94 | + | // CacheDir returns the cache directory path. | |
| 95 | + | CacheDir() string | |
| 96 | + | ||
| 97 | + | // PrefetchDNS prefetches DNS for the given hostname. | |
| 98 | + | PrefetchDNS(hostname string) | |
| 99 | + | ||
| 100 | + | // Close releases resources held by the context. | |
| 101 | + | Close() error | |
| 102 | + | } | |
A internal/application/port/webkit_context_test.go +156 -0
| @@ -0,0 +1,156 @@ | |||
| 1 | + | package port_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestMemoryPressureConfig_IsConfigured(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | config *port.MemoryPressureConfig | |
| 14 | + | expected bool | |
| 15 | + | }{ | |
| 16 | + | { | |
| 17 | + | name: "nil config", | |
| 18 | + | config: nil, | |
| 19 | + | expected: false, | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | name: "empty config", | |
| 23 | + | config: &port.MemoryPressureConfig{}, | |
| 24 | + | expected: false, | |
| 25 | + | }, | |
| 26 | + | { | |
| 27 | + | name: "only MemoryLimitMB set", | |
| 28 | + | config: &port.MemoryPressureConfig{ | |
| 29 | + | MemoryLimitMB: 1024, | |
| 30 | + | }, | |
| 31 | + | expected: true, | |
| 32 | + | }, | |
| 33 | + | { | |
| 34 | + | name: "only PollIntervalSec set", | |
| 35 | + | config: &port.MemoryPressureConfig{ | |
| 36 | + | PollIntervalSec: 30.0, | |
| 37 | + | }, | |
| 38 | + | expected: true, | |
| 39 | + | }, | |
| 40 | + | { | |
| 41 | + | name: "only ConservativeThreshold set", | |
| 42 | + | config: &port.MemoryPressureConfig{ | |
| 43 | + | ConservativeThreshold: 0.33, | |
| 44 | + | }, | |
| 45 | + | expected: true, | |
| 46 | + | }, | |
| 47 | + | { | |
| 48 | + | name: "only StrictThreshold set", | |
| 49 | + | config: &port.MemoryPressureConfig{ | |
| 50 | + | StrictThreshold: 0.5, | |
| 51 | + | }, | |
| 52 | + | expected: true, | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "all defaults (unset)", | |
| 56 | + | config: &port.MemoryPressureConfig{ | |
| 57 | + | MemoryLimitMB: 0, | |
| 58 | + | PollIntervalSec: 0, | |
| 59 | + | ConservativeThreshold: 0, | |
| 60 | + | StrictThreshold: 0, | |
| 61 | + | }, | |
| 62 | + | expected: false, | |
| 63 | + | }, | |
| 64 | + | { | |
| 65 | + | name: "fully configured", | |
| 66 | + | config: &port.MemoryPressureConfig{ | |
| 67 | + | MemoryLimitMB: 2048, | |
| 68 | + | PollIntervalSec: 15.0, | |
| 69 | + | ConservativeThreshold: 0.25, | |
| 70 | + | StrictThreshold: 0.6, | |
| 71 | + | }, | |
| 72 | + | expected: true, | |
| 73 | + | }, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | for _, tt := range tests { | |
| 77 | + | t.Run(tt.name, func(t *testing.T) { | |
| 78 | + | result := tt.config.IsConfigured() | |
| 79 | + | assert.Equal(t, tt.expected, result) | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestWebKitContextOptions_IsWebProcessMemoryConfigured(t *testing.T) { | |
| 85 | + | tests := []struct { | |
| 86 | + | name string | |
| 87 | + | opts port.WebKitContextOptions | |
| 88 | + | expected bool | |
| 89 | + | }{ | |
| 90 | + | { | |
| 91 | + | name: "nil WebProcessMemory", | |
| 92 | + | opts: port.WebKitContextOptions{}, | |
| 93 | + | expected: false, | |
| 94 | + | }, | |
| 95 | + | { | |
| 96 | + | name: "empty WebProcessMemory", | |
| 97 | + | opts: port.WebKitContextOptions{ | |
| 98 | + | WebProcessMemory: &port.MemoryPressureConfig{}, | |
| 99 | + | }, | |
| 100 | + | expected: false, | |
| 101 | + | }, | |
| 102 | + | { | |
| 103 | + | name: "configured WebProcessMemory", | |
| 104 | + | opts: port.WebKitContextOptions{ | |
| 105 | + | WebProcessMemory: &port.MemoryPressureConfig{ | |
| 106 | + | MemoryLimitMB: 1024, | |
| 107 | + | }, | |
| 108 | + | }, | |
| 109 | + | expected: true, | |
| 110 | + | }, | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | for _, tt := range tests { | |
| 114 | + | t.Run(tt.name, func(t *testing.T) { | |
| 115 | + | result := tt.opts.IsWebProcessMemoryConfigured() | |
| 116 | + | assert.Equal(t, tt.expected, result) | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestWebKitContextOptions_IsNetworkProcessMemoryConfigured(t *testing.T) { | |
| 122 | + | tests := []struct { | |
| 123 | + | name string | |
| 124 | + | opts port.WebKitContextOptions | |
| 125 | + | expected bool | |
| 126 | + | }{ | |
| 127 | + | { | |
| 128 | + | name: "nil NetworkProcessMemory", | |
| 129 | + | opts: port.WebKitContextOptions{}, | |
| 130 | + | expected: false, | |
| 131 | + | }, | |
| 132 | + | { | |
| 133 | + | name: "empty NetworkProcessMemory", | |
| 134 | + | opts: port.WebKitContextOptions{ | |
| 135 | + | NetworkProcessMemory: &port.MemoryPressureConfig{}, | |
| 136 | + | }, | |
| 137 | + | expected: false, | |
| 138 | + | }, | |
| 139 | + | { | |
| 140 | + | name: "configured NetworkProcessMemory", | |
| 141 | + | opts: port.WebKitContextOptions{ | |
| 142 | + | NetworkProcessMemory: &port.MemoryPressureConfig{ | |
| 143 | + | PollIntervalSec: 20.0, | |
| 144 | + | }, | |
| 145 | + | }, | |
| 146 | + | expected: true, | |
| 147 | + | }, | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | for _, tt := range tests { | |
| 151 | + | t.Run(tt.name, func(t *testing.T) { | |
| 152 | + | result := tt.opts.IsNetworkProcessMemoryConfigured() | |
| 153 | + | assert.Equal(t, tt.expected, result) | |
| 154 | + | }) | |
| 155 | + | } | |
| 156 | + | } | |
M internal/application/port/webui_config.go +16 -0
| @@ -5,11 +5,27 @@ import "context" | |||
| 5 | 5 | // WebUIConfig represents the subset of configuration editable in dumb://config. | |
| 6 | 6 | type WebUIConfig struct { | |
| 7 | 7 | Appearance WebUIAppearanceConfig `json:"appearance"` | |
| 8 | + | Performance WebUIPerformanceConfig `json:"performance"` | |
| 8 | 9 | DefaultUIScale float64 `json:"default_ui_scale"` | |
| 9 | 10 | DefaultSearchEngine string `json:"default_search_engine"` | |
| 10 | 11 | SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"` | |
| 11 | 12 | } | |
| 12 | 13 | ||
| 14 | + | // WebUIPerformanceConfig represents performance settings editable in dumb://config. | |
| 15 | + | type WebUIPerformanceConfig struct { | |
| 16 | + | Profile string `json:"profile"` | |
| 17 | + | Custom WebUICustomPerformanceConfig `json:"custom"` | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // WebUICustomPerformanceConfig holds user-editable fields for custom profile. | |
| 21 | + | type WebUICustomPerformanceConfig struct { | |
| 22 | + | SkiaCPUThreads int `json:"skia_cpu_threads"` | |
| 23 | + | SkiaGPUThreads int `json:"skia_gpu_threads"` | |
| 24 | + | WebProcessMemoryMB int `json:"web_process_memory_mb"` | |
| 25 | + | NetworkProcessMemoryMB int `json:"network_process_memory_mb"` | |
| 26 | + | WebViewPoolPrewarm int `json:"webview_pool_prewarm"` | |
| 27 | + | } | |
| 28 | + | ||
| 13 | 29 | type WebUIAppearanceConfig struct { | |
| 14 | 30 | SansFont string `json:"sans_font"` | |
| 15 | 31 | SerifFont string `json:"serif_font"` | |
M internal/application/port/webview.go +11 -0
| @@ -89,6 +89,9 @@ type WebViewCallbacks struct { | |||
| 89 | 89 | OnCreate func(request PopupRequest) WebView | |
| 90 | 90 | // OnReadyToShow is called when a popup WebView is ready to display. | |
| 91 | 91 | OnReadyToShow func() | |
| 92 | + | // OnLinkHover is called when hovering over a link, image, or media element. | |
| 93 | + | // The uri parameter contains the target URL, or empty string when leaving. | |
| 94 | + | OnLinkHover func(uri string) | |
| 92 | 95 | } | |
| 93 | 96 | ||
| 94 | 97 | // FindOptions configures search behavior. | |
| @@ -190,6 +193,14 @@ type WebView interface { | |||
| 190 | 193 | // Pass nil to clear all callbacks. | |
| 191 | 194 | SetCallbacks(callbacks *WebViewCallbacks) | |
| 192 | 195 | ||
| 196 | + | // --- Media/Fullscreen State --- | |
| 197 | + | ||
| 198 | + | // IsFullscreen returns true if the WebView is currently in fullscreen mode. | |
| 199 | + | IsFullscreen() bool | |
| 200 | + | ||
| 201 | + | // IsPlayingAudio returns true if the WebView is currently playing audio. | |
| 202 | + | IsPlayingAudio() bool | |
| 203 | + | ||
| 193 | 204 | // --- Lifecycle --- | |
| 194 | 205 | ||
| 195 | 206 | // IsDestroyed returns true if the WebView has been destroyed. | |
M internal/application/port/xdg.go +4 -0
| @@ -10,4 +10,8 @@ type XDGPaths interface { | |||
| 10 | 10 | FilterJSONDir() (string, error) | |
| 11 | 11 | FilterStoreDir() (string, error) | |
| 12 | 12 | FilterCacheDir() (string, error) | |
| 13 | + | ||
| 14 | + | // ManDir returns the user man page directory (man1 section). | |
| 15 | + | // Typically $XDG_DATA_HOME/man/man1 or ~/.local/share/man/man1. | |
| 16 | + | ManDir() (string, error) | |
| 13 | 17 | } | |
A internal/application/usecase/apply_update.go +151 -0
| @@ -0,0 +1,151 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/bnema/dumber/internal/logging" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | // File permission for directories. | |
| 16 | + | updateDirPerm = 0o755 | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // ApplyUpdateInput holds the input for the apply update use case. | |
| 20 | + | type ApplyUpdateInput struct { | |
| 21 | + | // DownloadURL is the URL to download the update from. | |
| 22 | + | DownloadURL string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // ApplyUpdateOutput holds the result of the update application. | |
| 26 | + | type ApplyUpdateOutput struct { | |
| 27 | + | // Status is the current update status. | |
| 28 | + | Status entity.UpdateStatus | |
| 29 | + | // Message is a human-readable status message. | |
| 30 | + | Message string | |
| 31 | + | // StagedPath is the path to the staged binary (if status is Ready). | |
| 32 | + | StagedPath string | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // ApplyUpdateUseCase downloads and stages updates for application on exit. | |
| 36 | + | type ApplyUpdateUseCase struct { | |
| 37 | + | downloader port.UpdateDownloader | |
| 38 | + | applier port.UpdateApplier | |
| 39 | + | cacheDir string | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // NewApplyUpdateUseCase creates a new apply update use case. | |
| 43 | + | func NewApplyUpdateUseCase( | |
| 44 | + | downloader port.UpdateDownloader, | |
| 45 | + | applier port.UpdateApplier, | |
| 46 | + | cacheDir string, | |
| 47 | + | ) *ApplyUpdateUseCase { | |
| 48 | + | return &ApplyUpdateUseCase{ | |
| 49 | + | downloader: downloader, | |
| 50 | + | applier: applier, | |
| 51 | + | cacheDir: cacheDir, | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Execute downloads and stages the update for application on exit. | |
| 56 | + | func (uc *ApplyUpdateUseCase) Execute(ctx context.Context, input ApplyUpdateInput) (*ApplyUpdateOutput, error) { | |
| 57 | + | log := logging.FromContext(ctx) | |
| 58 | + | ||
| 59 | + | // Check if we can self-update. | |
| 60 | + | if !uc.applier.CanSelfUpdate(ctx) { | |
| 61 | + | return &ApplyUpdateOutput{ | |
| 62 | + | Status: entity.UpdateStatusFailed, | |
| 63 | + | Message: "Cannot auto-update: binary is not writable", | |
| 64 | + | }, nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Create download directory. | |
| 68 | + | downloadDir := filepath.Join(uc.cacheDir, "updates") | |
| 69 | + | if err := os.MkdirAll(downloadDir, updateDirPerm); err != nil { | |
| 70 | + | return nil, fmt.Errorf("failed to create download directory: %w", err) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | log.Info().Str("url", input.DownloadURL).Msg("downloading update") | |
| 74 | + | ||
| 75 | + | // Download the archive. | |
| 76 | + | archivePath, err := uc.downloader.Download(ctx, input.DownloadURL, downloadDir) | |
| 77 | + | if err != nil { | |
| 78 | + | return &ApplyUpdateOutput{ | |
| 79 | + | Status: entity.UpdateStatusFailed, | |
| 80 | + | Message: fmt.Sprintf("Download failed: %v", err), | |
| 81 | + | }, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | log.Info().Str("archive", archivePath).Msg("extracting update") | |
| 85 | + | ||
| 86 | + | // Extract the binary. | |
| 87 | + | binaryPath, err := uc.downloader.Extract(ctx, archivePath, downloadDir) | |
| 88 | + | if err != nil { | |
| 89 | + | _ = os.Remove(archivePath) | |
| 90 | + | return &ApplyUpdateOutput{ | |
| 91 | + | Status: entity.UpdateStatusFailed, | |
| 92 | + | Message: fmt.Sprintf("Extraction failed: %v", err), | |
| 93 | + | }, nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | log.Info().Str("binary", binaryPath).Msg("staging update") | |
| 97 | + | ||
| 98 | + | // Stage the update. | |
| 99 | + | if err := uc.applier.StageUpdate(ctx, binaryPath); err != nil { | |
| 100 | + | _ = os.Remove(archivePath) | |
| 101 | + | _ = os.Remove(binaryPath) | |
| 102 | + | return &ApplyUpdateOutput{ | |
| 103 | + | Status: entity.UpdateStatusFailed, | |
| 104 | + | Message: fmt.Sprintf("Staging failed: %v", err), | |
| 105 | + | }, nil | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // Clean up download artifacts. | |
| 109 | + | _ = os.Remove(archivePath) | |
| 110 | + | _ = os.Remove(binaryPath) | |
| 111 | + | ||
| 112 | + | log.Info().Msg("update staged successfully, will apply on exit") | |
| 113 | + | ||
| 114 | + | return &ApplyUpdateOutput{ | |
| 115 | + | Status: entity.UpdateStatusReady, | |
| 116 | + | Message: "Update ready - will apply on exit", | |
| 117 | + | }, nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // FinalizeOnExit applies the staged update during shutdown. | |
| 121 | + | // This should be called from the app's shutdown handler. | |
| 122 | + | func (uc *ApplyUpdateUseCase) FinalizeOnExit(ctx context.Context) error { | |
| 123 | + | log := logging.FromContext(ctx) | |
| 124 | + | ||
| 125 | + | if !uc.applier.HasStagedUpdate(ctx) { | |
| 126 | + | log.Debug().Msg("no staged update to apply") | |
| 127 | + | return nil | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | backupPath, err := uc.applier.ApplyOnExit(ctx) | |
| 131 | + | if err != nil { | |
| 132 | + | log.Error().Err(err).Msg("failed to apply update on exit") | |
| 133 | + | return err | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | log.Info(). | |
| 137 | + | Str("backup", backupPath). | |
| 138 | + | Msg("update applied successfully") | |
| 139 | + | ||
| 140 | + | return nil | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // HasPendingUpdate checks if there's an update waiting to be applied on exit. | |
| 144 | + | func (uc *ApplyUpdateUseCase) HasPendingUpdate(ctx context.Context) bool { | |
| 145 | + | return uc.applier.HasStagedUpdate(ctx) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // ClearPendingUpdate removes any staged update. | |
| 149 | + | func (uc *ApplyUpdateUseCase) ClearPendingUpdate(ctx context.Context) error { | |
| 150 | + | return uc.applier.ClearStagedUpdate(ctx) | |
| 151 | + | } | |
A internal/application/usecase/check_update.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/bnema/dumber/internal/domain/build" | |
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // CheckUpdateInput holds the input for the check update use case. | |
| 12 | + | type CheckUpdateInput struct{} | |
| 13 | + | ||
| 14 | + | // CheckUpdateOutput holds the result of the update check. | |
| 15 | + | type CheckUpdateOutput struct { | |
| 16 | + | // UpdateAvailable is true if a newer version exists. | |
| 17 | + | UpdateAvailable bool | |
| 18 | + | // CanAutoUpdate is true if the binary is writable and auto-update is possible. | |
| 19 | + | CanAutoUpdate bool | |
| 20 | + | // CurrentVersion is the version of the running binary. | |
| 21 | + | CurrentVersion string | |
| 22 | + | // LatestVersion is the latest available version. | |
| 23 | + | LatestVersion string | |
| 24 | + | // ReleaseURL is the URL to the GitHub release page. | |
| 25 | + | ReleaseURL string | |
| 26 | + | // DownloadURL is the direct download URL for the archive. | |
| 27 | + | DownloadURL string | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // CheckUpdateUseCase checks for available updates. | |
| 31 | + | type CheckUpdateUseCase struct { | |
| 32 | + | checker port.UpdateChecker | |
| 33 | + | applier port.UpdateApplier | |
| 34 | + | buildInfo build.Info | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // NewCheckUpdateUseCase creates a new check update use case. | |
| 38 | + | func NewCheckUpdateUseCase( | |
| 39 | + | checker port.UpdateChecker, | |
| 40 | + | applier port.UpdateApplier, | |
| 41 | + | buildInfo build.Info, | |
| 42 | + | ) *CheckUpdateUseCase { | |
| 43 | + | return &CheckUpdateUseCase{ | |
| 44 | + | checker: checker, | |
| 45 | + | applier: applier, | |
| 46 | + | buildInfo: buildInfo, | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Execute checks for available updates. | |
| 51 | + | func (uc *CheckUpdateUseCase) Execute(ctx context.Context, _ CheckUpdateInput) (*CheckUpdateOutput, error) { | |
| 52 | + | log := logging.FromContext(ctx) | |
| 53 | + | ||
| 54 | + | info, err := uc.checker.CheckForUpdate(ctx, uc.buildInfo.Version) | |
| 55 | + | if err != nil { | |
| 56 | + | log.Warn().Err(err).Msg("update check failed") | |
| 57 | + | return nil, err | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | canAutoUpdate := false | |
| 61 | + | if info.IsNewer { | |
| 62 | + | canAutoUpdate = uc.applier.CanSelfUpdate(ctx) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | log.Debug(). | |
| 66 | + | Str("current", uc.buildInfo.Version). | |
| 67 | + | Str("latest", info.LatestVersion). | |
| 68 | + | Bool("update_available", info.IsNewer). | |
| 69 | + | Bool("can_auto_update", canAutoUpdate). | |
| 70 | + | Msg("update check completed") | |
| 71 | + | ||
| 72 | + | return &CheckUpdateOutput{ | |
| 73 | + | UpdateAvailable: info.IsNewer, | |
| 74 | + | CanAutoUpdate: canAutoUpdate, | |
| 75 | + | CurrentVersion: info.CurrentVersion, | |
| 76 | + | LatestVersion: info.LatestVersion, | |
| 77 | + | ReleaseURL: info.ReleaseURL, | |
| 78 | + | DownloadURL: info.DownloadURL, | |
| 79 | + | }, nil | |
| 80 | + | } | |
A internal/application/usecase/cleanup_sessions.go +98 -0
| @@ -0,0 +1,98 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // CleanupSessionsUseCase handles automatic cleanup of old exited sessions. | |
| 12 | + | type CleanupSessionsUseCase struct { | |
| 13 | + | sessionRepo repository.SessionRepository | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // NewCleanupSessionsUseCase creates a new CleanupSessionsUseCase. | |
| 17 | + | func NewCleanupSessionsUseCase(sessionRepo repository.SessionRepository) *CleanupSessionsUseCase { | |
| 18 | + | return &CleanupSessionsUseCase{ | |
| 19 | + | sessionRepo: sessionRepo, | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // CleanupSessionsInput contains the cleanup configuration. | |
| 24 | + | type CleanupSessionsInput struct { | |
| 25 | + | // MaxExitedSessions is the maximum number of exited sessions to keep. | |
| 26 | + | // Sessions beyond this count will be deleted (oldest first). | |
| 27 | + | MaxExitedSessions int | |
| 28 | + | ||
| 29 | + | // MaxExitedSessionAgeDays is the maximum age in days for exited sessions. | |
| 30 | + | // Sessions older than this will be deleted. | |
| 31 | + | // A value of 0 disables age-based cleanup. | |
| 32 | + | MaxExitedSessionAgeDays int | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // CleanupSessionsOutput contains the cleanup results. | |
| 36 | + | type CleanupSessionsOutput struct { | |
| 37 | + | // DeletedByAge is the number of sessions deleted due to age. | |
| 38 | + | DeletedByAge int64 | |
| 39 | + | ||
| 40 | + | // DeletedByCount is the number of sessions deleted due to count limit. | |
| 41 | + | DeletedByCount int64 | |
| 42 | + | ||
| 43 | + | // TotalDeleted is the total number of sessions deleted. | |
| 44 | + | TotalDeleted int64 | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Execute cleans up old exited sessions based on the provided configuration. | |
| 48 | + | // It first deletes sessions older than MaxExitedSessionAgeDays, | |
| 49 | + | // then enforces the MaxExitedSessions count limit. | |
| 50 | + | func (uc *CleanupSessionsUseCase) Execute(ctx context.Context, input CleanupSessionsInput) (CleanupSessionsOutput, error) { | |
| 51 | + | log := logging.FromContext(ctx) | |
| 52 | + | output := CleanupSessionsOutput{} | |
| 53 | + | ||
| 54 | + | // 1. Delete by age first (if enabled) | |
| 55 | + | if input.MaxExitedSessionAgeDays > 0 { | |
| 56 | + | cutoff := time.Now().AddDate(0, 0, -input.MaxExitedSessionAgeDays) | |
| 57 | + | deleted, err := uc.sessionRepo.DeleteExitedBefore(ctx, cutoff) | |
| 58 | + | if err != nil { | |
| 59 | + | log.Warn().Err(err).Msg("failed to delete sessions by age") | |
| 60 | + | } else { | |
| 61 | + | output.DeletedByAge = deleted | |
| 62 | + | if deleted > 0 { | |
| 63 | + | log.Info(). | |
| 64 | + | Int64("deleted", deleted). | |
| 65 | + | Int("max_age_days", input.MaxExitedSessionAgeDays). | |
| 66 | + | Msg("cleaned up old sessions by age") | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // 2. Enforce count limit on remaining sessions | |
| 72 | + | if input.MaxExitedSessions >= 0 { | |
| 73 | + | deleted, err := uc.sessionRepo.DeleteOldestExited(ctx, input.MaxExitedSessions) | |
| 74 | + | if err != nil { | |
| 75 | + | log.Warn().Err(err).Msg("failed to delete sessions by count") | |
| 76 | + | } else { | |
| 77 | + | output.DeletedByCount = deleted | |
| 78 | + | if deleted > 0 { | |
| 79 | + | log.Info(). | |
| 80 | + | Int64("deleted", deleted). | |
| 81 | + | Int("max_count", input.MaxExitedSessions). | |
| 82 | + | Msg("cleaned up old sessions by count") | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | output.TotalDeleted = output.DeletedByAge + output.DeletedByCount | |
| 88 | + | ||
| 89 | + | if output.TotalDeleted > 0 { | |
| 90 | + | log.Info(). | |
| 91 | + | Int64("total_deleted", output.TotalDeleted). | |
| 92 | + | Int64("by_age", output.DeletedByAge). | |
| 93 | + | Int64("by_count", output.DeletedByCount). | |
| 94 | + | Msg("session cleanup completed") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | return output, nil | |
| 98 | + | } | |
A internal/application/usecase/cleanup_sessions_test.go +224 -0
| @@ -0,0 +1,224 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestCleanupSessionsUseCase_Execute_AgeAndCountCleanup(t *testing.T) { | |
| 16 | + | ctx := testContext() | |
| 17 | + | ||
| 18 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 19 | + | ||
| 20 | + | // Expect age-based deletion | |
| 21 | + | sessionRepo.EXPECT(). | |
| 22 | + | DeleteExitedBefore(ctx, mock.MatchedBy(func(cutoff time.Time) bool { | |
| 23 | + | // Should be approximately 7 days ago | |
| 24 | + | expected := time.Now().AddDate(0, 0, -7) | |
| 25 | + | diff := expected.Sub(cutoff) | |
| 26 | + | return diff > -time.Minute && diff < time.Minute | |
| 27 | + | })). | |
| 28 | + | Return(int64(3), nil) | |
| 29 | + | ||
| 30 | + | // Expect count-based deletion | |
| 31 | + | sessionRepo.EXPECT(). | |
| 32 | + | DeleteOldestExited(ctx, 50). | |
| 33 | + | Return(int64(2), nil) | |
| 34 | + | ||
| 35 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 36 | + | ||
| 37 | + | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 38 | + | MaxExitedSessions: 50, | |
| 39 | + | MaxExitedSessionAgeDays: 7, | |
| 40 | + | }) | |
| 41 | + | ||
| 42 | + | require.NoError(t, err) | |
| 43 | + | assert.Equal(t, int64(3), output.DeletedByAge) | |
| 44 | + | assert.Equal(t, int64(2), output.DeletedByCount) | |
| 45 | + | assert.Equal(t, int64(5), output.TotalDeleted) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestCleanupSessionsUseCase_Execute_AgeOnlyCleanup(t *testing.T) { | |
| 49 | + | ctx := testContext() | |
| 50 | + | ||
| 51 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 52 | + | ||
| 53 | + | // Expect age-based deletion | |
| 54 | + | sessionRepo.EXPECT(). | |
| 55 | + | DeleteExitedBefore(ctx, mock.Anything). | |
| 56 | + | Return(int64(5), nil) | |
| 57 | + | ||
| 58 | + | // Count-based with -1 should be skipped (disabled) | |
| 59 | + | // MaxExitedSessions >= 0 triggers count cleanup, so use -1 to disable | |
| 60 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 61 | + | ||
| 62 | + | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 63 | + | MaxExitedSessions: -1, // Disabled | |
| 64 | + | MaxExitedSessionAgeDays: 30, | |
| 65 | + | }) | |
| 66 | + | ||
| 67 | + | require.NoError(t, err) | |
| 68 | + | assert.Equal(t, int64(5), output.DeletedByAge) | |
| 69 | + | assert.Equal(t, int64(0), output.DeletedByCount) | |
| 70 | + | assert.Equal(t, int64(5), output.TotalDeleted) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestCleanupSessionsUseCase_Execute_CountOnlyCleanup(t *testing.T) { | |
| 74 | + | ctx := testContext() | |
| 75 | + | ||
| 76 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 77 | + | ||
| 78 | + | // Count-based deletion only (age disabled with 0) | |
| 79 | + | sessionRepo.EXPECT(). | |
| 80 | + | DeleteOldestExited(ctx, 10). | |
| 81 | + | Return(int64(4), nil) | |
| 82 | + | ||
| 83 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 84 | + | ||
| 85 | + | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 86 | + | MaxExitedSessions: 10, | |
| 87 | + | MaxExitedSessionAgeDays: 0, // Disabled | |
| 88 | + | }) | |
| 89 | + | ||
| 90 | + | require.NoError(t, err) | |
| 91 | + | assert.Equal(t, int64(0), output.DeletedByAge) | |
| 92 | + | assert.Equal(t, int64(4), output.DeletedByCount) | |
| 93 | + | assert.Equal(t, int64(4), output.TotalDeleted) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestCleanupSessionsUseCase_Execute_NoCleanupNeeded(t *testing.T) { | |
| 97 | + | ctx := testContext() | |
| 98 | + | ||
| 99 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 100 | + | ||
| 101 | + | // Nothing to delete | |
| 102 | + | sessionRepo.EXPECT(). | |
| 103 | + | DeleteExitedBefore(ctx, mock.Anything). | |
| 104 | + | Return(int64(0), nil) | |
| 105 | + | ||
| 106 | + | sessionRepo.EXPECT(). | |
| 107 | + | DeleteOldestExited(ctx, 50). | |
| 108 | + | Return(int64(0), nil) | |
| 109 | + | ||
| 110 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 111 | + | ||
| 112 | + | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 113 | + | MaxExitedSessions: 50, | |
| 114 | + | MaxExitedSessionAgeDays: 7, | |
| 115 | + | }) | |
| 116 | + | ||
| 117 | + | require.NoError(t, err) | |
| 118 | + | assert.Equal(t, int64(0), output.DeletedByAge) | |
| 119 | + | assert.Equal(t, int64(0), output.DeletedByCount) | |
| 120 | + | assert.Equal(t, int64(0), output.TotalDeleted) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestCleanupSessionsUseCase_Execute_AgeDeleteError_ContinuesToCount(t *testing.T) { | |
| 124 | + | ctx := testContext() | |
| 125 | + | ||
| 126 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 127 | + | ||
| 128 | + | // Age-based deletion fails | |
| 129 | + | sessionRepo.EXPECT(). | |
| 130 | + | DeleteExitedBefore(ctx, mock.Anything). | |
| 131 | + | Return(int64(0), errors.New("db error")) | |
| 132 | + | ||
| 133 | + | // Should still attempt count-based deletion | |
| 134 | + | sessionRepo.EXPECT(). | |
| 135 | + | DeleteOldestExited(ctx, 50). | |
| 136 | + | Return(int64(3), nil) | |
| 137 | + | ||
| 138 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 139 | + | ||
| 140 | + | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 141 | + | MaxExitedSessions: 50, | |
| 142 | + | MaxExitedSessionAgeDays: 7, | |
| 143 | + | }) | |
| 144 | + | ||
| 145 | + | // Should not return error, just log warning | |
| 146 | + | require.NoError(t, err) | |
| 147 | + | assert.Equal(t, int64(0), output.DeletedByAge) | |
| 148 | + | assert.Equal(t, int64(3), output.DeletedByCount) | |
| 149 | + | assert.Equal(t, int64(3), output.TotalDeleted) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestCleanupSessionsUseCase_Execute_CountDeleteError(t *testing.T) { | |
| 153 | + | ctx := testContext() | |
| 154 | + | ||
| 155 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 156 | + | ||
| 157 | + | // Age-based deletion succeeds | |
| 158 | + | sessionRepo.EXPECT(). | |
| 159 | + | DeleteExitedBefore(ctx, mock.Anything). | |
| 160 | + | Return(int64(2), nil) | |
| 161 | + | ||
| 162 | + | // Count-based deletion fails | |
| 163 | + | sessionRepo.EXPECT(). | |
| 164 | + | DeleteOldestExited(ctx, 50). | |
| 165 | + | Return(int64(0), errors.New("db error")) | |
| 166 | + | ||
| 167 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 168 | + | ||
| 169 | + | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 170 | + | MaxExitedSessions: 50, | |
| 171 | + | MaxExitedSessionAgeDays: 7, | |
| 172 | + | }) | |
| 173 | + | ||
| 174 | + | // Should not return error, just log warning | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | assert.Equal(t, int64(2), output.DeletedByAge) | |
| 177 | + | assert.Equal(t, int64(0), output.DeletedByCount) | |
| 178 | + | assert.Equal(t, int64(2), output.TotalDeleted) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestCleanupSessionsUseCase_Execute_BothDisabled(t *testing.T) { | |
| 182 | + | ctx := testContext() | |
| 183 | + | ||
| 184 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 185 | + | ||
| 186 | + | // No expectations - nothing should be called | |
| 187 | + | // Actually, count with 0 means "keep 0" which will trigger cleanup | |
| 188 | + | // So we need -1 for count and 0 for age to fully disable | |
| 189 | + | ||
| 190 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 191 | + | ||
| 192 | + | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 193 | + | MaxExitedSessions: -1, // Disabled | |
| 194 | + | MaxExitedSessionAgeDays: 0, // Disabled | |
| 195 | + | }) | |
| 196 | + | ||
| 197 | + | require.NoError(t, err) | |
| 198 | + | assert.Equal(t, int64(0), output.DeletedByAge) | |
| 199 | + | assert.Equal(t, int64(0), output.DeletedByCount) | |
| 200 | + | assert.Equal(t, int64(0), output.TotalDeleted) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestCleanupSessionsUseCase_Execute_ZeroMaxSessions(t *testing.T) { | |
| 204 | + | ctx := testContext() | |
| 205 | + | ||
| 206 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 207 | + | ||
| 208 | + | // MaxExitedSessions = 0 means keep 0 sessions (delete all) | |
| 209 | + | sessionRepo.EXPECT(). | |
| 210 | + | DeleteOldestExited(ctx, 0). | |
| 211 | + | Return(int64(10), nil) | |
| 212 | + | ||
| 213 | + | uc := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 214 | + | ||
| 215 | + | output, err := uc.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 216 | + | MaxExitedSessions: 0, // Keep 0 = delete all | |
| 217 | + | MaxExitedSessionAgeDays: 0, // Disabled | |
| 218 | + | }) | |
| 219 | + | ||
| 220 | + | require.NoError(t, err) | |
| 221 | + | assert.Equal(t, int64(0), output.DeletedByAge) | |
| 222 | + | assert.Equal(t, int64(10), output.DeletedByCount) | |
| 223 | + | assert.Equal(t, int64(10), output.TotalDeleted) | |
| 224 | + | } | |
A internal/application/usecase/delete_session.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // DeleteSessionUseCase errors. | |
| 13 | + | var ( | |
| 14 | + | ErrCannotDeleteCurrentSession = errors.New("cannot delete current session") | |
| 15 | + | ErrCannotDeleteActiveSession = errors.New("cannot delete active session") | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // DeleteSessionUseCase handles session deletion with proper validation. | |
| 19 | + | type DeleteSessionUseCase struct { | |
| 20 | + | stateRepo repository.SessionStateRepository | |
| 21 | + | sessionRepo repository.SessionRepository | |
| 22 | + | lockDir string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // NewDeleteSessionUseCase creates a new DeleteSessionUseCase. | |
| 26 | + | func NewDeleteSessionUseCase( | |
| 27 | + | stateRepo repository.SessionStateRepository, | |
| 28 | + | sessionRepo repository.SessionRepository, | |
| 29 | + | lockDir string, | |
| 30 | + | ) *DeleteSessionUseCase { | |
| 31 | + | return &DeleteSessionUseCase{ | |
| 32 | + | stateRepo: stateRepo, | |
| 33 | + | sessionRepo: sessionRepo, | |
| 34 | + | lockDir: lockDir, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // DeleteSessionInput contains the parameters for session deletion. | |
| 39 | + | type DeleteSessionInput struct { | |
| 40 | + | SessionID entity.SessionID | |
| 41 | + | CurrentSessionID entity.SessionID | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Execute deletes a session and its state snapshot. | |
| 45 | + | // It validates that the session is not current or active before deletion. | |
| 46 | + | func (uc *DeleteSessionUseCase) Execute(ctx context.Context, input DeleteSessionInput) error { | |
| 47 | + | log := logging.FromContext(ctx) | |
| 48 | + | ||
| 49 | + | // Validate not deleting current session | |
| 50 | + | if input.SessionID == input.CurrentSessionID && input.CurrentSessionID != "" { | |
| 51 | + | return ErrCannotDeleteCurrentSession | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Check session exists | |
| 55 | + | session, err := uc.sessionRepo.FindByID(ctx, input.SessionID) | |
| 56 | + | if err != nil { | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | if session == nil { | |
| 60 | + | return ErrSessionNotFound | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Check not active (EndedAt is nil OR has lock file) | |
| 64 | + | if session.IsActive() || isSessionLocked(uc.lockDir, input.SessionID) { | |
| 65 | + | return ErrCannotDeleteActiveSession | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | log.Info().Str("session_id", string(input.SessionID)).Msg("deleting session") | |
| 69 | + | ||
| 70 | + | // Delete snapshot (ignore "not found" - session may not have a snapshot) | |
| 71 | + | if uc.stateRepo != nil { | |
| 72 | + | if err := uc.stateRepo.DeleteSnapshot(ctx, input.SessionID); err != nil { | |
| 73 | + | log.Debug().Err(err).Msg("failed to delete session snapshot (may not exist)") | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Delete session record | |
| 78 | + | return uc.sessionRepo.Delete(ctx, input.SessionID) | |
| 79 | + | } | |
A internal/application/usecase/delete_session_test.go +255 -0
| @@ -0,0 +1,255 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestDeleteSessionUseCase_Execute_Success(t *testing.T) { | |
| 18 | + | ctx := testContext() | |
| 19 | + | ||
| 20 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 21 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 22 | + | ||
| 23 | + | sessionID := entity.SessionID("20251224_120000_delete") | |
| 24 | + | ||
| 25 | + | endedAt := time.Now().Add(-time.Hour) | |
| 26 | + | session := &entity.Session{ | |
| 27 | + | ID: sessionID, | |
| 28 | + | Type: entity.SessionTypeBrowser, | |
| 29 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 30 | + | EndedAt: &endedAt, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil) | |
| 34 | + | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil) | |
| 35 | + | sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil) | |
| 36 | + | ||
| 37 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 38 | + | ||
| 39 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 40 | + | SessionID: sessionID, | |
| 41 | + | CurrentSessionID: "other-session", | |
| 42 | + | }) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestDeleteSessionUseCase_Execute_CannotDeleteCurrentSession(t *testing.T) { | |
| 47 | + | ctx := testContext() | |
| 48 | + | ||
| 49 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 50 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 51 | + | ||
| 52 | + | sessionID := entity.SessionID("20251224_120000_current") | |
| 53 | + | ||
| 54 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 55 | + | ||
| 56 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 57 | + | SessionID: sessionID, | |
| 58 | + | CurrentSessionID: sessionID, // Same as current | |
| 59 | + | }) | |
| 60 | + | require.Error(t, err) | |
| 61 | + | assert.ErrorIs(t, err, usecase.ErrCannotDeleteCurrentSession) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestDeleteSessionUseCase_Execute_SessionNotFound(t *testing.T) { | |
| 65 | + | ctx := testContext() | |
| 66 | + | ||
| 67 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 68 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 69 | + | ||
| 70 | + | sessionID := entity.SessionID("20251224_120000_notfound") | |
| 71 | + | ||
| 72 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, nil) | |
| 73 | + | ||
| 74 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 75 | + | ||
| 76 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 77 | + | SessionID: sessionID, | |
| 78 | + | CurrentSessionID: "other-session", | |
| 79 | + | }) | |
| 80 | + | require.Error(t, err) | |
| 81 | + | assert.ErrorIs(t, err, usecase.ErrSessionNotFound) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestDeleteSessionUseCase_Execute_CannotDeleteActiveSession(t *testing.T) { | |
| 85 | + | ctx := testContext() | |
| 86 | + | ||
| 87 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 88 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 89 | + | ||
| 90 | + | sessionID := entity.SessionID("20251224_120000_active") | |
| 91 | + | ||
| 92 | + | // Session with nil EndedAt (still active) | |
| 93 | + | session := &entity.Session{ | |
| 94 | + | ID: sessionID, | |
| 95 | + | Type: entity.SessionTypeBrowser, | |
| 96 | + | StartedAt: time.Now().Add(-time.Hour), | |
| 97 | + | EndedAt: nil, // Active | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil) | |
| 101 | + | ||
| 102 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 103 | + | ||
| 104 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 105 | + | SessionID: sessionID, | |
| 106 | + | CurrentSessionID: "other-session", | |
| 107 | + | }) | |
| 108 | + | require.Error(t, err) | |
| 109 | + | assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestDeleteSessionUseCase_Execute_CannotDeleteLockedSession(t *testing.T) { | |
| 113 | + | ctx := testContext() | |
| 114 | + | ||
| 115 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 116 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 117 | + | ||
| 118 | + | // Create a temporary lock directory and lock file | |
| 119 | + | lockDir := t.TempDir() | |
| 120 | + | sessionID := entity.SessionID("20251224_120000_locked") | |
| 121 | + | lockPath := filepath.Join(lockDir, "session_"+string(sessionID)+".lock") | |
| 122 | + | f, err := os.Create(lockPath) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | f.Close() | |
| 125 | + | ||
| 126 | + | endedAt := time.Now().Add(-time.Hour) | |
| 127 | + | session := &entity.Session{ | |
| 128 | + | ID: sessionID, | |
| 129 | + | Type: entity.SessionTypeBrowser, | |
| 130 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 131 | + | EndedAt: &endedAt, // Ended, but has lock file | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil) | |
| 135 | + | ||
| 136 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, lockDir) | |
| 137 | + | ||
| 138 | + | err = uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 139 | + | SessionID: sessionID, | |
| 140 | + | CurrentSessionID: "other-session", | |
| 141 | + | }) | |
| 142 | + | require.Error(t, err) | |
| 143 | + | assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) { | |
| 147 | + | ctx := testContext() | |
| 148 | + | ||
| 149 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 150 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 151 | + | ||
| 152 | + | sessionID := entity.SessionID("20251224_120000_dberror") | |
| 153 | + | ||
| 154 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, errors.New("db error")) | |
| 155 | + | ||
| 156 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 157 | + | ||
| 158 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 159 | + | SessionID: sessionID, | |
| 160 | + | CurrentSessionID: "other-session", | |
| 161 | + | }) | |
| 162 | + | require.Error(t, err) | |
| 163 | + | assert.Contains(t, err.Error(), "db error") | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestDeleteSessionUseCase_Execute_DeleteSnapshotErrorIgnored(t *testing.T) { | |
| 167 | + | ctx := testContext() | |
| 168 | + | ||
| 169 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 170 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 171 | + | ||
| 172 | + | sessionID := entity.SessionID("20251224_120000_nosnapshot") | |
| 173 | + | ||
| 174 | + | endedAt := time.Now().Add(-time.Hour) | |
| 175 | + | session := &entity.Session{ | |
| 176 | + | ID: sessionID, | |
| 177 | + | Type: entity.SessionTypeBrowser, | |
| 178 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 179 | + | EndedAt: &endedAt, | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil) | |
| 183 | + | // Snapshot deletion fails (e.g., no snapshot exists) - should be ignored | |
| 184 | + | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(errors.New("no rows")) | |
| 185 | + | sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil) | |
| 186 | + | ||
| 187 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 188 | + | ||
| 189 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 190 | + | SessionID: sessionID, | |
| 191 | + | CurrentSessionID: "other-session", | |
| 192 | + | }) | |
| 193 | + | // Should succeed even though DeleteSnapshot failed | |
| 194 | + | require.NoError(t, err) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestDeleteSessionUseCase_Execute_DeleteSessionError(t *testing.T) { | |
| 198 | + | ctx := testContext() | |
| 199 | + | ||
| 200 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 201 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 202 | + | ||
| 203 | + | sessionID := entity.SessionID("20251224_120000_deleteerror") | |
| 204 | + | ||
| 205 | + | endedAt := time.Now().Add(-time.Hour) | |
| 206 | + | session := &entity.Session{ | |
| 207 | + | ID: sessionID, | |
| 208 | + | Type: entity.SessionTypeBrowser, | |
| 209 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 210 | + | EndedAt: &endedAt, | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil) | |
| 214 | + | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil) | |
| 215 | + | sessionRepo.EXPECT().Delete(ctx, sessionID).Return(errors.New("delete failed")) | |
| 216 | + | ||
| 217 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 218 | + | ||
| 219 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 220 | + | SessionID: sessionID, | |
| 221 | + | CurrentSessionID: "other-session", | |
| 222 | + | }) | |
| 223 | + | require.Error(t, err) | |
| 224 | + | assert.Contains(t, err.Error(), "delete failed") | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func TestDeleteSessionUseCase_Execute_EmptyCurrentSessionAllowed(t *testing.T) { | |
| 228 | + | ctx := testContext() | |
| 229 | + | ||
| 230 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 231 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 232 | + | ||
| 233 | + | sessionID := entity.SessionID("20251224_120000_nocurrent") | |
| 234 | + | ||
| 235 | + | endedAt := time.Now().Add(-time.Hour) | |
| 236 | + | session := &entity.Session{ | |
| 237 | + | ID: sessionID, | |
| 238 | + | Type: entity.SessionTypeBrowser, | |
| 239 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 240 | + | EndedAt: &endedAt, | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil) | |
| 244 | + | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil) | |
| 245 | + | sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil) | |
| 246 | + | ||
| 247 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 248 | + | ||
| 249 | + | // CurrentSessionID is empty (e.g., CLI context) | |
| 250 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 251 | + | SessionID: sessionID, | |
| 252 | + | CurrentSessionID: "", | |
| 253 | + | }) | |
| 254 | + | require.NoError(t, err) | |
| 255 | + | } | |
A internal/application/usecase/get_config_schema.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // GetConfigSchemaUseCase retrieves configuration schema information. | |
| 11 | + | type GetConfigSchemaUseCase struct { | |
| 12 | + | provider port.ConfigSchemaProvider | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // NewGetConfigSchemaUseCase creates a new GetConfigSchemaUseCase. | |
| 16 | + | func NewGetConfigSchemaUseCase(provider port.ConfigSchemaProvider) *GetConfigSchemaUseCase { | |
| 17 | + | return &GetConfigSchemaUseCase{ | |
| 18 | + | provider: provider, | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // GetConfigSchemaInput contains input parameters for schema retrieval. | |
| 23 | + | type GetConfigSchemaInput struct { | |
| 24 | + | // Reserved for future filtering options (e.g., by section) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // GetConfigSchemaOutput contains the schema information. | |
| 28 | + | type GetConfigSchemaOutput struct { | |
| 29 | + | Keys []entity.ConfigKeyInfo | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Execute retrieves all configuration keys with their metadata. | |
| 33 | + | func (uc *GetConfigSchemaUseCase) Execute(_ context.Context, _ GetConfigSchemaInput) (*GetConfigSchemaOutput, error) { | |
| 34 | + | keys := uc.provider.GetSchema() | |
| 35 | + | return &GetConfigSchemaOutput{ | |
| 36 | + | Keys: keys, | |
| 37 | + | }, nil | |
| 38 | + | } | |
A internal/application/usecase/get_config_schema_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 12 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestGetConfigSchemaUseCase_Execute(t *testing.T) { | |
| 17 | + | t.Run("returns schema keys from provider", func(t *testing.T) { | |
| 18 | + | // Arrange | |
| 19 | + | mockProvider := mocks.NewMockConfigSchemaProvider(t) | |
| 20 | + | expectedKeys := []entity.ConfigKeyInfo{ | |
| 21 | + | { | |
| 22 | + | Key: "appearance.color_scheme", | |
| 23 | + | Type: "string", | |
| 24 | + | Default: "default", | |
| 25 | + | Description: "Theme preference", | |
| 26 | + | Values: []string{"default", "prefer-dark", "prefer-light"}, | |
| 27 | + | Section: "Appearance", | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | Key: "logging.level", | |
| 31 | + | Type: "string", | |
| 32 | + | Default: "info", | |
| 33 | + | Description: "Log verbosity level", | |
| 34 | + | Values: []string{"trace", "debug", "info", "warn", "error", "fatal"}, | |
| 35 | + | Section: "Logging", | |
| 36 | + | }, | |
| 37 | + | } | |
| 38 | + | mockProvider.EXPECT().GetSchema().Return(expectedKeys) | |
| 39 | + | ||
| 40 | + | uc := usecase.NewGetConfigSchemaUseCase(mockProvider) | |
| 41 | + | ||
| 42 | + | // Act | |
| 43 | + | result, err := uc.Execute(context.Background(), usecase.GetConfigSchemaInput{}) | |
| 44 | + | ||
| 45 | + | // Assert | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | assert.NotNil(t, result) | |
| 48 | + | assert.Len(t, result.Keys, 2) | |
| 49 | + | assert.Equal(t, "appearance.color_scheme", result.Keys[0].Key) | |
| 50 | + | assert.Equal(t, "logging.level", result.Keys[1].Key) | |
| 51 | + | mock.AssertExpectationsForObjects(t, mockProvider) | |
| 52 | + | }) | |
| 53 | + | ||
| 54 | + | t.Run("returns empty slice when no keys", func(t *testing.T) { | |
| 55 | + | // Arrange | |
| 56 | + | mockProvider := mocks.NewMockConfigSchemaProvider(t) | |
| 57 | + | mockProvider.EXPECT().GetSchema().Return([]entity.ConfigKeyInfo{}) | |
| 58 | + | ||
| 59 | + | uc := usecase.NewGetConfigSchemaUseCase(mockProvider) | |
| 60 | + | ||
| 61 | + | // Act | |
| 62 | + | result, err := uc.Execute(context.Background(), usecase.GetConfigSchemaInput{}) | |
| 63 | + | ||
| 64 | + | // Assert | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | assert.NotNil(t, result) | |
| 67 | + | assert.Empty(t, result.Keys) | |
| 68 | + | mock.AssertExpectationsForObjects(t, mockProvider) | |
| 69 | + | }) | |
| 70 | + | ||
| 71 | + | t.Run("keys include all expected fields", func(t *testing.T) { | |
| 72 | + | // Arrange | |
| 73 | + | mockProvider := mocks.NewMockConfigSchemaProvider(t) | |
| 74 | + | expectedKeys := []entity.ConfigKeyInfo{ | |
| 75 | + | { | |
| 76 | + | Key: "appearance.default_font_size", | |
| 77 | + | Type: "int", | |
| 78 | + | Default: "16", | |
| 79 | + | Description: "Default font size in CSS pixels", | |
| 80 | + | Range: "1-72", | |
| 81 | + | Section: "Appearance", | |
| 82 | + | }, | |
| 83 | + | } | |
| 84 | + | mockProvider.EXPECT().GetSchema().Return(expectedKeys) | |
| 85 | + | ||
| 86 | + | uc := usecase.NewGetConfigSchemaUseCase(mockProvider) | |
| 87 | + | ||
| 88 | + | // Act | |
| 89 | + | result, err := uc.Execute(context.Background(), usecase.GetConfigSchemaInput{}) | |
| 90 | + | ||
| 91 | + | // Assert | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | assert.NotNil(t, result) | |
| 94 | + | assert.Len(t, result.Keys, 1) | |
| 95 | + | ||
| 96 | + | key := result.Keys[0] | |
| 97 | + | assert.Equal(t, "appearance.default_font_size", key.Key) | |
| 98 | + | assert.Equal(t, "int", key.Type) | |
| 99 | + | assert.Equal(t, "16", key.Default) | |
| 100 | + | assert.Equal(t, "Default font size in CSS pixels", key.Description) | |
| 101 | + | assert.Equal(t, "1-72", key.Range) | |
| 102 | + | assert.Equal(t, "Appearance", key.Section) | |
| 103 | + | assert.Empty(t, key.Values) // No enum values for int type | |
| 104 | + | mock.AssertExpectationsForObjects(t, mockProvider) | |
| 105 | + | }) | |
| 106 | + | } | |
A internal/application/usecase/get_last_restorable_session.go +124 -0
| @@ -0,0 +1,124 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | // maxSessionsToCheck is the number of recent sessions to check for restoration. | |
| 13 | + | maxSessionsToCheck = 10 | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // GetLastRestorableSessionUseCase finds the most recent session that can be auto-restored. | |
| 17 | + | // A session is restorable if: | |
| 18 | + | // - It is not the current session | |
| 19 | + | // - It is not actively running (has ended or crashed without lock file) | |
| 20 | + | // - It has a valid snapshot with at least one tab | |
| 21 | + | type GetLastRestorableSessionUseCase struct { | |
| 22 | + | sessionRepo repository.SessionRepository | |
| 23 | + | stateRepo repository.SessionStateRepository | |
| 24 | + | lockDir string | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // NewGetLastRestorableSessionUseCase creates a new GetLastRestorableSessionUseCase. | |
| 28 | + | func NewGetLastRestorableSessionUseCase( | |
| 29 | + | sessionRepo repository.SessionRepository, | |
| 30 | + | stateRepo repository.SessionStateRepository, | |
| 31 | + | lockDir string, | |
| 32 | + | ) *GetLastRestorableSessionUseCase { | |
| 33 | + | return &GetLastRestorableSessionUseCase{ | |
| 34 | + | sessionRepo: sessionRepo, | |
| 35 | + | stateRepo: stateRepo, | |
| 36 | + | lockDir: lockDir, | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // GetLastRestorableSessionInput contains the parameters for finding a restorable session. | |
| 41 | + | type GetLastRestorableSessionInput struct { | |
| 42 | + | // ExcludeSessionID is the current session ID to exclude from results. | |
| 43 | + | ExcludeSessionID entity.SessionID | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // GetLastRestorableSessionOutput contains the found restorable session, if any. | |
| 47 | + | type GetLastRestorableSessionOutput struct { | |
| 48 | + | // SessionID is the ID of the restorable session, or empty if none found. | |
| 49 | + | SessionID entity.SessionID | |
| 50 | + | // State is the session state to restore, or nil if none found. | |
| 51 | + | State *entity.SessionState | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Execute finds the most recent restorable session. | |
| 55 | + | // Returns an empty output (not an error) if no restorable session is found. | |
| 56 | + | func (uc *GetLastRestorableSessionUseCase) Execute( | |
| 57 | + | ctx context.Context, | |
| 58 | + | input GetLastRestorableSessionInput, | |
| 59 | + | ) (*GetLastRestorableSessionOutput, error) { | |
| 60 | + | log := logging.FromContext(ctx) | |
| 61 | + | ||
| 62 | + | sessions, err := uc.sessionRepo.GetRecent(ctx, maxSessionsToCheck) | |
| 63 | + | if err != nil { | |
| 64 | + | return nil, err | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | for _, session := range sessions { | |
| 68 | + | // Only consider browser sessions | |
| 69 | + | if session.Type != entity.SessionTypeBrowser { | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Skip current session | |
| 74 | + | if session.ID == input.ExcludeSessionID { | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Check if session is actively running | |
| 79 | + | // A session is "active" if ended_at IS NULL AND it has a lock file | |
| 80 | + | if session.IsActive() && isSessionLocked(uc.lockDir, session.ID) { | |
| 81 | + | log.Debug(). | |
| 82 | + | Str("session_id", string(session.ID)). | |
| 83 | + | Msg("auto-restore: skipping active session") | |
| 84 | + | continue | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Try to get snapshot | |
| 88 | + | state, err := uc.stateRepo.GetSnapshot(ctx, session.ID) | |
| 89 | + | if err != nil { | |
| 90 | + | log.Debug(). | |
| 91 | + | Err(err). | |
| 92 | + | Str("session_id", string(session.ID)). | |
| 93 | + | Msg("auto-restore: failed to get snapshot") | |
| 94 | + | continue | |
| 95 | + | } | |
| 96 | + | if state == nil { | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Skip sessions with no tabs | |
| 101 | + | if len(state.Tabs) == 0 { | |
| 102 | + | log.Debug(). | |
| 103 | + | Str("session_id", string(session.ID)). | |
| 104 | + | Msg("auto-restore: skipping session with no tabs") | |
| 105 | + | continue | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // Found a valid restorable session | |
| 109 | + | log.Info(). | |
| 110 | + | Str("session_id", string(session.ID)). | |
| 111 | + | Int("tabs", len(state.Tabs)). | |
| 112 | + | Int("panes", state.CountPanes()). | |
| 113 | + | Msg("auto-restore: found restorable session") | |
| 114 | + | ||
| 115 | + | return &GetLastRestorableSessionOutput{ | |
| 116 | + | SessionID: session.ID, | |
| 117 | + | State: state, | |
| 118 | + | }, nil | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // No restorable session found | |
| 122 | + | log.Debug().Msg("auto-restore: no restorable session found") | |
| 123 | + | return &GetLastRestorableSessionOutput{}, nil | |
| 124 | + | } | |
A internal/application/usecase/get_last_restorable_session_test.go +408 -0
| @@ -0,0 +1,408 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestGetLastRestorableSessionUseCase_NoSessions(t *testing.T) { | |
| 17 | + | ctx := testContext() | |
| 18 | + | ||
| 19 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 20 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 21 | + | ||
| 22 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return([]*entity.Session{}, nil) | |
| 23 | + | ||
| 24 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 25 | + | ||
| 26 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 27 | + | ExcludeSessionID: "current", | |
| 28 | + | }) | |
| 29 | + | ||
| 30 | + | require.NoError(t, err) | |
| 31 | + | require.NotNil(t, output) | |
| 32 | + | assert.Empty(t, output.SessionID) | |
| 33 | + | assert.Nil(t, output.State) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestGetLastRestorableSessionUseCase_OnlyCurrentSession(t *testing.T) { | |
| 37 | + | ctx := testContext() | |
| 38 | + | ||
| 39 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 40 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 41 | + | ||
| 42 | + | currentID := entity.SessionID("20251225_120000_curr") | |
| 43 | + | endedAt := time.Now() | |
| 44 | + | ||
| 45 | + | sessions := []*entity.Session{ | |
| 46 | + | { | |
| 47 | + | ID: currentID, | |
| 48 | + | Type: entity.SessionTypeBrowser, | |
| 49 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 50 | + | EndedAt: &endedAt, | |
| 51 | + | }, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 55 | + | ||
| 56 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 57 | + | ||
| 58 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 59 | + | ExcludeSessionID: currentID, | |
| 60 | + | }) | |
| 61 | + | ||
| 62 | + | require.NoError(t, err) | |
| 63 | + | require.NotNil(t, output) | |
| 64 | + | assert.Empty(t, output.SessionID) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestGetLastRestorableSessionUseCase_SkipsCLISessions(t *testing.T) { | |
| 68 | + | ctx := testContext() | |
| 69 | + | ||
| 70 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 71 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 72 | + | ||
| 73 | + | endedAt := time.Now() | |
| 74 | + | sessions := []*entity.Session{ | |
| 75 | + | { | |
| 76 | + | ID: "20251225_120000_cli1", | |
| 77 | + | Type: entity.SessionTypeCLI, // Not a browser session | |
| 78 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 79 | + | EndedAt: &endedAt, | |
| 80 | + | }, | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 84 | + | ||
| 85 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 86 | + | ||
| 87 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 88 | + | ExcludeSessionID: "current", | |
| 89 | + | }) | |
| 90 | + | ||
| 91 | + | require.NoError(t, err) | |
| 92 | + | assert.Empty(t, output.SessionID) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestGetLastRestorableSessionUseCase_SkipsActiveWithLock(t *testing.T) { | |
| 96 | + | ctx := testContext() | |
| 97 | + | ||
| 98 | + | // Create temp dir for lock files | |
| 99 | + | lockDir := t.TempDir() | |
| 100 | + | ||
| 101 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 102 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 103 | + | ||
| 104 | + | activeID := entity.SessionID("20251225_120000_actv") | |
| 105 | + | ||
| 106 | + | // Create lock file to simulate running session | |
| 107 | + | lockPath := filepath.Join(lockDir, "session_"+string(activeID)+".lock") | |
| 108 | + | f, err := os.Create(lockPath) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | f.Close() | |
| 111 | + | ||
| 112 | + | sessions := []*entity.Session{ | |
| 113 | + | { | |
| 114 | + | ID: activeID, | |
| 115 | + | Type: entity.SessionTypeBrowser, | |
| 116 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 117 | + | EndedAt: nil, // Active (no ended_at) | |
| 118 | + | }, | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 122 | + | ||
| 123 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir) | |
| 124 | + | ||
| 125 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 126 | + | ExcludeSessionID: "current", | |
| 127 | + | }) | |
| 128 | + | ||
| 129 | + | require.NoError(t, err) | |
| 130 | + | assert.Empty(t, output.SessionID) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestGetLastRestorableSessionUseCase_SkipsNoSnapshot(t *testing.T) { | |
| 134 | + | ctx := testContext() | |
| 135 | + | ||
| 136 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 137 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 138 | + | ||
| 139 | + | endedAt := time.Now() | |
| 140 | + | sessionID := entity.SessionID("20251225_120000_nosn") | |
| 141 | + | ||
| 142 | + | sessions := []*entity.Session{ | |
| 143 | + | { | |
| 144 | + | ID: sessionID, | |
| 145 | + | Type: entity.SessionTypeBrowser, | |
| 146 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 147 | + | EndedAt: &endedAt, | |
| 148 | + | }, | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 152 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil) // No snapshot | |
| 153 | + | ||
| 154 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 155 | + | ||
| 156 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 157 | + | ExcludeSessionID: "current", | |
| 158 | + | }) | |
| 159 | + | ||
| 160 | + | require.NoError(t, err) | |
| 161 | + | assert.Empty(t, output.SessionID) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestGetLastRestorableSessionUseCase_SkipsEmptyTabs(t *testing.T) { | |
| 165 | + | ctx := testContext() | |
| 166 | + | ||
| 167 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 168 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 169 | + | ||
| 170 | + | endedAt := time.Now() | |
| 171 | + | sessionID := entity.SessionID("20251225_120000_emty") | |
| 172 | + | ||
| 173 | + | sessions := []*entity.Session{ | |
| 174 | + | { | |
| 175 | + | ID: sessionID, | |
| 176 | + | Type: entity.SessionTypeBrowser, | |
| 177 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 178 | + | EndedAt: &endedAt, | |
| 179 | + | }, | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | emptyState := &entity.SessionState{ | |
| 183 | + | Version: entity.SessionStateVersion, | |
| 184 | + | SessionID: sessionID, | |
| 185 | + | Tabs: []entity.TabSnapshot{}, // No tabs | |
| 186 | + | SavedAt: time.Now(), | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 190 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(emptyState, nil) | |
| 191 | + | ||
| 192 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 193 | + | ||
| 194 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 195 | + | ExcludeSessionID: "current", | |
| 196 | + | }) | |
| 197 | + | ||
| 198 | + | require.NoError(t, err) | |
| 199 | + | assert.Empty(t, output.SessionID) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestGetLastRestorableSessionUseCase_ReturnsEndedSession(t *testing.T) { | |
| 203 | + | ctx := testContext() | |
| 204 | + | ||
| 205 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 206 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 207 | + | ||
| 208 | + | endedAt := time.Now() | |
| 209 | + | sessionID := entity.SessionID("20251225_120000_good") | |
| 210 | + | ||
| 211 | + | sessions := []*entity.Session{ | |
| 212 | + | { | |
| 213 | + | ID: sessionID, | |
| 214 | + | Type: entity.SessionTypeBrowser, | |
| 215 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 216 | + | EndedAt: &endedAt, // Gracefully ended | |
| 217 | + | }, | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | validState := &entity.SessionState{ | |
| 221 | + | Version: entity.SessionStateVersion, | |
| 222 | + | SessionID: sessionID, | |
| 223 | + | Tabs: []entity.TabSnapshot{ | |
| 224 | + | { | |
| 225 | + | ID: "tab-1", | |
| 226 | + | Name: "Test Tab", | |
| 227 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 228 | + | Root: &entity.PaneNodeSnapshot{ | |
| 229 | + | Pane: &entity.PaneSnapshot{ | |
| 230 | + | ID: "pane-1", | |
| 231 | + | URI: "https://example.com", | |
| 232 | + | Title: "Example", | |
| 233 | + | }, | |
| 234 | + | }, | |
| 235 | + | }, | |
| 236 | + | }, | |
| 237 | + | }, | |
| 238 | + | SavedAt: time.Now(), | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 242 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil) | |
| 243 | + | ||
| 244 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 245 | + | ||
| 246 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 247 | + | ExcludeSessionID: "current", | |
| 248 | + | }) | |
| 249 | + | ||
| 250 | + | require.NoError(t, err) | |
| 251 | + | require.NotNil(t, output) | |
| 252 | + | assert.Equal(t, sessionID, output.SessionID) | |
| 253 | + | assert.Equal(t, validState, output.State) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func TestGetLastRestorableSessionUseCase_ReturnsCrashedSession(t *testing.T) { | |
| 257 | + | ctx := testContext() | |
| 258 | + | ||
| 259 | + | // Empty lock dir (no lock files) | |
| 260 | + | lockDir := t.TempDir() | |
| 261 | + | ||
| 262 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 263 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 264 | + | ||
| 265 | + | sessionID := entity.SessionID("20251225_120000_crsh") | |
| 266 | + | ||
| 267 | + | // Session with no ended_at (crashed) but no lock file | |
| 268 | + | sessions := []*entity.Session{ | |
| 269 | + | { | |
| 270 | + | ID: sessionID, | |
| 271 | + | Type: entity.SessionTypeBrowser, | |
| 272 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 273 | + | EndedAt: nil, // Not ended - simulates crash | |
| 274 | + | }, | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | validState := &entity.SessionState{ | |
| 278 | + | Version: entity.SessionStateVersion, | |
| 279 | + | SessionID: sessionID, | |
| 280 | + | Tabs: []entity.TabSnapshot{ | |
| 281 | + | { | |
| 282 | + | ID: "tab-1", | |
| 283 | + | Name: "Crashed Tab", | |
| 284 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 285 | + | Root: &entity.PaneNodeSnapshot{ | |
| 286 | + | Pane: &entity.PaneSnapshot{ | |
| 287 | + | ID: "pane-1", | |
| 288 | + | URI: "https://example.com", | |
| 289 | + | Title: "Example", | |
| 290 | + | }, | |
| 291 | + | }, | |
| 292 | + | }, | |
| 293 | + | }, | |
| 294 | + | }, | |
| 295 | + | SavedAt: time.Now(), | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 299 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil) | |
| 300 | + | ||
| 301 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir) | |
| 302 | + | ||
| 303 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 304 | + | ExcludeSessionID: "current", | |
| 305 | + | }) | |
| 306 | + | ||
| 307 | + | require.NoError(t, err) | |
| 308 | + | require.NotNil(t, output) | |
| 309 | + | assert.Equal(t, sessionID, output.SessionID) | |
| 310 | + | assert.Equal(t, validState, output.State) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func TestGetLastRestorableSessionUseCase_ReturnsMostRecent(t *testing.T) { | |
| 314 | + | ctx := testContext() | |
| 315 | + | ||
| 316 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 317 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 318 | + | ||
| 319 | + | endedAt := time.Now() | |
| 320 | + | recentID := entity.SessionID("20251225_140000_new1") | |
| 321 | + | olderID := entity.SessionID("20251225_120000_old1") | |
| 322 | + | ||
| 323 | + | // Sessions ordered by started_at DESC (most recent first) | |
| 324 | + | sessions := []*entity.Session{ | |
| 325 | + | { | |
| 326 | + | ID: recentID, | |
| 327 | + | Type: entity.SessionTypeBrowser, | |
| 328 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 329 | + | EndedAt: &endedAt, | |
| 330 | + | }, | |
| 331 | + | { | |
| 332 | + | ID: olderID, | |
| 333 | + | Type: entity.SessionTypeBrowser, | |
| 334 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 335 | + | EndedAt: &endedAt, | |
| 336 | + | }, | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | recentState := &entity.SessionState{ | |
| 340 | + | Version: entity.SessionStateVersion, | |
| 341 | + | SessionID: recentID, | |
| 342 | + | Tabs: []entity.TabSnapshot{ | |
| 343 | + | {ID: "tab-1", Name: "Recent Tab"}, | |
| 344 | + | }, | |
| 345 | + | SavedAt: time.Now(), | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 349 | + | stateRepo.EXPECT().GetSnapshot(ctx, recentID).Return(recentState, nil) | |
| 350 | + | // Note: GetSnapshot for olderID should NOT be called since we return first match | |
| 351 | + | ||
| 352 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 353 | + | ||
| 354 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 355 | + | ExcludeSessionID: "current", | |
| 356 | + | }) | |
| 357 | + | ||
| 358 | + | require.NoError(t, err) | |
| 359 | + | assert.Equal(t, recentID, output.SessionID) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func TestGetLastRestorableSessionUseCase_SkipsFirstIfNoSnapshot(t *testing.T) { | |
| 363 | + | ctx := testContext() | |
| 364 | + | ||
| 365 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 366 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 367 | + | ||
| 368 | + | endedAt := time.Now() | |
| 369 | + | noSnapID := entity.SessionID("20251225_140000_nosn") | |
| 370 | + | goodID := entity.SessionID("20251225_120000_good") | |
| 371 | + | ||
| 372 | + | sessions := []*entity.Session{ | |
| 373 | + | { | |
| 374 | + | ID: noSnapID, | |
| 375 | + | Type: entity.SessionTypeBrowser, | |
| 376 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 377 | + | EndedAt: &endedAt, | |
| 378 | + | }, | |
| 379 | + | { | |
| 380 | + | ID: goodID, | |
| 381 | + | Type: entity.SessionTypeBrowser, | |
| 382 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 383 | + | EndedAt: &endedAt, | |
| 384 | + | }, | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | goodState := &entity.SessionState{ | |
| 388 | + | Version: entity.SessionStateVersion, | |
| 389 | + | SessionID: goodID, | |
| 390 | + | Tabs: []entity.TabSnapshot{ | |
| 391 | + | {ID: "tab-1", Name: "Good Tab"}, | |
| 392 | + | }, | |
| 393 | + | SavedAt: time.Now(), | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 397 | + | stateRepo.EXPECT().GetSnapshot(ctx, noSnapID).Return(nil, nil) // No snapshot | |
| 398 | + | stateRepo.EXPECT().GetSnapshot(ctx, goodID).Return(goodState, nil) | |
| 399 | + | ||
| 400 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 401 | + | ||
| 402 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 403 | + | ExcludeSessionID: "current", | |
| 404 | + | }) | |
| 405 | + | ||
| 406 | + | require.NoError(t, err) | |
| 407 | + | assert.Equal(t, goodID, output.SessionID) | |
| 408 | + | } | |
A internal/application/usecase/list_sessions.go +256 -0
| @@ -0,0 +1,256 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // ListSessionsUseCase handles listing sessions with their state information. | |
| 13 | + | type ListSessionsUseCase struct { | |
| 14 | + | sessionRepo repository.SessionRepository | |
| 15 | + | stateRepo repository.SessionStateRepository | |
| 16 | + | lockDir string // Directory where lock files are stored | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // NewListSessionsUseCase creates a new ListSessionsUseCase. | |
| 20 | + | func NewListSessionsUseCase( | |
| 21 | + | sessionRepo repository.SessionRepository, | |
| 22 | + | stateRepo repository.SessionStateRepository, | |
| 23 | + | lockDir string, | |
| 24 | + | ) *ListSessionsUseCase { | |
| 25 | + | return &ListSessionsUseCase{ | |
| 26 | + | sessionRepo: sessionRepo, | |
| 27 | + | stateRepo: stateRepo, | |
| 28 | + | lockDir: lockDir, | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // ListSessionsOutput contains the list of sessions with their info. | |
| 33 | + | type ListSessionsOutput struct { | |
| 34 | + | Sessions []entity.SessionInfo | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Execute returns a list of sessions with their state information. | |
| 38 | + | // Sessions are sorted with active first, then by date (most recent first). | |
| 39 | + | func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) { | |
| 40 | + | log := logging.FromContext(ctx) | |
| 41 | + | ||
| 42 | + | if limit <= 0 { | |
| 43 | + | limit = 50 | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | sessions, err := uc.sessionRepo.GetRecent(ctx, limit) | |
| 47 | + | if err != nil { | |
| 48 | + | return nil, err | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Get all snapshots | |
| 52 | + | snapshots, err := uc.stateRepo.GetAllSnapshots(ctx) | |
| 53 | + | if err != nil { | |
| 54 | + | log.Warn().Err(err).Msg("failed to get session snapshots") | |
| 55 | + | // Continue without snapshots | |
| 56 | + | snapshots = nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Build snapshot lookup map | |
| 60 | + | snapshotMap := make(map[entity.SessionID]*entity.SessionState) | |
| 61 | + | for _, s := range snapshots { | |
| 62 | + | snapshotMap[s.SessionID] = s | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Build session info list | |
| 66 | + | result := make([]entity.SessionInfo, 0, len(sessions)) | |
| 67 | + | for _, session := range sessions { | |
| 68 | + | // Only include browser sessions | |
| 69 | + | if session.Type != entity.SessionTypeBrowser { | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | info := entity.SessionInfo{ | |
| 74 | + | Session: session, | |
| 75 | + | IsCurrent: session.ID == currentSessionID, | |
| 76 | + | IsActive: session.IsActive() || uc.hasActiveLock(session.ID), | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | if state, ok := snapshotMap[session.ID]; ok { | |
| 80 | + | info.State = state | |
| 81 | + | info.TabCount = len(state.Tabs) | |
| 82 | + | info.PaneCount = state.CountPanes() | |
| 83 | + | info.UpdatedAt = state.SavedAt | |
| 84 | + | } else { | |
| 85 | + | info.UpdatedAt = session.StartedAt | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | result = append(result, info) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Sort: current first, then active, then by date | |
| 92 | + | sortSessionInfos(result, currentSessionID) | |
| 93 | + | ||
| 94 | + | return &ListSessionsOutput{Sessions: result}, nil | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // hasActiveLock checks if a session has an active lock file. | |
| 98 | + | func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool { | |
| 99 | + | return isSessionLocked(uc.lockDir, sessionID) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending. | |
| 103 | + | func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) { | |
| 104 | + | // Simple bubble sort for small lists | |
| 105 | + | n := len(infos) | |
| 106 | + | for i := 0; i < n-1; i++ { | |
| 107 | + | for j := 0; j < n-i-1; j++ { | |
| 108 | + | if shouldSwap(infos[j], infos[j+1], currentID) { | |
| 109 | + | infos[j], infos[j+1] = infos[j+1], infos[j] | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func shouldSwap(a, b entity.SessionInfo, currentID entity.SessionID) bool { | |
| 116 | + | // Current session always first | |
| 117 | + | if a.Session.ID == currentID { | |
| 118 | + | return false | |
| 119 | + | } | |
| 120 | + | if b.Session.ID == currentID { | |
| 121 | + | return true | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Active sessions before inactive | |
| 125 | + | if a.IsActive && !b.IsActive { | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | if !a.IsActive && b.IsActive { | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // Most recent first | |
| 133 | + | return a.UpdatedAt.Before(b.UpdatedAt) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // GetSessionInfo returns info for a specific session. | |
| 137 | + | func (uc *ListSessionsUseCase) GetSessionInfo( | |
| 138 | + | ctx context.Context, | |
| 139 | + | sessionID, currentSessionID entity.SessionID, | |
| 140 | + | ) (*entity.SessionInfo, error) { | |
| 141 | + | session, err := uc.sessionRepo.FindByID(ctx, sessionID) | |
| 142 | + | if err != nil { | |
| 143 | + | return nil, err | |
| 144 | + | } | |
| 145 | + | if session == nil { | |
| 146 | + | return nil, nil | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | info := &entity.SessionInfo{ | |
| 150 | + | Session: session, | |
| 151 | + | IsCurrent: session.ID == currentSessionID, | |
| 152 | + | IsActive: session.IsActive() || uc.hasActiveLock(session.ID), | |
| 153 | + | UpdatedAt: session.StartedAt, | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | state, err := uc.stateRepo.GetSnapshot(ctx, sessionID) | |
| 157 | + | if err != nil { | |
| 158 | + | logging.FromContext(ctx).Warn().Err(err).Str("session_id", string(sessionID)).Msg("failed to get session state") | |
| 159 | + | } | |
| 160 | + | if state != nil { | |
| 161 | + | info.State = state | |
| 162 | + | info.TabCount = len(state.Tabs) | |
| 163 | + | info.PaneCount = state.CountPanes() | |
| 164 | + | info.UpdatedAt = state.SavedAt | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | return info, nil | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | const ( | |
| 171 | + | hoursPerDay = 24 | |
| 172 | + | daysPerWeek = 7 | |
| 173 | + | maxIDDisplay = 20 | |
| 174 | + | ) | |
| 175 | + | ||
| 176 | + | // GetRelativeTime returns a human-readable relative time string. | |
| 177 | + | func GetRelativeTime(t time.Time) string { | |
| 178 | + | now := time.Now() | |
| 179 | + | diff := now.Sub(t) | |
| 180 | + | ||
| 181 | + | switch { | |
| 182 | + | case diff < time.Minute: | |
| 183 | + | return "just now" | |
| 184 | + | case diff < time.Hour: | |
| 185 | + | mins := int(diff.Minutes()) | |
| 186 | + | if mins == 1 { | |
| 187 | + | return "1m ago" | |
| 188 | + | } | |
| 189 | + | return formatDuration(mins, "m") | |
| 190 | + | case diff < hoursPerDay*time.Hour: | |
| 191 | + | hours := int(diff.Hours()) | |
| 192 | + | if hours == 1 { | |
| 193 | + | return "1h ago" | |
| 194 | + | } | |
| 195 | + | return formatDuration(hours, "h") | |
| 196 | + | case diff < daysPerWeek*hoursPerDay*time.Hour: | |
| 197 | + | days := int(diff.Hours() / hoursPerDay) | |
| 198 | + | if days == 1 { | |
| 199 | + | return "1d ago" | |
| 200 | + | } | |
| 201 | + | return formatDuration(days, "d") | |
| 202 | + | default: | |
| 203 | + | weeks := int(diff.Hours() / hoursPerDay / daysPerWeek) | |
| 204 | + | if weeks == 1 { | |
| 205 | + | return "1w ago" | |
| 206 | + | } | |
| 207 | + | return formatDuration(weeks, "w") | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func formatDuration(n int, unit string) string { | |
| 212 | + | return string(rune('0'+n/10)) + string(rune('0'+n%10)) + unit + " ago" | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // PurgeableSessionsOutput contains inactive sessions that can be purged. | |
| 216 | + | type PurgeableSessionsOutput struct { | |
| 217 | + | Sessions []entity.SessionPurgeItem | |
| 218 | + | TotalSize int64 | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // GetPurgeableSessions returns all inactive sessions that can be safely deleted. | |
| 222 | + | // These are sessions that have ended and have no active lock file. | |
| 223 | + | func (uc *ListSessionsUseCase) GetPurgeableSessions(ctx context.Context) (*PurgeableSessionsOutput, error) { | |
| 224 | + | log := logging.FromContext(ctx) | |
| 225 | + | ||
| 226 | + | // Get all sessions (no current session in CLI context) | |
| 227 | + | output, err := uc.Execute(ctx, "", maxPurgeableSessions) | |
| 228 | + | if err != nil { | |
| 229 | + | return nil, err | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // Filter to only inactive sessions | |
| 233 | + | var purgeable []entity.SessionPurgeItem | |
| 234 | + | for _, info := range output.Sessions { | |
| 235 | + | if !info.IsActive { | |
| 236 | + | purgeable = append(purgeable, entity.SessionPurgeItem{ | |
| 237 | + | Info: info, | |
| 238 | + | Selected: true, // Default to selected | |
| 239 | + | }) | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // Get total size of all snapshots | |
| 244 | + | totalSize, err := uc.stateRepo.GetTotalSnapshotsSize(ctx) | |
| 245 | + | if err != nil { | |
| 246 | + | log.Warn().Err(err).Msg("failed to get total snapshots size") | |
| 247 | + | totalSize = 0 | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | return &PurgeableSessionsOutput{ | |
| 251 | + | Sessions: purgeable, | |
| 252 | + | TotalSize: totalSize, | |
| 253 | + | }, nil | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | const maxPurgeableSessions = 1000 | |
A internal/application/usecase/list_sessions_test.go +178 -0
| @@ -0,0 +1,178 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestListSessionsUseCase_Execute_ReturnsSessionsWithState(t *testing.T) { | |
| 15 | + | ctx := testContext() | |
| 16 | + | ||
| 17 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 18 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 19 | + | ||
| 20 | + | endedAt := time.Now().Add(-time.Hour) | |
| 21 | + | sessions := []*entity.Session{ | |
| 22 | + | { | |
| 23 | + | ID: "20251224_120000_abc1", | |
| 24 | + | Type: entity.SessionTypeBrowser, | |
| 25 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 26 | + | EndedAt: &endedAt, | |
| 27 | + | }, | |
| 28 | + | { | |
| 29 | + | ID: "20251224_130000_def2", | |
| 30 | + | Type: entity.SessionTypeBrowser, | |
| 31 | + | StartedAt: time.Now().Add(-time.Hour), | |
| 32 | + | EndedAt: nil, // Active | |
| 33 | + | }, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | states := []*entity.SessionState{ | |
| 37 | + | { | |
| 38 | + | SessionID: "20251224_120000_abc1", | |
| 39 | + | SavedAt: time.Now().Add(-time.Hour), | |
| 40 | + | Tabs: []entity.TabSnapshot{ | |
| 41 | + | {ID: "tab1"}, | |
| 42 | + | {ID: "tab2"}, | |
| 43 | + | }, | |
| 44 | + | }, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | sessionRepo.EXPECT().GetRecent(ctx, 50).Return(sessions, nil) | |
| 48 | + | stateRepo.EXPECT().GetAllSnapshots(ctx).Return(states, nil) | |
| 49 | + | ||
| 50 | + | uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "") | |
| 51 | + | ||
| 52 | + | output, err := uc.Execute(ctx, "", 50) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | require.NotNil(t, output) | |
| 55 | + | require.Len(t, output.Sessions, 2) | |
| 56 | + | ||
| 57 | + | // Active session should be first (or current, but we have no current) | |
| 58 | + | // The sorting puts active sessions before inactive | |
| 59 | + | activeSession := output.Sessions[0] | |
| 60 | + | assert.True(t, activeSession.IsActive) | |
| 61 | + | ||
| 62 | + | // Check session with state has tab count | |
| 63 | + | var sessionWithState *entity.SessionInfo | |
| 64 | + | for i := range output.Sessions { | |
| 65 | + | if output.Sessions[i].Session.ID == "20251224_120000_abc1" { | |
| 66 | + | sessionWithState = &output.Sessions[i] | |
| 67 | + | break | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | require.NotNil(t, sessionWithState) | |
| 71 | + | assert.Equal(t, 2, sessionWithState.TabCount) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestListSessionsUseCase_GetPurgeableSessions_ReturnsOnlyInactive(t *testing.T) { | |
| 75 | + | ctx := testContext() | |
| 76 | + | ||
| 77 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 78 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 79 | + | ||
| 80 | + | endedAt := time.Now().Add(-time.Hour) | |
| 81 | + | sessions := []*entity.Session{ | |
| 82 | + | { | |
| 83 | + | ID: "20251224_120000_inactive1", | |
| 84 | + | Type: entity.SessionTypeBrowser, | |
| 85 | + | StartedAt: time.Now().Add(-3 * time.Hour), | |
| 86 | + | EndedAt: &endedAt, // Ended - purgeable | |
| 87 | + | }, | |
| 88 | + | { | |
| 89 | + | ID: "20251224_130000_active1", | |
| 90 | + | Type: entity.SessionTypeBrowser, | |
| 91 | + | StartedAt: time.Now().Add(-time.Hour), | |
| 92 | + | EndedAt: nil, // Active - NOT purgeable | |
| 93 | + | }, | |
| 94 | + | { | |
| 95 | + | ID: "20251224_140000_inactive2", | |
| 96 | + | Type: entity.SessionTypeBrowser, | |
| 97 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 98 | + | EndedAt: &endedAt, // Ended - purgeable | |
| 99 | + | }, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil) | |
| 103 | + | stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil) | |
| 104 | + | stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(12345), nil) | |
| 105 | + | ||
| 106 | + | uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "") | |
| 107 | + | ||
| 108 | + | output, err := uc.GetPurgeableSessions(ctx) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | require.NotNil(t, output) | |
| 111 | + | ||
| 112 | + | // Should only have 2 inactive sessions, not the active one | |
| 113 | + | require.Len(t, output.Sessions, 2) | |
| 114 | + | assert.Equal(t, int64(12345), output.TotalSize) | |
| 115 | + | ||
| 116 | + | // Verify all returned sessions are inactive | |
| 117 | + | for _, s := range output.Sessions { | |
| 118 | + | assert.False(t, s.Info.IsActive, "session %s should not be active", s.Info.Session.ID) | |
| 119 | + | assert.True(t, s.Selected, "sessions should be selected by default") | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestListSessionsUseCase_GetPurgeableSessions_EmptyWhenAllActive(t *testing.T) { | |
| 124 | + | ctx := testContext() | |
| 125 | + | ||
| 126 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 127 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 128 | + | ||
| 129 | + | sessions := []*entity.Session{ | |
| 130 | + | { | |
| 131 | + | ID: "20251224_130000_active1", | |
| 132 | + | Type: entity.SessionTypeBrowser, | |
| 133 | + | StartedAt: time.Now().Add(-time.Hour), | |
| 134 | + | EndedAt: nil, // Active | |
| 135 | + | }, | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil) | |
| 139 | + | stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil) | |
| 140 | + | stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), nil) | |
| 141 | + | ||
| 142 | + | uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "") | |
| 143 | + | ||
| 144 | + | output, err := uc.GetPurgeableSessions(ctx) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | require.NotNil(t, output) | |
| 147 | + | require.Empty(t, output.Sessions) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestListSessionsUseCase_GetPurgeableSessions_HandlesGetSizeError(t *testing.T) { | |
| 151 | + | ctx := testContext() | |
| 152 | + | ||
| 153 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 154 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 155 | + | ||
| 156 | + | endedAt := time.Now().Add(-time.Hour) | |
| 157 | + | sessions := []*entity.Session{ | |
| 158 | + | { | |
| 159 | + | ID: "20251224_120000_inactive1", | |
| 160 | + | Type: entity.SessionTypeBrowser, | |
| 161 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 162 | + | EndedAt: &endedAt, | |
| 163 | + | }, | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil) | |
| 167 | + | stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil) | |
| 168 | + | stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), assert.AnError) | |
| 169 | + | ||
| 170 | + | uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "") | |
| 171 | + | ||
| 172 | + | output, err := uc.GetPurgeableSessions(ctx) | |
| 173 | + | // Should succeed even if size query fails | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | require.NotNil(t, output) | |
| 176 | + | require.Len(t, output.Sessions, 1) | |
| 177 | + | assert.Equal(t, int64(0), output.TotalSize) // Size defaults to 0 on error | |
| 178 | + | } | |
M internal/application/usecase/manage_favorites.go +71 -0
| @@ -196,6 +196,77 @@ func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (b | |||
| 196 | 196 | return fav != nil, nil | |
| 197 | 197 | } | |
| 198 | 198 | ||
| 199 | + | // ToggleResult indicates the result of a toggle operation. | |
| 200 | + | type ToggleResult struct { | |
| 201 | + | Added bool // true if favorite was added, false if removed | |
| 202 | + | URL string | |
| 203 | + | Title string | |
| 204 | + | Message string // User-friendly message | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // Toggle adds or removes a URL from favorites based on current state. | |
| 208 | + | // If the URL is already a favorite, it removes it. Otherwise, it adds it. | |
| 209 | + | func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) { | |
| 210 | + | log := logging.FromContext(ctx) | |
| 211 | + | log.Debug().Str("url", url).Msg("toggling favorite") | |
| 212 | + | ||
| 213 | + | if url == "" { | |
| 214 | + | return nil, fmt.Errorf("URL cannot be empty") | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // Check if already favorited | |
| 218 | + | existing, err := uc.favoriteRepo.FindByURL(ctx, url) | |
| 219 | + | if err != nil { | |
| 220 | + | return nil, fmt.Errorf("failed to check existing favorite: %w", err) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | if existing != nil { | |
| 224 | + | // Remove favorite | |
| 225 | + | if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil { | |
| 226 | + | return nil, fmt.Errorf("failed to remove favorite: %w", err) | |
| 227 | + | } | |
| 228 | + | log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle") | |
| 229 | + | return &ToggleResult{ | |
| 230 | + | Added: false, | |
| 231 | + | URL: url, | |
| 232 | + | Title: existing.Title, | |
| 233 | + | Message: "Favorite removed", | |
| 234 | + | }, nil | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // Add favorite | |
| 238 | + | fav := entity.NewFavorite(url, title) | |
| 239 | + | if err := uc.favoriteRepo.Save(ctx, fav); err != nil { | |
| 240 | + | return nil, fmt.Errorf("failed to add favorite: %w", err) | |
| 241 | + | } | |
| 242 | + | log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle") | |
| 243 | + | return &ToggleResult{ | |
| 244 | + | Added: true, | |
| 245 | + | URL: url, | |
| 246 | + | Title: title, | |
| 247 | + | Message: "Favorite added", | |
| 248 | + | }, nil | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // GetAllURLs returns a set of all favorited URLs for efficient batch lookup. | |
| 252 | + | func (uc *ManageFavoritesUseCase) GetAllURLs(ctx context.Context) (map[string]struct{}, error) { | |
| 253 | + | log := logging.FromContext(ctx) | |
| 254 | + | log.Debug().Msg("getting all favorite URLs") | |
| 255 | + | ||
| 256 | + | favs, err := uc.favoriteRepo.GetAll(ctx) | |
| 257 | + | if err != nil { | |
| 258 | + | return nil, fmt.Errorf("failed to get favorites: %w", err) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | urls := make(map[string]struct{}, len(favs)) | |
| 262 | + | for _, fav := range favs { | |
| 263 | + | urls[fav.URL] = struct{}{} | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | log.Debug().Int("count", len(urls)).Msg("retrieved favorite URLs") | |
| 267 | + | return urls, nil | |
| 268 | + | } | |
| 269 | + | ||
| 199 | 270 | // GetTree builds a complete hierarchical view of folders and favorites. | |
| 200 | 271 | func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.FavoriteTree, error) { | |
| 201 | 272 | log := logging.FromContext(ctx) | |
A internal/application/usecase/manage_favorites_test.go +200 -0
| @@ -0,0 +1,200 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestManageFavoritesUseCase_GetAllURLs_ReturnsURLSet(t *testing.T) { | |
| 16 | + | ctx := testContext() | |
| 17 | + | ||
| 18 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 19 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 20 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 21 | + | ||
| 22 | + | favorites := []*entity.Favorite{ | |
| 23 | + | {ID: 1, URL: "https://example.com"}, | |
| 24 | + | {ID: 2, URL: "https://github.com"}, | |
| 25 | + | {ID: 3, URL: "https://google.com"}, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil) | |
| 29 | + | ||
| 30 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 31 | + | ||
| 32 | + | urls, err := uc.GetAllURLs(ctx) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.NotNil(t, urls) | |
| 35 | + | ||
| 36 | + | assert.Len(t, urls, 3) | |
| 37 | + | _, hasExample := urls["https://example.com"] | |
| 38 | + | _, hasGithub := urls["https://github.com"] | |
| 39 | + | _, hasGoogle := urls["https://google.com"] | |
| 40 | + | assert.True(t, hasExample) | |
| 41 | + | assert.True(t, hasGithub) | |
| 42 | + | assert.True(t, hasGoogle) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestManageFavoritesUseCase_GetAllURLs_ReturnsEmptySetWhenNoFavorites(t *testing.T) { | |
| 46 | + | ctx := testContext() | |
| 47 | + | ||
| 48 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 49 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 50 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 51 | + | ||
| 52 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil) | |
| 53 | + | ||
| 54 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 55 | + | ||
| 56 | + | urls, err := uc.GetAllURLs(ctx) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | require.NotNil(t, urls) | |
| 59 | + | assert.Empty(t, urls) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing.T) { | |
| 63 | + | ctx := testContext() | |
| 64 | + | ||
| 65 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 66 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 67 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 68 | + | ||
| 69 | + | favoriteRepo.EXPECT().GetAll(mock.Anything).Return(nil, errors.New("db connection failed")) | |
| 70 | + | ||
| 71 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 72 | + | ||
| 73 | + | urls, err := uc.GetAllURLs(ctx) | |
| 74 | + | require.Error(t, err) | |
| 75 | + | assert.Nil(t, urls) | |
| 76 | + | assert.Contains(t, err.Error(), "failed to get favorites") | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) { | |
| 80 | + | ctx := testContext() | |
| 81 | + | ||
| 82 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 83 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 84 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 85 | + | ||
| 86 | + | // URL is not a favorite | |
| 87 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil) | |
| 88 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(f *entity.Favorite) bool { | |
| 89 | + | return f.URL == "https://example.com" && f.Title == "Example" | |
| 90 | + | })).Return(nil) | |
| 91 | + | ||
| 92 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 93 | + | ||
| 94 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | require.NotNil(t, result) | |
| 97 | + | ||
| 98 | + | assert.True(t, result.Added) | |
| 99 | + | assert.Equal(t, "https://example.com", result.URL) | |
| 100 | + | assert.Equal(t, "Example", result.Title) | |
| 101 | + | assert.Equal(t, "Favorite added", result.Message) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T) { | |
| 105 | + | ctx := testContext() | |
| 106 | + | ||
| 107 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 108 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 109 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 110 | + | ||
| 111 | + | existingFav := &entity.Favorite{ | |
| 112 | + | ID: 42, | |
| 113 | + | URL: "https://example.com", | |
| 114 | + | Title: "Example Site", | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // URL is already a favorite | |
| 118 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | |
| 119 | + | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil) | |
| 120 | + | ||
| 121 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 122 | + | ||
| 123 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | require.NotNil(t, result) | |
| 126 | + | ||
| 127 | + | assert.False(t, result.Added) | |
| 128 | + | assert.Equal(t, "https://example.com", result.URL) | |
| 129 | + | assert.Equal(t, "Example Site", result.Title) // Uses existing title | |
| 130 | + | assert.Equal(t, "Favorite removed", result.Message) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) { | |
| 134 | + | ctx := testContext() | |
| 135 | + | ||
| 136 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 137 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 138 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 139 | + | ||
| 140 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 141 | + | ||
| 142 | + | result, err := uc.Toggle(ctx, "", "Title") | |
| 143 | + | require.Error(t, err) | |
| 144 | + | assert.Nil(t, result) | |
| 145 | + | assert.Contains(t, err.Error(), "URL cannot be empty") | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) { | |
| 149 | + | ctx := testContext() | |
| 150 | + | ||
| 151 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 152 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 153 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 154 | + | ||
| 155 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error")) | |
| 156 | + | ||
| 157 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 158 | + | ||
| 159 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 160 | + | require.Error(t, err) | |
| 161 | + | assert.Nil(t, result) | |
| 162 | + | assert.Contains(t, err.Error(), "failed to check existing favorite") | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestManageFavoritesUseCase_Toggle_SaveErrorReturnsError(t *testing.T) { | |
| 166 | + | ctx := testContext() | |
| 167 | + | ||
| 168 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 169 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 170 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 171 | + | ||
| 172 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil) | |
| 173 | + | favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error")) | |
| 174 | + | ||
| 175 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 176 | + | ||
| 177 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 178 | + | require.Error(t, err) | |
| 179 | + | assert.Nil(t, result) | |
| 180 | + | assert.Contains(t, err.Error(), "failed to add favorite") | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) { | |
| 184 | + | ctx := testContext() | |
| 185 | + | ||
| 186 | + | favoriteRepo := repomocks.NewMockFavoriteRepository(t) | |
| 187 | + | folderRepo := repomocks.NewMockFolderRepository(t) | |
| 188 | + | tagRepo := repomocks.NewMockTagRepository(t) | |
| 189 | + | ||
| 190 | + | existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"} | |
| 191 | + | favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil) | |
| 192 | + | favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error")) | |
| 193 | + | ||
| 194 | + | uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo) | |
| 195 | + | ||
| 196 | + | result, err := uc.Toggle(ctx, "https://example.com", "Example") | |
| 197 | + | require.Error(t, err) | |
| 198 | + | assert.Nil(t, result) | |
| 199 | + | assert.Contains(t, err.Error(), "failed to remove favorite") | |
| 200 | + | } | |
M internal/application/usecase/manage_panes.go +851 -1
| @@ -2,10 +2,14 @@ package usecase | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 7 | + | "math" | |
| 6 | 8 | "sort" | |
| 9 | + | "strings" | |
| 7 | 10 | ||
| 8 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 9 | 13 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 14 | ) | |
| 11 | 15 | ||
| @@ -29,6 +33,42 @@ const ( | |||
| 29 | 33 | NavDown NavigateDirection = "down" | |
| 30 | 34 | ) | |
| 31 | 35 | ||
| 36 | + | // ResizeDirection indicates the direction for pane resizing. | |
| 37 | + | type ResizeDirection string | |
| 38 | + | ||
| 39 | + | const ( | |
| 40 | + | ResizeIncreaseLeft ResizeDirection = "increase_left" | |
| 41 | + | ResizeIncreaseRight ResizeDirection = "increase_right" | |
| 42 | + | ResizeIncreaseUp ResizeDirection = "increase_up" | |
| 43 | + | ResizeIncreaseDown ResizeDirection = "increase_down" | |
| 44 | + | ||
| 45 | + | ResizeDecreaseLeft ResizeDirection = "decrease_left" | |
| 46 | + | ResizeDecreaseRight ResizeDirection = "decrease_right" | |
| 47 | + | ResizeDecreaseUp ResizeDirection = "decrease_up" | |
| 48 | + | ResizeDecreaseDown ResizeDirection = "decrease_down" | |
| 49 | + | ||
| 50 | + | ResizeIncrease ResizeDirection = "increase" | |
| 51 | + | ResizeDecrease ResizeDirection = "decrease" | |
| 52 | + | ) | |
| 53 | + | ||
| 54 | + | var ErrNothingToResize = errors.New("nothing to resize") | |
| 55 | + | ||
| 56 | + | type ConsumeOrExpelDirection string | |
| 57 | + | ||
| 58 | + | const ( | |
| 59 | + | ConsumeOrExpelLeft ConsumeOrExpelDirection = "left" | |
| 60 | + | ConsumeOrExpelRight ConsumeOrExpelDirection = "right" | |
| 61 | + | ConsumeOrExpelUp ConsumeOrExpelDirection = "up" | |
| 62 | + | ConsumeOrExpelDown ConsumeOrExpelDirection = "down" | |
| 63 | + | ) | |
| 64 | + | ||
| 65 | + | type ConsumeOrExpelResult struct { | |
| 66 | + | Action string // "consumed", "expelled", or "none" | |
| 67 | + | ErrorMessage string | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | const consumeOrExpelExpelCycleMarker = "_expel_cycle" | |
| 71 | + | ||
| 32 | 72 | // ManagePanesUseCase handles pane tree operations. | |
| 33 | 73 | type ManagePanesUseCase struct { | |
| 34 | 74 | idGenerator IDGenerator | |
| @@ -91,7 +131,7 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||
| 91 | 131 | paneID := entity.PaneID(uc.idGenerator()) | |
| 92 | 132 | newPane = entity.NewPane(paneID) | |
| 93 | 133 | if input.InitialURL != "" { | |
| 94 | - | newPane.URI = input.InitialURL | |
| 134 | + | newPane.URI = domainurl.Normalize(input.InitialURL) | |
| 95 | 135 | } else { | |
| 96 | 136 | newPane.URI = "about:blank" | |
| 97 | 137 | } | |
| @@ -166,6 +206,254 @@ func (uc *ManagePanesUseCase) Split(ctx context.Context, input SplitPaneInput) ( | |||
| 166 | 206 | }, nil | |
| 167 | 207 | } | |
| 168 | 208 | ||
| 209 | + | // Resize adjusts the nearest applicable split ratio for the given direction. | |
| 210 | + | // stepPercent is applied per keystroke (e.g. 5.0 means 5%). | |
| 211 | + | // minPanePercent enforces a minimum size for each side of a split. | |
| 212 | + | func (uc *ManagePanesUseCase) Resize( | |
| 213 | + | ctx context.Context, | |
| 214 | + | ws *entity.Workspace, | |
| 215 | + | paneNode *entity.PaneNode, | |
| 216 | + | dir ResizeDirection, | |
| 217 | + | stepPercent float64, | |
| 218 | + | minPanePercent float64, | |
| 219 | + | ) error { | |
| 220 | + | log := logging.FromContext(ctx) | |
| 221 | + | if uc == nil { | |
| 222 | + | return fmt.Errorf("manage panes use case is nil") | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | if ws == nil { | |
| 226 | + | return fmt.Errorf("workspace is required") | |
| 227 | + | } | |
| 228 | + | if ws.Root == nil { | |
| 229 | + | return ErrNothingToResize | |
| 230 | + | } | |
| 231 | + | if paneNode == nil { | |
| 232 | + | return fmt.Errorf("pane node is required") | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | target := paneNode | |
| 236 | + | if target.Parent != nil && target.Parent.IsStacked { | |
| 237 | + | target = target.Parent | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | actualDir := dir | |
| 241 | + | switch dir { | |
| 242 | + | case ResizeIncrease: | |
| 243 | + | actualDir = findSmartResizeDirection(target, true) | |
| 244 | + | case ResizeDecrease: | |
| 245 | + | actualDir = findSmartResizeDirection(target, false) | |
| 246 | + | } | |
| 247 | + | if actualDir == "" { | |
| 248 | + | return ErrNothingToResize | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | axis, ok := axisForResizeDirection(actualDir) | |
| 252 | + | if !ok { | |
| 253 | + | return ErrNothingToResize | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | splitNode := findNearestSplitForAxis(target, axis) | |
| 257 | + | if splitNode == nil { | |
| 258 | + | return ErrNothingToResize | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // We treat resize directions as moving the split divider. | |
| 262 | + | // SplitRatio is the proportion allocated to the first child (left/top). | |
| 263 | + | // - Moving the divider right/down increases SplitRatio. | |
| 264 | + | // - Moving the divider left/up decreases SplitRatio. | |
| 265 | + | delta := deltaForDividerMove(actualDir, stepPercent) | |
| 266 | + | ||
| 267 | + | minRatio := minPanePercent / 100.0 | |
| 268 | + | maxRatio := 1.0 - minRatio | |
| 269 | + | oldRatio := splitNode.SplitRatio | |
| 270 | + | splitNode.SplitRatio = clampFloat64(splitNode.SplitRatio+delta, minRatio, maxRatio) | |
| 271 | + | ||
| 272 | + | log.Debug(). | |
| 273 | + | Str("direction", string(dir)). | |
| 274 | + | Str("actual_direction", string(actualDir)). | |
| 275 | + | Float64("old_ratio", oldRatio). | |
| 276 | + | Float64("new_ratio", splitNode.SplitRatio). | |
| 277 | + | Msg("pane resized") | |
| 278 | + | ||
| 279 | + | return nil | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | type SetSplitRatioInput struct { | |
| 283 | + | Workspace *entity.Workspace | |
| 284 | + | SplitNodeID string | |
| 285 | + | Ratio float64 | |
| 286 | + | MinPanePercent float64 | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func (uc *ManagePanesUseCase) SetSplitRatio(ctx context.Context, input SetSplitRatioInput) error { | |
| 290 | + | log := logging.FromContext(ctx) | |
| 291 | + | if uc == nil { | |
| 292 | + | return fmt.Errorf("manage panes use case is nil") | |
| 293 | + | } | |
| 294 | + | if input.Workspace == nil { | |
| 295 | + | return fmt.Errorf("workspace is required") | |
| 296 | + | } | |
| 297 | + | if input.Workspace.Root == nil { | |
| 298 | + | return ErrNothingToResize | |
| 299 | + | } | |
| 300 | + | if input.SplitNodeID == "" { | |
| 301 | + | return fmt.Errorf("split node id is required") | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | var splitNode *entity.PaneNode | |
| 305 | + | input.Workspace.Root.Walk(func(node *entity.PaneNode) bool { | |
| 306 | + | if node.ID == input.SplitNodeID { | |
| 307 | + | splitNode = node | |
| 308 | + | return false | |
| 309 | + | } | |
| 310 | + | return true | |
| 311 | + | }) | |
| 312 | + | ||
| 313 | + | if splitNode == nil || !splitNode.IsSplit() { | |
| 314 | + | return fmt.Errorf("split node not found: %s", input.SplitNodeID) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | minRatio := input.MinPanePercent / 100.0 | |
| 318 | + | maxRatio := 1.0 - minRatio | |
| 319 | + | oldRatio := splitNode.SplitRatio | |
| 320 | + | clamped := clampFloat64(input.Ratio, minRatio, maxRatio) | |
| 321 | + | splitNode.SplitRatio = roundSplitRatio(clamped) | |
| 322 | + | ||
| 323 | + | log.Debug(). | |
| 324 | + | Str("split_node_id", input.SplitNodeID). | |
| 325 | + | Float64("old_ratio", oldRatio). | |
| 326 | + | Float64("new_ratio", splitNode.SplitRatio). | |
| 327 | + | Msg("split ratio set") | |
| 328 | + | ||
| 329 | + | return nil | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func findSmartResizeDirection(target *entity.PaneNode, growActive bool) ResizeDirection { | |
| 333 | + | splitNode, axis, isStartChild := findNearestSplitForResize(target) | |
| 334 | + | if splitNode == nil { | |
| 335 | + | return "" | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // Smart resize means "grow/shrink the active pane". | |
| 339 | + | // SplitRatio is the proportion allocated to the first child (left/top). | |
| 340 | + | // - If active is first child: grow by increasing ratio, shrink by decreasing. | |
| 341 | + | // - If active is second child: grow by decreasing ratio, shrink by increasing. | |
| 342 | + | growMeansIncreaseRatio := isStartChild | |
| 343 | + | if !growActive { | |
| 344 | + | growMeansIncreaseRatio = !growMeansIncreaseRatio | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | switch axis { | |
| 348 | + | case resizeAxisHorizontal: | |
| 349 | + | if growMeansIncreaseRatio { | |
| 350 | + | return ResizeIncreaseRight | |
| 351 | + | } | |
| 352 | + | return ResizeIncreaseLeft | |
| 353 | + | case resizeAxisVertical: | |
| 354 | + | if growMeansIncreaseRatio { | |
| 355 | + | return ResizeIncreaseDown | |
| 356 | + | } | |
| 357 | + | return ResizeIncreaseUp | |
| 358 | + | default: | |
| 359 | + | return "" | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // findNearestSplitForResize returns the nearest split ancestor for the active pane. | |
| 364 | + | // It prefers horizontal splits over vertical when both are available at the same depth. | |
| 365 | + | func findNearestSplitForResize(node *entity.PaneNode) (splitNode *entity.PaneNode, axis resizeAxis, isStartChild bool) { | |
| 366 | + | current := node | |
| 367 | + | for current != nil && current.Parent != nil { | |
| 368 | + | parent := current.Parent | |
| 369 | + | if parent.IsSplit() { | |
| 370 | + | isStartChild = parent.Left() == current | |
| 371 | + | switch parent.SplitDir { | |
| 372 | + | case entity.SplitHorizontal: | |
| 373 | + | return parent, resizeAxisHorizontal, isStartChild | |
| 374 | + | case entity.SplitVertical: | |
| 375 | + | return parent, resizeAxisVertical, isStartChild | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | current = parent | |
| 379 | + | } | |
| 380 | + | return nil, resizeAxisNone, false | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | type resizeAxis int | |
| 384 | + | ||
| 385 | + | const ( | |
| 386 | + | resizeAxisNone resizeAxis = iota | |
| 387 | + | resizeAxisHorizontal | |
| 388 | + | resizeAxisVertical | |
| 389 | + | ) | |
| 390 | + | ||
| 391 | + | func axisForResizeDirection(dir ResizeDirection) (resizeAxis, bool) { | |
| 392 | + | switch dir { | |
| 393 | + | case ResizeIncreaseLeft, ResizeIncreaseRight, ResizeDecreaseLeft, ResizeDecreaseRight: | |
| 394 | + | return resizeAxisHorizontal, true | |
| 395 | + | case ResizeIncreaseUp, ResizeIncreaseDown, ResizeDecreaseUp, ResizeDecreaseDown: | |
| 396 | + | return resizeAxisVertical, true | |
| 397 | + | default: | |
| 398 | + | return resizeAxisNone, false | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | func deltaForDividerMove(dir ResizeDirection, stepPercent float64) float64 { | |
| 403 | + | if stepPercent < 0 { | |
| 404 | + | stepPercent = -stepPercent | |
| 405 | + | } | |
| 406 | + | delta := stepPercent / 100.0 | |
| 407 | + | ||
| 408 | + | switch dir { | |
| 409 | + | case ResizeIncreaseRight, ResizeIncreaseDown: | |
| 410 | + | return delta | |
| 411 | + | case ResizeIncreaseLeft, ResizeIncreaseUp: | |
| 412 | + | return -delta | |
| 413 | + | case ResizeDecreaseRight, ResizeDecreaseDown: | |
| 414 | + | return -delta | |
| 415 | + | case ResizeDecreaseLeft, ResizeDecreaseUp: | |
| 416 | + | return delta | |
| 417 | + | default: | |
| 418 | + | return 0 | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | // findNearestSplitForAxis walks up the tree to find the nearest split matching the axis. | |
| 423 | + | func findNearestSplitForAxis(node *entity.PaneNode, axis resizeAxis) *entity.PaneNode { | |
| 424 | + | current := node | |
| 425 | + | for current != nil && current.Parent != nil { | |
| 426 | + | parent := current.Parent | |
| 427 | + | if parent.IsSplit() { | |
| 428 | + | if axis == resizeAxisHorizontal && parent.SplitDir == entity.SplitHorizontal { | |
| 429 | + | return parent | |
| 430 | + | } | |
| 431 | + | if axis == resizeAxisVertical && parent.SplitDir == entity.SplitVertical { | |
| 432 | + | return parent | |
| 433 | + | } | |
| 434 | + | } | |
| 435 | + | current = parent | |
| 436 | + | } | |
| 437 | + | return nil | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | const splitRatioRoundFactor = 100.0 | |
| 441 | + | ||
| 442 | + | func roundSplitRatio(ratio float64) float64 { | |
| 443 | + | // Keep snapshots stable and readable; avoids persisting noisy float values. | |
| 444 | + | return math.Round(ratio*splitRatioRoundFactor) / splitRatioRoundFactor | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | func clampFloat64(v, minVal, maxVal float64) float64 { | |
| 448 | + | if v < minVal { | |
| 449 | + | return minVal | |
| 450 | + | } | |
| 451 | + | if v > maxVal { | |
| 452 | + | return maxVal | |
| 453 | + | } | |
| 454 | + | return v | |
| 455 | + | } | |
| 456 | + | ||
| 169 | 457 | // Close removes a pane and promotes its sibling. | |
| 170 | 458 | // Returns the sibling node that was promoted, or nil if this was the last pane. | |
| 171 | 459 | func (uc *ManagePanesUseCase) Close(ctx context.Context, ws *entity.Workspace, paneNode *entity.PaneNode) (*entity.PaneNode, error) { | |
| @@ -803,6 +1091,568 @@ func (uc *ManagePanesUseCase) RemoveFromStack(ctx context.Context, stackNode *en | |||
| 803 | 1091 | return nil | |
| 804 | 1092 | } | |
| 805 | 1093 | ||
| 1094 | + | func (uc *ManagePanesUseCase) ConsumeOrExpel( | |
| 1095 | + | ctx context.Context, | |
| 1096 | + | ws *entity.Workspace, | |
| 1097 | + | activeNode *entity.PaneNode, | |
| 1098 | + | direction ConsumeOrExpelDirection, | |
| 1099 | + | ) (*ConsumeOrExpelResult, error) { | |
| 1100 | + | if uc == nil { | |
| 1101 | + | return nil, fmt.Errorf("manage panes use case is nil") | |
| 1102 | + | } | |
| 1103 | + | if ws == nil { | |
| 1104 | + | return nil, fmt.Errorf("workspace is required") | |
| 1105 | + | } | |
| 1106 | + | if ws.Root == nil { | |
| 1107 | + | return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil | |
| 1108 | + | } | |
| 1109 | + | if activeNode == nil { | |
| 1110 | + | return nil, fmt.Errorf("active pane node is required") | |
| 1111 | + | } | |
| 1112 | + | if activeNode.Pane == nil { | |
| 1113 | + | return nil, fmt.Errorf("active pane node has no pane") | |
| 1114 | + | } | |
| 1115 | + | ||
| 1116 | + | if ws.PaneCount() <= 1 { | |
| 1117 | + | return &ConsumeOrExpelResult{Action: "none", ErrorMessage: "Only one pane"}, nil | |
| 1118 | + | } | |
| 1119 | + | ||
| 1120 | + | // Expel if active pane is inside a stack. | |
| 1121 | + | if activeNode.Parent != nil && activeNode.Parent.IsStacked { | |
| 1122 | + | return uc.expelFromStack(ctx, ws, activeNode, direction) | |
| 1123 | + | } | |
| 1124 | + | ||
| 1125 | + | // Consume if active pane is not inside a stack. | |
| 1126 | + | return uc.consumeIntoSiblingStack(ctx, ws, activeNode, direction) | |
| 1127 | + | } | |
| 1128 | + | ||
| 1129 | + | func (*ManagePanesUseCase) consumeIntoSiblingStack( | |
| 1130 | + | ctx context.Context, | |
| 1131 | + | ws *entity.Workspace, | |
| 1132 | + | activeNode *entity.PaneNode, | |
| 1133 | + | direction ConsumeOrExpelDirection, | |
| 1134 | + | ) (*ConsumeOrExpelResult, error) { | |
| 1135 | + | log := logging.FromContext(ctx) | |
| 1136 | + | ||
| 1137 | + | sibling, _ := findAdjacentSiblingForConsumeOrExpel(activeNode, direction) | |
| 1138 | + | if sibling == nil { | |
| 1139 | + | sibling = fallbackSiblingForConsumeOrExpel(activeNode, direction) | |
| 1140 | + | } | |
| 1141 | + | if sibling == nil { | |
| 1142 | + | return &ConsumeOrExpelResult{Action: "none", ErrorMessage: directionNoSiblingMessage(direction)}, nil | |
| 1143 | + | } | |
| 1144 | + | ||
| 1145 | + | // Left/right consume cycles layout for immediate split siblings: | |
| 1146 | + | // 1) horizontal split -> vertical split (target on top) | |
| 1147 | + | // 2) vertical split -> stack (top then bottom) | |
| 1148 | + | if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight { | |
| 1149 | + | cycled, cycleAction, err := cycleHorizontalToVerticalThenStack(ws, activeNode, sibling, direction) | |
| 1150 | + | if err != nil { | |
| 1151 | + | return nil, err | |
| 1152 | + | } | |
| 1153 | + | if cycled { | |
| 1154 | + | switch cycleAction { | |
| 1155 | + | case "vertical_split": | |
| 1156 | + | log.Info(). | |
| 1157 | + | Str("pane_id", activeNode.ID). | |
| 1158 | + | Str("sibling_id", sibling.ID). | |
| 1159 | + | Msg("pane moved below sibling") | |
| 1160 | + | case "stack": | |
| 1161 | + | log.Info(). | |
| 1162 | + | Str("pane_id", activeNode.ID). | |
| 1163 | + | Str("sibling_id", sibling.ID). | |
| 1164 | + | Msg("split converted to stack") | |
| 1165 | + | } | |
| 1166 | + | return &ConsumeOrExpelResult{Action: "consumed"}, nil | |
| 1167 | + | } | |
| 1168 | + | } | |
| 1169 | + | ||
| 1170 | + | moved, err := detachLeafByPromotingSibling(ws, activeNode) | |
| 1171 | + | if err != nil { | |
| 1172 | + | return nil, err | |
| 1173 | + | } | |
| 1174 | + | moved.Parent = nil | |
| 1175 | + | ||
| 1176 | + | stackNode := sibling | |
| 1177 | + | if stackNode.IsLeaf() { | |
| 1178 | + | stackNode = convertLeafToStackContainer(stackNode) | |
| 1179 | + | } | |
| 1180 | + | if !stackNode.IsStacked { | |
| 1181 | + | return nil, fmt.Errorf("target sibling is not stackable") | |
| 1182 | + | } | |
| 1183 | + | ||
| 1184 | + | // Insert consumed pane at the bottom/end. | |
| 1185 | + | moved.Parent = stackNode | |
| 1186 | + | stackNode.Children = append(stackNode.Children, moved) | |
| 1187 | + | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |
| 1188 | + | ws.ActivePaneID = moved.Pane.ID | |
| 1189 | + | ||
| 1190 | + | log.Info(). | |
| 1191 | + | Str("pane_id", moved.ID). | |
| 1192 | + | Str("stack_id", stackNode.ID). | |
| 1193 | + | Int("stack_size", len(stackNode.Children)). | |
| 1194 | + | Msg("pane consumed into sibling stack") | |
| 1195 | + | ||
| 1196 | + | return &ConsumeOrExpelResult{Action: "consumed"}, nil | |
| 1197 | + | } | |
| 1198 | + | ||
| 1199 | + | func (uc *ManagePanesUseCase) expelFromStack( | |
| 1200 | + | ctx context.Context, | |
| 1201 | + | ws *entity.Workspace, | |
| 1202 | + | activeNode *entity.PaneNode, | |
| 1203 | + | direction ConsumeOrExpelDirection, | |
| 1204 | + | ) (*ConsumeOrExpelResult, error) { | |
| 1205 | + | log := logging.FromContext(ctx) | |
| 1206 | + | ||
| 1207 | + | stackNode := activeNode.Parent | |
| 1208 | + | if stackNode == nil || !stackNode.IsStacked { | |
| 1209 | + | return &ConsumeOrExpelResult{Action: "none"}, nil | |
| 1210 | + | } | |
| 1211 | + | ||
| 1212 | + | expelled, err := removeLeafFromStack(stackNode, activeNode) | |
| 1213 | + | if err != nil { | |
| 1214 | + | return nil, err | |
| 1215 | + | } | |
| 1216 | + | expelled.Parent = nil | |
| 1217 | + | ||
| 1218 | + | // If only one pane remains, dissolve stack into a leaf node. | |
| 1219 | + | if len(stackNode.Children) == 1 { | |
| 1220 | + | dissolveStackIntoLeaf(stackNode) | |
| 1221 | + | } | |
| 1222 | + | ||
| 1223 | + | // Split around the remaining container (stackNode) with expelled pane. | |
| 1224 | + | // For left/right, expel cycles stack -> vertical split first: | |
| 1225 | + | // - left: expelled pane above | |
| 1226 | + | // - right: expelled pane below | |
| 1227 | + | splitDirection := direction | |
| 1228 | + | switch direction { | |
| 1229 | + | case ConsumeOrExpelLeft: | |
| 1230 | + | splitDirection = ConsumeOrExpelUp | |
| 1231 | + | case ConsumeOrExpelRight: | |
| 1232 | + | splitDirection = ConsumeOrExpelDown | |
| 1233 | + | } | |
| 1234 | + | ||
| 1235 | + | if direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight { | |
| 1236 | + | err := splitExistingNodeWithMarker( | |
| 1237 | + | ws, | |
| 1238 | + | stackNode, | |
| 1239 | + | expelled, | |
| 1240 | + | splitDirection, | |
| 1241 | + | uc.idGenerator, | |
| 1242 | + | consumeOrExpelExpelCycleMarker, | |
| 1243 | + | ) | |
| 1244 | + | if err != nil { | |
| 1245 | + | return nil, err | |
| 1246 | + | } | |
| 1247 | + | } else { | |
| 1248 | + | if err := splitExistingNode(ws, stackNode, expelled, splitDirection, uc.idGenerator); err != nil { | |
| 1249 | + | return nil, err | |
| 1250 | + | } | |
| 1251 | + | } | |
| 1252 | + | ws.ActivePaneID = expelled.Pane.ID | |
| 1253 | + | ||
| 1254 | + | log.Info(). | |
| 1255 | + | Str("pane_id", expelled.ID). | |
| 1256 | + | Str("direction", string(direction)). | |
| 1257 | + | Msg("pane expelled from stack") | |
| 1258 | + | ||
| 1259 | + | return &ConsumeOrExpelResult{Action: "expelled"}, nil | |
| 1260 | + | } | |
| 1261 | + | ||
| 1262 | + | func directionNoSiblingMessage(direction ConsumeOrExpelDirection) string { | |
| 1263 | + | switch direction { | |
| 1264 | + | case ConsumeOrExpelLeft: | |
| 1265 | + | return "No pane to the left" | |
| 1266 | + | case ConsumeOrExpelRight: | |
| 1267 | + | return "No pane to the right" | |
| 1268 | + | case ConsumeOrExpelUp: | |
| 1269 | + | return "No pane above" | |
| 1270 | + | case ConsumeOrExpelDown: | |
| 1271 | + | return "No pane below" | |
| 1272 | + | default: | |
| 1273 | + | return "No pane in that direction" | |
| 1274 | + | } | |
| 1275 | + | } | |
| 1276 | + | ||
| 1277 | + | func findAdjacentSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) (*entity.PaneNode, string) { | |
| 1278 | + | if node == nil { | |
| 1279 | + | return nil, "" | |
| 1280 | + | } | |
| 1281 | + | ||
| 1282 | + | // Determine if we need to go horizontal or vertical. | |
| 1283 | + | isHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight | |
| 1284 | + | isForward := direction == ConsumeOrExpelRight || direction == ConsumeOrExpelDown | |
| 1285 | + | ||
| 1286 | + | // Walk up the tree to find an ancestor split matching our direction axis. | |
| 1287 | + | current := node | |
| 1288 | + | for current.Parent != nil { | |
| 1289 | + | parent := current.Parent | |
| 1290 | + | if parent.IsSplit() { | |
| 1291 | + | parentIsHorizontal := parent.SplitDir == entity.SplitHorizontal | |
| 1292 | + | if parentIsHorizontal == isHorizontal { | |
| 1293 | + | if isForward { | |
| 1294 | + | if parent.Right() != nil && parent.Left() == current { | |
| 1295 | + | return parent.Right(), "" | |
| 1296 | + | } | |
| 1297 | + | } else { | |
| 1298 | + | if parent.Left() != nil && parent.Right() == current { | |
| 1299 | + | return parent.Left(), "" | |
| 1300 | + | } | |
| 1301 | + | } | |
| 1302 | + | } | |
| 1303 | + | } | |
| 1304 | + | current = parent | |
| 1305 | + | } | |
| 1306 | + | ||
| 1307 | + | return nil, directionNoSiblingMessage(direction) | |
| 1308 | + | } | |
| 1309 | + | ||
| 1310 | + | func fallbackSiblingForConsumeOrExpel(node *entity.PaneNode, direction ConsumeOrExpelDirection) *entity.PaneNode { | |
| 1311 | + | if node == nil || node.Parent == nil { | |
| 1312 | + | return nil | |
| 1313 | + | } | |
| 1314 | + | ||
| 1315 | + | parent := node.Parent | |
| 1316 | + | if !parent.IsSplit() { | |
| 1317 | + | return nil | |
| 1318 | + | } | |
| 1319 | + | ||
| 1320 | + | axisHorizontal := direction == ConsumeOrExpelLeft || direction == ConsumeOrExpelRight | |
| 1321 | + | if axisHorizontal { | |
| 1322 | + | if parent.SplitDir == entity.SplitHorizontal { | |
| 1323 | + | return nil | |
| 1324 | + | } | |
| 1325 | + | } else { | |
| 1326 | + | if parent.SplitDir == entity.SplitVertical { | |
| 1327 | + | return nil | |
| 1328 | + | } | |
| 1329 | + | } | |
| 1330 | + | ||
| 1331 | + | // Cross-axis fallback is only used to enable the leaf cycling behavior | |
| 1332 | + | // (H -> V -> stack, and after expel: V -> H). It should never cause a leaf to | |
| 1333 | + | // consume into a stacked/split sibling when pressing an unrelated axis key. | |
| 1334 | + | var sibling *entity.PaneNode | |
| 1335 | + | if parent.Left() == node { | |
| 1336 | + | sibling = parent.Right() | |
| 1337 | + | } else if parent.Right() == node { | |
| 1338 | + | sibling = parent.Left() | |
| 1339 | + | } | |
| 1340 | + | if sibling == nil { | |
| 1341 | + | return nil | |
| 1342 | + | } | |
| 1343 | + | if !node.IsLeaf() || !sibling.IsLeaf() { | |
| 1344 | + | return nil | |
| 1345 | + | } | |
| 1346 | + | return sibling | |
| 1347 | + | } | |
| 1348 | + | ||
| 1349 | + | func cycleHorizontalToVerticalThenStack( | |
| 1350 | + | ws *entity.Workspace, | |
| 1351 | + | activeNode *entity.PaneNode, | |
| 1352 | + | sibling *entity.PaneNode, | |
| 1353 | + | direction ConsumeOrExpelDirection, | |
| 1354 | + | ) (cycled bool, cycleAction string, err error) { | |
| 1355 | + | if ws == nil { | |
| 1356 | + | return false, "", fmt.Errorf("workspace is required") | |
| 1357 | + | } | |
| 1358 | + | if activeNode == nil || sibling == nil { | |
| 1359 | + | return false, "", nil | |
| 1360 | + | } | |
| 1361 | + | if !activeNode.IsLeaf() || !sibling.IsLeaf() { | |
| 1362 | + | return false, "", nil | |
| 1363 | + | } | |
| 1364 | + | ||
| 1365 | + | parent := activeNode.Parent | |
| 1366 | + | if parent == nil || sibling.Parent != parent || !parent.IsSplit() { | |
| 1367 | + | return false, "", nil | |
| 1368 | + | } | |
| 1369 | + | ||
| 1370 | + | switch parent.SplitDir { | |
| 1371 | + | case entity.SplitHorizontal: | |
| 1372 | + | // Horizontal -> Vertical: target sibling should be on top. | |
| 1373 | + | parent.SplitDir = entity.SplitVertical | |
| 1374 | + | if parent.Children[0] != sibling { | |
| 1375 | + | parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0] | |
| 1376 | + | } | |
| 1377 | + | ws.ActivePaneID = activeNode.Pane.ID | |
| 1378 | + | return true, "vertical_split", nil | |
| 1379 | + | case entity.SplitVertical: | |
| 1380 | + | // Vertical -> Horizontal when pressing left/right (reverse of first step). | |
| 1381 | + | // This is used after expel: stack -> vertical; then Ctrl+[ / Ctrl+] should turn it into left/right. | |
| 1382 | + | if direction == ConsumeOrExpelRight && strings.Contains(parent.ID, consumeOrExpelExpelCycleMarker) { | |
| 1383 | + | parent.SplitDir = entity.SplitHorizontal | |
| 1384 | + | // Active should become the right child. | |
| 1385 | + | if parent.Children[1] != activeNode { | |
| 1386 | + | parent.Children[0], parent.Children[1] = parent.Children[1], parent.Children[0] | |
| 1387 | + | } | |
| 1388 | + | ws.ActivePaneID = activeNode.Pane.ID | |
| 1389 | + | return true, "horizontal_split", nil | |
| 1390 | + | } | |
| 1391 | + | ||
| 1392 | + | // Otherwise, vertical -> stack. | |
| 1393 | + | parent.SplitDir = entity.SplitNone | |
| 1394 | + | parent.SplitRatio = 0 | |
| 1395 | + | parent.IsStacked = true | |
| 1396 | + | parent.Pane = nil | |
| 1397 | + | if parent.Children[0] == activeNode { | |
| 1398 | + | parent.ActiveStackIndex = 0 | |
| 1399 | + | } else if parent.Children[1] == activeNode { | |
| 1400 | + | parent.ActiveStackIndex = 1 | |
| 1401 | + | } else { | |
| 1402 | + | parent.ActiveStackIndex = 0 | |
| 1403 | + | } | |
| 1404 | + | ws.ActivePaneID = activeNode.Pane.ID | |
| 1405 | + | return true, "stack", nil | |
| 1406 | + | default: | |
| 1407 | + | return false, "", nil | |
| 1408 | + | } | |
| 1409 | + | } | |
| 1410 | + | ||
| 1411 | + | func detachLeafByPromotingSibling(ws *entity.Workspace, leaf *entity.PaneNode) (*entity.PaneNode, error) { | |
| 1412 | + | if ws == nil { | |
| 1413 | + | return nil, fmt.Errorf("workspace is required") | |
| 1414 | + | } | |
| 1415 | + | if leaf == nil { | |
| 1416 | + | return nil, fmt.Errorf("leaf is required") | |
| 1417 | + | } | |
| 1418 | + | if !leaf.IsLeaf() { | |
| 1419 | + | return nil, fmt.Errorf("can only detach leaf node") | |
| 1420 | + | } | |
| 1421 | + | ||
| 1422 | + | parent := leaf.Parent | |
| 1423 | + | if parent == nil { | |
| 1424 | + | return nil, fmt.Errorf("cannot detach root leaf") | |
| 1425 | + | } | |
| 1426 | + | if !parent.IsSplit() { | |
| 1427 | + | return nil, fmt.Errorf("leaf parent is not a split") | |
| 1428 | + | } | |
| 1429 | + | ||
| 1430 | + | var sibling *entity.PaneNode | |
| 1431 | + | for _, child := range parent.Children { | |
| 1432 | + | if child != leaf { | |
| 1433 | + | sibling = child | |
| 1434 | + | break | |
| 1435 | + | } | |
| 1436 | + | } | |
| 1437 | + | if sibling == nil { | |
| 1438 | + | return nil, fmt.Errorf("no sibling found for leaf") | |
| 1439 | + | } | |
| 1440 | + | ||
| 1441 | + | grandparent := parent.Parent | |
| 1442 | + | if grandparent == nil { | |
| 1443 | + | ws.Root = sibling | |
| 1444 | + | sibling.Parent = nil | |
| 1445 | + | } else { | |
| 1446 | + | for i, child := range grandparent.Children { | |
| 1447 | + | if child == parent { | |
| 1448 | + | grandparent.Children[i] = sibling | |
| 1449 | + | break | |
| 1450 | + | } | |
| 1451 | + | } | |
| 1452 | + | sibling.Parent = grandparent | |
| 1453 | + | } | |
| 1454 | + | ||
| 1455 | + | leaf.Parent = nil | |
| 1456 | + | return leaf, nil | |
| 1457 | + | } | |
| 1458 | + | ||
| 1459 | + | func convertLeafToStackContainer(leaf *entity.PaneNode) *entity.PaneNode { | |
| 1460 | + | if leaf == nil || !leaf.IsLeaf() { | |
| 1461 | + | return leaf | |
| 1462 | + | } | |
| 1463 | + | ||
| 1464 | + | originalPane := leaf.Pane | |
| 1465 | + | originalChildNode := &entity.PaneNode{ | |
| 1466 | + | ID: leaf.ID + "_0", | |
| 1467 | + | Pane: originalPane, | |
| 1468 | + | Parent: leaf, | |
| 1469 | + | } | |
| 1470 | + | ||
| 1471 | + | leaf.Pane = nil | |
| 1472 | + | leaf.IsStacked = true | |
| 1473 | + | leaf.ActiveStackIndex = 0 | |
| 1474 | + | leaf.Children = []*entity.PaneNode{originalChildNode} | |
| 1475 | + | ||
| 1476 | + | return leaf | |
| 1477 | + | } | |
| 1478 | + | ||
| 1479 | + | func removeLeafFromStack(stackNode, leaf *entity.PaneNode) (*entity.PaneNode, error) { | |
| 1480 | + | if stackNode == nil { | |
| 1481 | + | return nil, fmt.Errorf("stack node is required") | |
| 1482 | + | } | |
| 1483 | + | if !stackNode.IsStacked { | |
| 1484 | + | return nil, fmt.Errorf("node is not a stack") | |
| 1485 | + | } | |
| 1486 | + | if leaf == nil || leaf.Pane == nil { | |
| 1487 | + | return nil, fmt.Errorf("leaf is required") | |
| 1488 | + | } | |
| 1489 | + | ||
| 1490 | + | idx := -1 | |
| 1491 | + | for i, child := range stackNode.Children { | |
| 1492 | + | if child == leaf { | |
| 1493 | + | idx = i | |
| 1494 | + | break | |
| 1495 | + | } | |
| 1496 | + | } | |
| 1497 | + | if idx < 0 { | |
| 1498 | + | return nil, fmt.Errorf("pane not found in stack") | |
| 1499 | + | } | |
| 1500 | + | ||
| 1501 | + | stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...) | |
| 1502 | + | if stackNode.ActiveStackIndex >= len(stackNode.Children) { | |
| 1503 | + | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |
| 1504 | + | } | |
| 1505 | + | if stackNode.ActiveStackIndex < 0 { | |
| 1506 | + | stackNode.ActiveStackIndex = 0 | |
| 1507 | + | } | |
| 1508 | + | ||
| 1509 | + | leaf.Parent = nil | |
| 1510 | + | return leaf, nil | |
| 1511 | + | } | |
| 1512 | + | ||
| 1513 | + | func dissolveStackIntoLeaf(stackNode *entity.PaneNode) { | |
| 1514 | + | if stackNode == nil || !stackNode.IsStacked || len(stackNode.Children) != 1 { | |
| 1515 | + | return | |
| 1516 | + | } | |
| 1517 | + | ||
| 1518 | + | remaining := stackNode.Children[0] | |
| 1519 | + | stackNode.Pane = remaining.Pane | |
| 1520 | + | stackNode.Children = nil | |
| 1521 | + | stackNode.IsStacked = false | |
| 1522 | + | stackNode.ActiveStackIndex = 0 | |
| 1523 | + | } | |
| 1524 | + | ||
| 1525 | + | func splitExistingNode( | |
| 1526 | + | ws *entity.Workspace, | |
| 1527 | + | targetNode *entity.PaneNode, | |
| 1528 | + | newSibling *entity.PaneNode, | |
| 1529 | + | direction ConsumeOrExpelDirection, | |
| 1530 | + | idGen IDGenerator, | |
| 1531 | + | ) error { | |
| 1532 | + | if ws == nil { | |
| 1533 | + | return fmt.Errorf("workspace is required") | |
| 1534 | + | } | |
| 1535 | + | if targetNode == nil { | |
| 1536 | + | return fmt.Errorf("target node is required") | |
| 1537 | + | } | |
| 1538 | + | if newSibling == nil { | |
| 1539 | + | return fmt.Errorf("new sibling node is required") | |
| 1540 | + | } | |
| 1541 | + | if idGen == nil { | |
| 1542 | + | return fmt.Errorf("id generator is required") | |
| 1543 | + | } | |
| 1544 | + | ||
| 1545 | + | var splitDir entity.SplitDirection | |
| 1546 | + | switch direction { | |
| 1547 | + | case ConsumeOrExpelLeft, ConsumeOrExpelRight: | |
| 1548 | + | splitDir = entity.SplitHorizontal | |
| 1549 | + | case ConsumeOrExpelUp, ConsumeOrExpelDown: | |
| 1550 | + | splitDir = entity.SplitVertical | |
| 1551 | + | default: | |
| 1552 | + | return fmt.Errorf("invalid direction: %s", direction) | |
| 1553 | + | } | |
| 1554 | + | ||
| 1555 | + | parentNode := &entity.PaneNode{ | |
| 1556 | + | ID: idGen(), | |
| 1557 | + | SplitDir: splitDir, | |
| 1558 | + | SplitRatio: 0.5, | |
| 1559 | + | Children: make([]*entity.PaneNode, 2), | |
| 1560 | + | } | |
| 1561 | + | ||
| 1562 | + | switch direction { | |
| 1563 | + | case ConsumeOrExpelLeft, ConsumeOrExpelUp: | |
| 1564 | + | parentNode.Children[0] = newSibling | |
| 1565 | + | parentNode.Children[1] = targetNode | |
| 1566 | + | case ConsumeOrExpelRight, ConsumeOrExpelDown: | |
| 1567 | + | parentNode.Children[0] = targetNode | |
| 1568 | + | parentNode.Children[1] = newSibling | |
| 1569 | + | } | |
| 1570 | + | ||
| 1571 | + | newSibling.Parent = parentNode | |
| 1572 | + | oldParent := targetNode.Parent | |
| 1573 | + | targetNode.Parent = parentNode | |
| 1574 | + | ||
| 1575 | + | if oldParent == nil { | |
| 1576 | + | ws.Root = parentNode | |
| 1577 | + | } else { | |
| 1578 | + | for i, child := range oldParent.Children { | |
| 1579 | + | if child == targetNode { | |
| 1580 | + | oldParent.Children[i] = parentNode | |
| 1581 | + | break | |
| 1582 | + | } | |
| 1583 | + | } | |
| 1584 | + | parentNode.Parent = oldParent | |
| 1585 | + | } | |
| 1586 | + | ||
| 1587 | + | return nil | |
| 1588 | + | } | |
| 1589 | + | ||
| 1590 | + | func splitExistingNodeWithMarker( | |
| 1591 | + | ws *entity.Workspace, | |
| 1592 | + | targetNode *entity.PaneNode, | |
| 1593 | + | newSibling *entity.PaneNode, | |
| 1594 | + | direction ConsumeOrExpelDirection, | |
| 1595 | + | idGen IDGenerator, | |
| 1596 | + | marker string, | |
| 1597 | + | ) error { | |
| 1598 | + | if ws == nil { | |
| 1599 | + | return fmt.Errorf("workspace is required") | |
| 1600 | + | } | |
| 1601 | + | if targetNode == nil { | |
| 1602 | + | return fmt.Errorf("target node is required") | |
| 1603 | + | } | |
| 1604 | + | if newSibling == nil { | |
| 1605 | + | return fmt.Errorf("new sibling node is required") | |
| 1606 | + | } | |
| 1607 | + | if idGen == nil { | |
| 1608 | + | return fmt.Errorf("id generator is required") | |
| 1609 | + | } | |
| 1610 | + | ||
| 1611 | + | var splitDir entity.SplitDirection | |
| 1612 | + | switch direction { | |
| 1613 | + | case ConsumeOrExpelLeft, ConsumeOrExpelRight: | |
| 1614 | + | splitDir = entity.SplitHorizontal | |
| 1615 | + | case ConsumeOrExpelUp, ConsumeOrExpelDown: | |
| 1616 | + | splitDir = entity.SplitVertical | |
| 1617 | + | default: | |
| 1618 | + | return fmt.Errorf("invalid direction: %s", direction) | |
| 1619 | + | } | |
| 1620 | + | ||
| 1621 | + | parentNode := &entity.PaneNode{ | |
| 1622 | + | ID: idGen() + marker, | |
| 1623 | + | SplitDir: splitDir, | |
| 1624 | + | SplitRatio: 0.5, | |
| 1625 | + | Children: make([]*entity.PaneNode, 2), | |
| 1626 | + | } | |
| 1627 | + | ||
| 1628 | + | switch direction { | |
| 1629 | + | case ConsumeOrExpelLeft, ConsumeOrExpelUp: | |
| 1630 | + | parentNode.Children[0] = newSibling | |
| 1631 | + | parentNode.Children[1] = targetNode | |
| 1632 | + | case ConsumeOrExpelRight, ConsumeOrExpelDown: | |
| 1633 | + | parentNode.Children[0] = targetNode | |
| 1634 | + | parentNode.Children[1] = newSibling | |
| 1635 | + | } | |
| 1636 | + | ||
| 1637 | + | newSibling.Parent = parentNode | |
| 1638 | + | oldParent := targetNode.Parent | |
| 1639 | + | targetNode.Parent = parentNode | |
| 1640 | + | ||
| 1641 | + | if oldParent == nil { | |
| 1642 | + | ws.Root = parentNode | |
| 1643 | + | } else { | |
| 1644 | + | for i, child := range oldParent.Children { | |
| 1645 | + | if child == targetNode { | |
| 1646 | + | oldParent.Children[i] = parentNode | |
| 1647 | + | break | |
| 1648 | + | } | |
| 1649 | + | } | |
| 1650 | + | parentNode.Parent = oldParent | |
| 1651 | + | } | |
| 1652 | + | ||
| 1653 | + | return nil | |
| 1654 | + | } | |
| 1655 | + | ||
| 806 | 1656 | // GetAllPanes returns all leaf panes in a workspace. | |
| 807 | 1657 | // | |
| 808 | 1658 | //nolint:revive // receiver required for interface consistency | |
A internal/application/usecase/manage_panes_consume_expel_test.go +438 -0
| @@ -0,0 +1,438 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_CyclesHorizontalToVerticalThenStack(t *testing.T) { | |
| 11 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 12 | + | ctx := context.Background() | |
| 13 | + | ||
| 14 | + | left := leaf("left") | |
| 15 | + | right := leaf("right") | |
| 16 | + | root := split(entity.SplitHorizontal, left, right) | |
| 17 | + | ws := &entity.Workspace{Root: root, ActivePaneID: right.Pane.ID} | |
| 18 | + | ||
| 19 | + | // First press: horizontal -> vertical split (left on top, right on bottom). | |
| 20 | + | res, err := uc.ConsumeOrExpel(ctx, ws, right, ConsumeOrExpelLeft) | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatalf("unexpected error: %v", err) | |
| 23 | + | } | |
| 24 | + | if res == nil || res.Action != "consumed" { | |
| 25 | + | t.Fatalf("result action=%v, want consumed", res) | |
| 26 | + | } | |
| 27 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |
| 28 | + | t.Fatalf("root should be a vertical split") | |
| 29 | + | } | |
| 30 | + | if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "left" { | |
| 31 | + | t.Fatalf("top pane=%v, want left", ws.Root.Left()) | |
| 32 | + | } | |
| 33 | + | if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "right" { | |
| 34 | + | t.Fatalf("bottom pane=%v, want right", ws.Root.Right()) | |
| 35 | + | } | |
| 36 | + | if ws.ActivePaneID != "right" { | |
| 37 | + | t.Fatalf("active=%s, want right", ws.ActivePaneID) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Second press: vertical split -> stack. | |
| 41 | + | active := ws.ActivePane() | |
| 42 | + | if active == nil { | |
| 43 | + | t.Fatalf("expected active pane") | |
| 44 | + | } | |
| 45 | + | res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelLeft) | |
| 46 | + | if err != nil { | |
| 47 | + | t.Fatalf("unexpected error: %v", err) | |
| 48 | + | } | |
| 49 | + | if res == nil || res.Action != "consumed" { | |
| 50 | + | t.Fatalf("result action=%v, want consumed", res) | |
| 51 | + | } | |
| 52 | + | if ws.Root == nil || !ws.Root.IsStacked { | |
| 53 | + | t.Fatalf("root should be a stack") | |
| 54 | + | } | |
| 55 | + | if got := panesInOrder(ws.Root); got != "left,right" { | |
| 56 | + | t.Fatalf("stack panes=%s, want left,right", got) | |
| 57 | + | } | |
| 58 | + | if ws.ActivePaneID != "right" { | |
| 59 | + | t.Fatalf("active=%s, want right", ws.ActivePaneID) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestManagePanesUseCase_ConsumeOrExpel_ConsumeRight_CyclesHorizontalToVerticalThenStack(t *testing.T) { | |
| 64 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 65 | + | ctx := context.Background() | |
| 66 | + | ||
| 67 | + | left := leaf("left") | |
| 68 | + | right := leaf("right") | |
| 69 | + | root := split(entity.SplitHorizontal, left, right) | |
| 70 | + | ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID} | |
| 71 | + | ||
| 72 | + | // First press: horizontal -> vertical split (right on top, left on bottom). | |
| 73 | + | res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelRight) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatalf("unexpected error: %v", err) | |
| 76 | + | } | |
| 77 | + | if res == nil || res.Action != "consumed" { | |
| 78 | + | t.Fatalf("result action=%v, want consumed", res) | |
| 79 | + | } | |
| 80 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |
| 81 | + | t.Fatalf("root should be a vertical split") | |
| 82 | + | } | |
| 83 | + | if ws.Root.Left() == nil || ws.Root.Left().Pane.ID != "right" { | |
| 84 | + | t.Fatalf("top pane=%v, want right", ws.Root.Left()) | |
| 85 | + | } | |
| 86 | + | if ws.Root.Right() == nil || ws.Root.Right().Pane.ID != "left" { | |
| 87 | + | t.Fatalf("bottom pane=%v, want left", ws.Root.Right()) | |
| 88 | + | } | |
| 89 | + | if ws.ActivePaneID != "left" { | |
| 90 | + | t.Fatalf("active=%s, want left", ws.ActivePaneID) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Second press: vertical split -> stack. | |
| 94 | + | active := ws.ActivePane() | |
| 95 | + | if active == nil { | |
| 96 | + | t.Fatalf("expected active pane") | |
| 97 | + | } | |
| 98 | + | res, err = uc.ConsumeOrExpel(ctx, ws, active, ConsumeOrExpelRight) | |
| 99 | + | if err != nil { | |
| 100 | + | t.Fatalf("unexpected error: %v", err) | |
| 101 | + | } | |
| 102 | + | if res == nil || res.Action != "consumed" { | |
| 103 | + | t.Fatalf("result action=%v, want consumed", res) | |
| 104 | + | } | |
| 105 | + | if ws.Root == nil || !ws.Root.IsStacked { | |
| 106 | + | t.Fatalf("root should be a stack") | |
| 107 | + | } | |
| 108 | + | if got := panesInOrder(ws.Root); got != "right,left" { | |
| 109 | + | t.Fatalf("stack panes=%s, want right,left", got) | |
| 110 | + | } | |
| 111 | + | if ws.ActivePaneID != "left" { | |
| 112 | + | t.Fatalf("active=%s, want left", ws.ActivePaneID) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestManagePanesUseCase_ConsumeOrExpel_ConsumeIntoExistingStack_AppendsToEnd(t *testing.T) { | |
| 117 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 118 | + | ctx := context.Background() | |
| 119 | + | ||
| 120 | + | stackNode := stack(leaf("a"), leaf("b")) | |
| 121 | + | c := leaf("c") | |
| 122 | + | root := split(entity.SplitHorizontal, stackNode, c) | |
| 123 | + | ws := &entity.Workspace{Root: root, ActivePaneID: c.Pane.ID} | |
| 124 | + | ||
| 125 | + | res, err := uc.ConsumeOrExpel(ctx, ws, c, ConsumeOrExpelLeft) | |
| 126 | + | if err != nil { | |
| 127 | + | t.Fatalf("unexpected error: %v", err) | |
| 128 | + | } | |
| 129 | + | if res == nil || res.Action != "consumed" { | |
| 130 | + | t.Fatalf("result action=%v, want consumed", res) | |
| 131 | + | } | |
| 132 | + | if ws.Root == nil || !ws.Root.IsStacked { | |
| 133 | + | t.Fatalf("root should be a stack") | |
| 134 | + | } | |
| 135 | + | if got := panesInOrder(ws.Root); got != "a,b,c" { | |
| 136 | + | t.Fatalf("stack panes=%s, want a,b,c", got) | |
| 137 | + | } | |
| 138 | + | if ws.ActivePaneID != "c" { | |
| 139 | + | t.Fatalf("active=%s, want c", ws.ActivePaneID) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestManagePanesUseCase_ConsumeOrExpel_ConsumeLeft_InVerticalSplitWithStackedSibling_Noops(t *testing.T) { | |
| 144 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 145 | + | ctx := context.Background() | |
| 146 | + | ||
| 147 | + | top := leaf("top") | |
| 148 | + | bottomStack := stack(leaf("a"), leaf("b")) | |
| 149 | + | root := split(entity.SplitVertical, top, bottomStack) | |
| 150 | + | ws := &entity.Workspace{Root: root, ActivePaneID: top.Pane.ID} | |
| 151 | + | ||
| 152 | + | res, err := uc.ConsumeOrExpel(ctx, ws, top, ConsumeOrExpelLeft) | |
| 153 | + | if err != nil { | |
| 154 | + | t.Fatalf("unexpected error: %v", err) | |
| 155 | + | } | |
| 156 | + | if res == nil || res.Action != "none" { | |
| 157 | + | t.Fatalf("result action=%v, want none", res) | |
| 158 | + | } | |
| 159 | + | if res.ErrorMessage != "No pane to the left" { | |
| 160 | + | t.Fatalf("error=%q, want %q", res.ErrorMessage, "No pane to the left") | |
| 161 | + | } | |
| 162 | + | if ws.Root != root { | |
| 163 | + | t.Fatalf("tree should be unchanged") | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestManagePanesUseCase_ConsumeOrExpel_Expel_DissolvesTwoPaneStack(t *testing.T) { | |
| 168 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 169 | + | ctx := context.Background() | |
| 170 | + | ||
| 171 | + | stackNode := stack(leaf("a"), leaf("b")) | |
| 172 | + | b := stackNode.Children[1] | |
| 173 | + | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |
| 174 | + | ||
| 175 | + | res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight) | |
| 176 | + | if err != nil { | |
| 177 | + | t.Fatalf("unexpected error: %v", err) | |
| 178 | + | } | |
| 179 | + | if res == nil || res.Action != "expelled" { | |
| 180 | + | t.Fatalf("result action=%v, want expelled", res) | |
| 181 | + | } | |
| 182 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |
| 183 | + | t.Fatalf("root should be a vertical split") | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | top := ws.Root.Left() | |
| 187 | + | bottom := ws.Root.Right() | |
| 188 | + | if top == nil || bottom == nil { | |
| 189 | + | t.Fatalf("split children should exist") | |
| 190 | + | } | |
| 191 | + | if !top.IsLeaf() || top.Pane == nil || top.Pane.ID != "a" { | |
| 192 | + | t.Fatalf("top child should be leaf a") | |
| 193 | + | } | |
| 194 | + | if !bottom.IsLeaf() || bottom.Pane == nil || bottom.Pane.ID != "b" { | |
| 195 | + | t.Fatalf("bottom child should be leaf b") | |
| 196 | + | } | |
| 197 | + | if ws.ActivePaneID != "b" { | |
| 198 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeRight_CyclesVerticalToHorizontal(t *testing.T) { | |
| 203 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 204 | + | ctx := context.Background() | |
| 205 | + | ||
| 206 | + | stackNode := stack(leaf("a"), leaf("b")) | |
| 207 | + | b := stackNode.Children[1] | |
| 208 | + | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |
| 209 | + | ||
| 210 | + | // First press (on stacked pane): stack -> vertical split with active on bottom. | |
| 211 | + | mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled") | |
| 212 | + | mustSplitRoot(t, ws, entity.SplitVertical) | |
| 213 | + | if string(ws.ActivePaneID) != "b" { | |
| 214 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | active := ws.ActivePane() | |
| 218 | + | mustLeafPaneID(t, active, "b") | |
| 219 | + | ||
| 220 | + | // Second press (on bottom pane): vertical -> horizontal split with active on right. | |
| 221 | + | mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelRight, "consumed") | |
| 222 | + | root := mustSplitRoot(t, ws, entity.SplitHorizontal) | |
| 223 | + | mustLeafPaneID(t, root.Left(), "a") | |
| 224 | + | mustLeafPaneID(t, root.Right(), "b") | |
| 225 | + | if string(ws.ActivePaneID) != "b" { | |
| 226 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestManagePanesUseCase_ConsumeOrExpel_ExpelThenConsumeLeft_ReturnsToStack(t *testing.T) { | |
| 231 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 232 | + | ctx := context.Background() | |
| 233 | + | ||
| 234 | + | stackNode := stack(leaf("a"), leaf("b")) | |
| 235 | + | b := stackNode.Children[1] | |
| 236 | + | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |
| 237 | + | ||
| 238 | + | // First press (on stacked pane): stack -> vertical split with active on bottom. | |
| 239 | + | mustConsumeOrExpelAction(t, uc, ctx, ws, b, ConsumeOrExpelRight, "expelled") | |
| 240 | + | mustSplitRoot(t, ws, entity.SplitVertical) | |
| 241 | + | if string(ws.ActivePaneID) != "b" { | |
| 242 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | active := ws.ActivePane() | |
| 246 | + | mustLeafPaneID(t, active, "b") | |
| 247 | + | ||
| 248 | + | // Second press (on bottom pane): vertical -> stack (return to original). | |
| 249 | + | mustConsumeOrExpelAction(t, uc, ctx, ws, active, ConsumeOrExpelLeft, "consumed") | |
| 250 | + | if ws.Root == nil || !ws.Root.IsStacked { | |
| 251 | + | t.Fatalf("root should be a stack") | |
| 252 | + | } | |
| 253 | + | if got := panesInOrder(ws.Root); got != "a,b" { | |
| 254 | + | t.Fatalf("stack panes=%s, want a,b", got) | |
| 255 | + | } | |
| 256 | + | if string(ws.ActivePaneID) != "b" { | |
| 257 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func TestManagePanesUseCase_ConsumeOrExpel_Expel_StackRemainsWhenMoreThanTwo(t *testing.T) { | |
| 262 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 263 | + | ctx := context.Background() | |
| 264 | + | ||
| 265 | + | stackNode := stack(leaf("a"), leaf("b"), leaf("c")) | |
| 266 | + | b := stackNode.Children[1] | |
| 267 | + | ws := &entity.Workspace{Root: stackNode, ActivePaneID: b.Pane.ID} | |
| 268 | + | ||
| 269 | + | res, err := uc.ConsumeOrExpel(ctx, ws, b, ConsumeOrExpelRight) | |
| 270 | + | if err != nil { | |
| 271 | + | t.Fatalf("unexpected error: %v", err) | |
| 272 | + | } | |
| 273 | + | if res == nil || res.Action != "expelled" { | |
| 274 | + | t.Fatalf("result action=%v, want expelled", res) | |
| 275 | + | } | |
| 276 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |
| 277 | + | t.Fatalf("root should be a vertical split") | |
| 278 | + | } | |
| 279 | + | top := ws.Root.Left() | |
| 280 | + | bottom := ws.Root.Right() | |
| 281 | + | if top == nil || !top.IsStacked { | |
| 282 | + | t.Fatalf("top child should be stack") | |
| 283 | + | } | |
| 284 | + | if panesInOrder(top) != "a,c" { | |
| 285 | + | t.Fatalf("remaining stack panes=%s, want a,c", panesInOrder(top)) | |
| 286 | + | } | |
| 287 | + | if bottom == nil || !bottom.IsLeaf() || bottom.Pane.ID != "b" { | |
| 288 | + | t.Fatalf("bottom child should be expelled leaf b") | |
| 289 | + | } | |
| 290 | + | if ws.ActivePaneID != "b" { | |
| 291 | + | t.Fatalf("active=%s, want b", ws.ActivePaneID) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func TestManagePanesUseCase_ConsumeOrExpel_NoSibling_ReturnsWarningAndNoop(t *testing.T) { | |
| 296 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 297 | + | ctx := context.Background() | |
| 298 | + | ||
| 299 | + | left := leaf("left") | |
| 300 | + | right := leaf("right") | |
| 301 | + | root := split(entity.SplitHorizontal, left, right) | |
| 302 | + | ws := &entity.Workspace{Root: root, ActivePaneID: left.Pane.ID} | |
| 303 | + | ||
| 304 | + | res, err := uc.ConsumeOrExpel(ctx, ws, left, ConsumeOrExpelLeft) | |
| 305 | + | if err != nil { | |
| 306 | + | t.Fatalf("unexpected error: %v", err) | |
| 307 | + | } | |
| 308 | + | if res == nil || res.Action != "none" { | |
| 309 | + | t.Fatalf("result action=%v, want none", res) | |
| 310 | + | } | |
| 311 | + | if res.ErrorMessage == "" { | |
| 312 | + | t.Fatalf("expected warning message") | |
| 313 | + | } | |
| 314 | + | if ws.Root != root { | |
| 315 | + | t.Fatalf("tree should be unchanged") | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func TestManagePanesUseCase_ConsumeOrExpel_NestedSplits_ConsumesImmediateSiblingOnly(t *testing.T) { | |
| 320 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 321 | + | ctx := context.Background() | |
| 322 | + | ||
| 323 | + | a := leaf("a") | |
| 324 | + | b := leaf("b") | |
| 325 | + | top := split(entity.SplitHorizontal, a, b) | |
| 326 | + | c := leaf("c") | |
| 327 | + | root := split(entity.SplitVertical, top, c) | |
| 328 | + | ws := &entity.Workspace{Root: root, ActivePaneID: a.Pane.ID} | |
| 329 | + | ||
| 330 | + | res, err := uc.ConsumeOrExpel(ctx, ws, a, ConsumeOrExpelDown) | |
| 331 | + | if err != nil { | |
| 332 | + | t.Fatalf("unexpected error: %v", err) | |
| 333 | + | } | |
| 334 | + | if res == nil || res.Action != "consumed" { | |
| 335 | + | t.Fatalf("result action=%v, want consumed", res) | |
| 336 | + | } | |
| 337 | + | if ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != entity.SplitVertical { | |
| 338 | + | t.Fatalf("root should remain a vertical split") | |
| 339 | + | } | |
| 340 | + | if ws.Root.Right() == nil || !ws.Root.Right().IsStacked { | |
| 341 | + | t.Fatalf("expected bottom pane to become a stack") | |
| 342 | + | } | |
| 343 | + | if panesInOrder(ws.Root.Right()) != "c,a" { | |
| 344 | + | t.Fatalf("bottom stack panes=%s, want c,a", panesInOrder(ws.Root.Right())) | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func TestManagePanesUseCase_ConsumeOrExpel_OnlyOnePane(t *testing.T) { | |
| 349 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 350 | + | ctx := context.Background() | |
| 351 | + | ||
| 352 | + | only := leaf("only") | |
| 353 | + | ws := &entity.Workspace{Root: only, ActivePaneID: only.Pane.ID} | |
| 354 | + | ||
| 355 | + | res, err := uc.ConsumeOrExpel(ctx, ws, only, ConsumeOrExpelLeft) | |
| 356 | + | if err != nil { | |
| 357 | + | t.Fatalf("unexpected error: %v", err) | |
| 358 | + | } | |
| 359 | + | if res == nil || res.Action != "none" { | |
| 360 | + | t.Fatalf("result action=%v, want none", res) | |
| 361 | + | } | |
| 362 | + | if res.ErrorMessage == "" { | |
| 363 | + | t.Fatalf("expected warning message") | |
| 364 | + | } | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func mustConsumeOrExpelAction( | |
| 368 | + | t *testing.T, | |
| 369 | + | uc *ManagePanesUseCase, | |
| 370 | + | ctx context.Context, | |
| 371 | + | ws *entity.Workspace, | |
| 372 | + | node *entity.PaneNode, | |
| 373 | + | direction ConsumeOrExpelDirection, | |
| 374 | + | wantAction string, | |
| 375 | + | ) { | |
| 376 | + | t.Helper() | |
| 377 | + | res, err := uc.ConsumeOrExpel(ctx, ws, node, direction) | |
| 378 | + | if err != nil { | |
| 379 | + | t.Fatalf("unexpected error: %v", err) | |
| 380 | + | } | |
| 381 | + | if res == nil || res.Action != wantAction { | |
| 382 | + | t.Fatalf("result action=%v, want %s", res, wantAction) | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | func mustSplitRoot(t *testing.T, ws *entity.Workspace, dir entity.SplitDirection) *entity.PaneNode { | |
| 387 | + | t.Helper() | |
| 388 | + | if ws == nil || ws.Root == nil || !ws.Root.IsSplit() || ws.Root.SplitDir != dir { | |
| 389 | + | t.Fatalf("root should be a %v split", dir) | |
| 390 | + | } | |
| 391 | + | return ws.Root | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func mustLeafPaneID(t *testing.T, node *entity.PaneNode, want string) { | |
| 395 | + | t.Helper() | |
| 396 | + | if node == nil || !node.IsLeaf() || node.Pane == nil || string(node.Pane.ID) != want { | |
| 397 | + | t.Fatalf("leaf pane=%v, want %s", node, want) | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | func leaf(id string) *entity.PaneNode { | |
| 402 | + | return &entity.PaneNode{ID: id, Pane: &entity.Pane{ID: entity.PaneID(id)}} | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | func split(dir entity.SplitDirection, left, right *entity.PaneNode) *entity.PaneNode { | |
| 406 | + | root := &entity.PaneNode{ID: "split", SplitDir: dir, SplitRatio: 0.5, Children: []*entity.PaneNode{left, right}} | |
| 407 | + | left.Parent = root | |
| 408 | + | right.Parent = root | |
| 409 | + | return root | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func stack(panes ...*entity.PaneNode) *entity.PaneNode { | |
| 413 | + | n := &entity.PaneNode{ID: "stack", IsStacked: true, ActiveStackIndex: 0} | |
| 414 | + | children := make([]*entity.PaneNode, 0, len(panes)) | |
| 415 | + | for _, p := range panes { | |
| 416 | + | p.Parent = n | |
| 417 | + | children = append(children, p) | |
| 418 | + | } | |
| 419 | + | n.Children = children | |
| 420 | + | return n | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | func panesInOrder(stackNode *entity.PaneNode) string { | |
| 424 | + | if stackNode == nil || len(stackNode.Children) == 0 { | |
| 425 | + | return "" | |
| 426 | + | } | |
| 427 | + | out := "" | |
| 428 | + | for i, child := range stackNode.Children { | |
| 429 | + | if child == nil || child.Pane == nil { | |
| 430 | + | continue | |
| 431 | + | } | |
| 432 | + | if i > 0 { | |
| 433 | + | out += "," | |
| 434 | + | } | |
| 435 | + | out += string(child.Pane.ID) | |
| 436 | + | } | |
| 437 | + | return out | |
| 438 | + | } | |
A internal/application/usecase/manage_panes_resize_test.go +163 -0
| @@ -0,0 +1,163 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestManagePanesUseCase_Resize_Errors(t *testing.T) { | |
| 12 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 13 | + | ||
| 14 | + | ctx := context.Background() | |
| 15 | + | ||
| 16 | + | err := uc.Resize(ctx, nil, nil, ResizeIncreaseDown, 5, 10) | |
| 17 | + | if err == nil { | |
| 18 | + | t.Fatalf("expected error when workspace is nil") | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | ws := &entity.Workspace{} | |
| 22 | + | err = uc.Resize(ctx, ws, nil, ResizeIncreaseDown, 5, 10) | |
| 23 | + | if err == nil { | |
| 24 | + | t.Fatalf("expected error when pane node is nil") | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Root nil should return ErrNothingToResize. | |
| 28 | + | leaf := &entity.PaneNode{ID: "p1", Pane: &entity.Pane{ID: "p1"}} | |
| 29 | + | err = uc.Resize(ctx, ws, leaf, ResizeIncreaseDown, 5, 10) | |
| 30 | + | if !errors.Is(err, ErrNothingToResize) { | |
| 31 | + | t.Fatalf("expected ErrNothingToResize, got %v", err) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestManagePanesUseCase_Resize_VerticalDividerMove(t *testing.T) { | |
| 36 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 37 | + | ctx := context.Background() | |
| 38 | + | ||
| 39 | + | top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}} | |
| 40 | + | bottom := &entity.PaneNode{ID: "bottom", Pane: &entity.Pane{ID: "bottom"}} | |
| 41 | + | root := &entity.PaneNode{ | |
| 42 | + | ID: "split", | |
| 43 | + | SplitDir: entity.SplitVertical, | |
| 44 | + | SplitRatio: 0.5, | |
| 45 | + | Children: []*entity.PaneNode{top, bottom}, | |
| 46 | + | } | |
| 47 | + | top.Parent = root | |
| 48 | + | bottom.Parent = root | |
| 49 | + | ||
| 50 | + | ws := &entity.Workspace{Root: root, ActivePaneID: "bottom"} | |
| 51 | + | ||
| 52 | + | // Moving divider down increases the first child's ratio. | |
| 53 | + | if err := uc.Resize(ctx, ws, bottom, ResizeIncreaseDown, 5.0, 10.0); err != nil { | |
| 54 | + | t.Fatalf("unexpected error: %v", err) | |
| 55 | + | } | |
| 56 | + | if got, want := root.SplitRatio, 0.55; got != want { | |
| 57 | + | t.Fatalf("split ratio = %v, want %v", got, want) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Moving divider up decreases the first child's ratio. | |
| 61 | + | if err := uc.Resize(ctx, ws, bottom, ResizeIncreaseUp, 5.0, 10.0); err != nil { | |
| 62 | + | t.Fatalf("unexpected error: %v", err) | |
| 63 | + | } | |
| 64 | + | if got, want := root.SplitRatio, 0.5; got != want { | |
| 65 | + | t.Fatalf("split ratio = %v, want %v", got, want) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestManagePanesUseCase_Resize_SmartResizeGrowsAndShrinksActivePane(t *testing.T) { | |
| 70 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 71 | + | ctx := context.Background() | |
| 72 | + | ||
| 73 | + | top := &entity.PaneNode{ID: "top", Pane: &entity.Pane{ID: "top"}} | |
| 74 | + | bottom := &entity.PaneNode{ID: "bottom", Pane: &entity.Pane{ID: "bottom"}} | |
| 75 | + | root := &entity.PaneNode{ | |
| 76 | + | ID: "split", | |
| 77 | + | SplitDir: entity.SplitVertical, | |
| 78 | + | SplitRatio: 0.5, | |
| 79 | + | Children: []*entity.PaneNode{top, bottom}, | |
| 80 | + | } | |
| 81 | + | top.Parent = root | |
| 82 | + | bottom.Parent = root | |
| 83 | + | ||
| 84 | + | ws := &entity.Workspace{Root: root, ActivePaneID: "bottom"} | |
| 85 | + | ||
| 86 | + | // Smart increase should grow the active (bottom) pane: decrease SplitRatio. | |
| 87 | + | if err := uc.Resize(ctx, ws, bottom, ResizeIncrease, 5.0, 10.0); err != nil { | |
| 88 | + | t.Fatalf("unexpected error: %v", err) | |
| 89 | + | } | |
| 90 | + | if got, want := root.SplitRatio, 0.45; got != want { | |
| 91 | + | t.Fatalf("split ratio = %v, want %v", got, want) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Smart decrease should shrink the active pane: increase SplitRatio. | |
| 95 | + | if err := uc.Resize(ctx, ws, bottom, ResizeDecrease, 5.0, 10.0); err != nil { | |
| 96 | + | t.Fatalf("unexpected error: %v", err) | |
| 97 | + | } | |
| 98 | + | if got, want := root.SplitRatio, 0.5; got != want { | |
| 99 | + | t.Fatalf("split ratio = %v, want %v", got, want) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestManagePanesUseCase_Resize_ClampsToMinPanePercent(t *testing.T) { | |
| 104 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 105 | + | ctx := context.Background() | |
| 106 | + | ||
| 107 | + | left := &entity.PaneNode{ID: "left", Pane: &entity.Pane{ID: "left"}} | |
| 108 | + | right := &entity.PaneNode{ID: "right", Pane: &entity.Pane{ID: "right"}} | |
| 109 | + | root := &entity.PaneNode{ | |
| 110 | + | ID: "split", | |
| 111 | + | SplitDir: entity.SplitHorizontal, | |
| 112 | + | SplitRatio: 0.9, | |
| 113 | + | Children: []*entity.PaneNode{left, right}, | |
| 114 | + | } | |
| 115 | + | left.Parent = root | |
| 116 | + | right.Parent = root | |
| 117 | + | ||
| 118 | + | ws := &entity.Workspace{Root: root, ActivePaneID: "left"} | |
| 119 | + | ||
| 120 | + | // maxRatio = 1 - (minPanePercent/100) = 0.9 | |
| 121 | + | if err := uc.Resize(ctx, ws, left, ResizeIncreaseRight, 5.0, 10.0); err != nil { | |
| 122 | + | t.Fatalf("unexpected error: %v", err) | |
| 123 | + | } | |
| 124 | + | if got, want := root.SplitRatio, 0.9; got != want { | |
| 125 | + | t.Fatalf("split ratio = %v, want %v", got, want) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestManagePanesUseCase_Resize_TargetsStackContainer(t *testing.T) { | |
| 130 | + | uc := NewManagePanesUseCase(func() string { return "id" }) | |
| 131 | + | ctx := context.Background() | |
| 132 | + | ||
| 133 | + | stackLeaf1 := &entity.PaneNode{ID: "s1", Pane: &entity.Pane{ID: "s1"}} | |
| 134 | + | stackLeaf2 := &entity.PaneNode{ID: "s2", Pane: &entity.Pane{ID: "s2"}} | |
| 135 | + | stack := &entity.PaneNode{ | |
| 136 | + | ID: "stack", | |
| 137 | + | IsStacked: true, | |
| 138 | + | ActiveStackIndex: 0, | |
| 139 | + | Children: []*entity.PaneNode{stackLeaf1, stackLeaf2}, | |
| 140 | + | } | |
| 141 | + | stackLeaf1.Parent = stack | |
| 142 | + | stackLeaf2.Parent = stack | |
| 143 | + | ||
| 144 | + | right := &entity.PaneNode{ID: "right", Pane: &entity.Pane{ID: "right"}} | |
| 145 | + | root := &entity.PaneNode{ | |
| 146 | + | ID: "split", | |
| 147 | + | SplitDir: entity.SplitHorizontal, | |
| 148 | + | SplitRatio: 0.5, | |
| 149 | + | Children: []*entity.PaneNode{stack, right}, | |
| 150 | + | } | |
| 151 | + | stack.Parent = root | |
| 152 | + | right.Parent = root | |
| 153 | + | ||
| 154 | + | ws := &entity.Workspace{Root: root, ActivePaneID: "s1"} | |
| 155 | + | ||
| 156 | + | // Pass the leaf inside the stack; Resize should act on the split above the stack. | |
| 157 | + | if err := uc.Resize(ctx, ws, stackLeaf1, ResizeIncreaseRight, 5.0, 10.0); err != nil { | |
| 158 | + | t.Fatalf("unexpected error: %v", err) | |
| 159 | + | } | |
| 160 | + | if got, want := root.SplitRatio, 0.55; got != want { | |
| 161 | + | t.Fatalf("split ratio = %v, want %v", got, want) | |
| 162 | + | } | |
| 163 | + | } | |
M internal/application/usecase/manage_zoom.go +45 -2
| @@ -13,20 +13,28 @@ import ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // ManageZoomUseCase handles per-domain zoom level operations. | |
| 16 | + | // It uses an LRU cache to avoid database queries on every navigation. | |
| 16 | 17 | type ManageZoomUseCase struct { | |
| 17 | 18 | zoomRepo repository.ZoomRepository | |
| 18 | 19 | defaultZoom float64 | |
| 20 | + | cache port.Cache[string, *entity.ZoomLevel] | |
| 19 | 21 | } | |
| 20 | 22 | ||
| 21 | 23 | // NewManageZoomUseCase creates a new zoom management use case. | |
| 22 | 24 | // defaultZoom is the zoom level to use when resetting (typically from config). | |
| 23 | - | func NewManageZoomUseCase(zoomRepo repository.ZoomRepository, defaultZoom float64) *ManageZoomUseCase { | |
| 25 | + | // cache is an LRU cache for zoom levels to avoid database queries on repeat visits. | |
| 26 | + | func NewManageZoomUseCase( | |
| 27 | + | zoomRepo repository.ZoomRepository, | |
| 28 | + | defaultZoom float64, | |
| 29 | + | cache port.Cache[string, *entity.ZoomLevel], | |
| 30 | + | ) *ManageZoomUseCase { | |
| 24 | 31 | if defaultZoom <= 0 { | |
| 25 | 32 | defaultZoom = entity.ZoomDefault | |
| 26 | 33 | } | |
| 27 | 34 | return &ManageZoomUseCase{ | |
| 28 | 35 | zoomRepo: zoomRepo, | |
| 29 | 36 | defaultZoom: defaultZoom, | |
| 37 | + | cache: cache, | |
| 30 | 38 | } | |
| 31 | 39 | } | |
| 32 | 40 | ||
| @@ -37,10 +45,20 @@ func (uc *ManageZoomUseCase) DefaultZoom() float64 { | |||
| 37 | 45 | ||
| 38 | 46 | // GetZoom retrieves the zoom level for a domain. | |
| 39 | 47 | // Returns the configured default zoom level if none is set. | |
| 48 | + | // Uses LRU cache to avoid database queries on repeat visits. | |
| 40 | 49 | func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entity.ZoomLevel, error) { | |
| 41 | 50 | log := logging.FromContext(ctx) | |
| 42 | - | log.Debug().Str("domain", domain).Msg("getting zoom level") | |
| 43 | 51 | ||
| 52 | + | // Check cache first (fast path - no I/O) | |
| 53 | + | if uc.cache != nil { | |
| 54 | + | if cached, ok := uc.cache.Get(domain); ok { | |
| 55 | + | log.Debug().Str("domain", domain).Float64("zoom", cached.ZoomFactor).Msg("zoom level from cache") | |
| 56 | + | return cached, nil | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Cache miss - fetch from database | |
| 61 | + | log.Debug().Str("domain", domain).Msg("zoom cache miss, querying database") | |
| 44 | 62 | zoom, err := uc.zoomRepo.Get(ctx, domain) | |
| 45 | 63 | if err != nil { | |
| 46 | 64 | return nil, fmt.Errorf("failed to get zoom level: %w", err) | |
| @@ -51,6 +69,11 @@ func (uc *ManageZoomUseCase) GetZoom(ctx context.Context, domain string) (*entit | |||
| 51 | 69 | log.Debug().Str("domain", domain).Float64("zoom", zoom.ZoomFactor).Msg("using default zoom") | |
| 52 | 70 | } | |
| 53 | 71 | ||
| 72 | + | // Cache for future lookups | |
| 73 | + | if uc.cache != nil { | |
| 74 | + | uc.cache.Set(domain, zoom) | |
| 75 | + | } | |
| 76 | + | ||
| 54 | 77 | return zoom, nil | |
| 55 | 78 | } | |
| 56 | 79 | ||
| @@ -64,6 +87,11 @@ func (uc *ManageZoomUseCase) SetZoom(ctx context.Context, domain string, factor | |||
| 64 | 87 | return fmt.Errorf("failed to set zoom level: %w", err) | |
| 65 | 88 | } | |
| 66 | 89 | ||
| 90 | + | // Update cache | |
| 91 | + | if uc.cache != nil { | |
| 92 | + | uc.cache.Set(domain, zoom) | |
| 93 | + | } | |
| 94 | + | ||
| 67 | 95 | log.Info().Str("domain", domain).Float64("factor", zoom.ZoomFactor).Msg("zoom level saved") | |
| 68 | 96 | return nil | |
| 69 | 97 | } | |
| @@ -77,6 +105,11 @@ func (uc *ManageZoomUseCase) ResetZoom(ctx context.Context, domain string) error | |||
| 77 | 105 | return fmt.Errorf("failed to reset zoom level: %w", err) | |
| 78 | 106 | } | |
| 79 | 107 | ||
| 108 | + | // Invalidate cache | |
| 109 | + | if uc.cache != nil { | |
| 110 | + | uc.cache.Remove(domain) | |
| 111 | + | } | |
| 112 | + | ||
| 80 | 113 | log.Info().Str("domain", domain).Msg("zoom level reset to default") | |
| 81 | 114 | return nil | |
| 82 | 115 | } | |
| @@ -98,6 +131,11 @@ func (uc *ManageZoomUseCase) ZoomIn(ctx context.Context, domain string, current | |||
| 98 | 131 | return nil, fmt.Errorf("failed to save zoom level: %w", err) | |
| 99 | 132 | } | |
| 100 | 133 | ||
| 134 | + | // Update cache | |
| 135 | + | if uc.cache != nil { | |
| 136 | + | uc.cache.Set(domain, zoom) | |
| 137 | + | } | |
| 138 | + | ||
| 101 | 139 | return zoom, nil | |
| 102 | 140 | } | |
| 103 | 141 | ||
| @@ -118,6 +156,11 @@ func (uc *ManageZoomUseCase) ZoomOut(ctx context.Context, domain string, current | |||
| 118 | 156 | return nil, fmt.Errorf("failed to save zoom level: %w", err) | |
| 119 | 157 | } | |
| 120 | 158 | ||
| 159 | + | // Update cache | |
| 160 | + | if uc.cache != nil { | |
| 161 | + | uc.cache.Set(domain, zoom) | |
| 162 | + | } | |
| 163 | + | ||
| 121 | 164 | return zoom, nil | |
| 122 | 165 | } | |
| 123 | 166 | ||
A internal/application/usecase/migrate_config.go +166 -0
| @@ -0,0 +1,166 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/bnema/dumber/internal/logging" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // CheckConfigMigrationInput holds the input for checking config migration. | |
| 11 | + | type CheckConfigMigrationInput struct{} | |
| 12 | + | ||
| 13 | + | // CheckConfigMigrationOutput holds the result of the migration check. | |
| 14 | + | type CheckConfigMigrationOutput struct { | |
| 15 | + | // NeedsMigration is true if there are missing keys. | |
| 16 | + | NeedsMigration bool | |
| 17 | + | // MissingKeys contains info about each missing key. | |
| 18 | + | MissingKeys []port.KeyInfo | |
| 19 | + | // ConfigFile is the path to the config file. | |
| 20 | + | ConfigFile string | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // DetectChangesInput holds the input for detecting config changes. | |
| 24 | + | type DetectChangesInput struct{} | |
| 25 | + | ||
| 26 | + | // DetectChangesOutput holds the result of change detection. | |
| 27 | + | type DetectChangesOutput struct { | |
| 28 | + | // HasChanges is true if any changes were detected. | |
| 29 | + | HasChanges bool | |
| 30 | + | // Changes contains all detected changes. | |
| 31 | + | Changes []port.KeyChange | |
| 32 | + | // DiffText is a formatted diff-like string representation. | |
| 33 | + | DiffText string | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // MigrateConfigInput holds the input for migrating config. | |
| 37 | + | type MigrateConfigInput struct{} | |
| 38 | + | ||
| 39 | + | // MigrateConfigOutput holds the result of the migration. | |
| 40 | + | type MigrateConfigOutput struct { | |
| 41 | + | // AddedKeys contains the keys that were added. | |
| 42 | + | AddedKeys []string | |
| 43 | + | // ConfigFile is the path to the config file. | |
| 44 | + | ConfigFile string | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // MigrateConfigUseCase handles config migration operations. | |
| 48 | + | type MigrateConfigUseCase struct { | |
| 49 | + | migrator port.ConfigMigrator | |
| 50 | + | diffFormatter port.DiffFormatter | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // NewMigrateConfigUseCase creates a new migrate config use case. | |
| 54 | + | func NewMigrateConfigUseCase(migrator port.ConfigMigrator, diffFormatter port.DiffFormatter) *MigrateConfigUseCase { | |
| 55 | + | return &MigrateConfigUseCase{ | |
| 56 | + | migrator: migrator, | |
| 57 | + | diffFormatter: diffFormatter, | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Check checks if the user config is missing any default keys. | |
| 62 | + | func (uc *MigrateConfigUseCase) Check(ctx context.Context, _ CheckConfigMigrationInput) (*CheckConfigMigrationOutput, error) { | |
| 63 | + | log := logging.FromContext(ctx) | |
| 64 | + | ||
| 65 | + | result, err := uc.migrator.CheckMigration() | |
| 66 | + | if err != nil { | |
| 67 | + | log.Warn().Err(err).Msg("config migration check failed") | |
| 68 | + | return nil, err | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // No migration needed | |
| 72 | + | if result == nil || len(result.MissingKeys) == 0 { | |
| 73 | + | log.Debug().Msg("config is up to date, no migration needed") | |
| 74 | + | return &CheckConfigMigrationOutput{ | |
| 75 | + | NeedsMigration: false, | |
| 76 | + | MissingKeys: nil, | |
| 77 | + | ConfigFile: "", | |
| 78 | + | }, nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Build key info for each missing key | |
| 82 | + | keyInfos := make([]port.KeyInfo, 0, len(result.MissingKeys)) | |
| 83 | + | for _, key := range result.MissingKeys { | |
| 84 | + | keyInfos = append(keyInfos, uc.migrator.GetKeyInfo(key)) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | log.Debug(). | |
| 88 | + | Int("missing_keys", len(result.MissingKeys)). | |
| 89 | + | Str("config_file", result.ConfigFile). | |
| 90 | + | Msg("config migration check completed") | |
| 91 | + | ||
| 92 | + | return &CheckConfigMigrationOutput{ | |
| 93 | + | NeedsMigration: true, | |
| 94 | + | MissingKeys: keyInfos, | |
| 95 | + | ConfigFile: result.ConfigFile, | |
| 96 | + | }, nil | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // DetectChanges detects all config changes and returns a diff-like output. | |
| 100 | + | func (uc *MigrateConfigUseCase) DetectChanges(ctx context.Context, _ DetectChangesInput) (*DetectChangesOutput, error) { | |
| 101 | + | log := logging.FromContext(ctx) | |
| 102 | + | ||
| 103 | + | changes, err := uc.migrator.DetectChanges() | |
| 104 | + | if err != nil { | |
| 105 | + | log.Warn().Err(err).Msg("config change detection failed") | |
| 106 | + | return nil, err | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | if len(changes) == 0 { | |
| 110 | + | log.Debug().Msg("no config changes detected") | |
| 111 | + | return &DetectChangesOutput{ | |
| 112 | + | HasChanges: false, | |
| 113 | + | Changes: nil, | |
| 114 | + | DiffText: "No changes detected.", | |
| 115 | + | }, nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | log.Debug(). | |
| 119 | + | Int("changes", len(changes)). | |
| 120 | + | Msg("config changes detected") | |
| 121 | + | ||
| 122 | + | return &DetectChangesOutput{ | |
| 123 | + | HasChanges: true, | |
| 124 | + | Changes: changes, | |
| 125 | + | DiffText: uc.diffFormatter.FormatChangesAsDiff(changes), | |
| 126 | + | }, nil | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // Execute adds missing default keys and removes deprecated keys from the user's config file. | |
| 130 | + | func (uc *MigrateConfigUseCase) Execute(ctx context.Context, _ MigrateConfigInput) (*MigrateConfigOutput, error) { | |
| 131 | + | log := logging.FromContext(ctx) | |
| 132 | + | ||
| 133 | + | // Check for any changes (added, removed, or renamed keys) | |
| 134 | + | changes, err := uc.migrator.DetectChanges() | |
| 135 | + | if err != nil { | |
| 136 | + | return nil, err | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | if len(changes) == 0 { | |
| 140 | + | log.Debug().Msg("no migration needed") | |
| 141 | + | return &MigrateConfigOutput{ | |
| 142 | + | AddedKeys: nil, | |
| 143 | + | ConfigFile: "", | |
| 144 | + | }, nil | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Get config file path for output | |
| 148 | + | configFile, _ := uc.migrator.GetConfigFile() | |
| 149 | + | ||
| 150 | + | // Perform migration | |
| 151 | + | appliedKeys, err := uc.migrator.Migrate() | |
| 152 | + | if err != nil { | |
| 153 | + | log.Error().Err(err).Msg("config migration failed") | |
| 154 | + | return nil, err | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | log.Info(). | |
| 158 | + | Int("applied_keys", len(appliedKeys)). | |
| 159 | + | Str("config_file", configFile). | |
| 160 | + | Msg("config migration completed") | |
| 161 | + | ||
| 162 | + | return &MigrateConfigOutput{ | |
| 163 | + | AddedKeys: appliedKeys, | |
| 164 | + | ConfigFile: configFile, | |
| 165 | + | }, nil | |
| 166 | + | } | |
A internal/application/usecase/migrate_config_test.go +373 -0
| @@ -0,0 +1,373 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestMigrateConfigUseCase_Check_NoMigrationNeeded(t *testing.T) { | |
| 17 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 18 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 19 | + | mockMigrator.EXPECT().CheckMigration().Return(nil, nil) | |
| 20 | + | ||
| 21 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 22 | + | ctx := context.Background() | |
| 23 | + | ||
| 24 | + | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | |
| 25 | + | ||
| 26 | + | require.NoError(t, err) | |
| 27 | + | assert.False(t, result.NeedsMigration) | |
| 28 | + | assert.Empty(t, result.MissingKeys) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestMigrateConfigUseCase_Check_MigrationNeeded(t *testing.T) { | |
| 32 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 33 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 34 | + | ||
| 35 | + | migrationResult := &port.MigrationResult{ | |
| 36 | + | MissingKeys: []string{"key1", "key2"}, | |
| 37 | + | ConfigFile: "/path/to/config.toml", | |
| 38 | + | } | |
| 39 | + | mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil) | |
| 40 | + | ||
| 41 | + | // Mock GetKeyInfo for each missing key | |
| 42 | + | mockMigrator.EXPECT().GetKeyInfo("key1").Return(port.KeyInfo{ | |
| 43 | + | Key: "key1", | |
| 44 | + | Type: "bool", | |
| 45 | + | DefaultValue: "true", | |
| 46 | + | }) | |
| 47 | + | mockMigrator.EXPECT().GetKeyInfo("key2").Return(port.KeyInfo{ | |
| 48 | + | Key: "key2", | |
| 49 | + | Type: "string", | |
| 50 | + | DefaultValue: `"default"`, | |
| 51 | + | }) | |
| 52 | + | ||
| 53 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 54 | + | ctx := context.Background() | |
| 55 | + | ||
| 56 | + | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | |
| 57 | + | ||
| 58 | + | require.NoError(t, err) | |
| 59 | + | assert.True(t, result.NeedsMigration) | |
| 60 | + | assert.Len(t, result.MissingKeys, 2) | |
| 61 | + | assert.Equal(t, "/path/to/config.toml", result.ConfigFile) | |
| 62 | + | ||
| 63 | + | // Verify key info | |
| 64 | + | assert.Equal(t, "key1", result.MissingKeys[0].Key) | |
| 65 | + | assert.Equal(t, "bool", result.MissingKeys[0].Type) | |
| 66 | + | assert.Equal(t, "key2", result.MissingKeys[1].Key) | |
| 67 | + | assert.Equal(t, "string", result.MissingKeys[1].Type) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestMigrateConfigUseCase_Check_Error(t *testing.T) { | |
| 71 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 72 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 73 | + | expectedErr := errors.New("check failed") | |
| 74 | + | mockMigrator.EXPECT().CheckMigration().Return(nil, expectedErr) | |
| 75 | + | ||
| 76 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 77 | + | ctx := context.Background() | |
| 78 | + | ||
| 79 | + | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | |
| 80 | + | ||
| 81 | + | require.Error(t, err) | |
| 82 | + | assert.Nil(t, result) | |
| 83 | + | assert.Equal(t, expectedErr, err) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestMigrateConfigUseCase_Execute_NoMigrationNeeded(t *testing.T) { | |
| 87 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 88 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 89 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, nil) | |
| 90 | + | ||
| 91 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 92 | + | ctx := context.Background() | |
| 93 | + | ||
| 94 | + | result, err := uc.Execute(ctx, MigrateConfigInput{}) | |
| 95 | + | ||
| 96 | + | require.NoError(t, err) | |
| 97 | + | assert.Empty(t, result.AddedKeys) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestMigrateConfigUseCase_Execute_Success(t *testing.T) { | |
| 101 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 102 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 103 | + | ||
| 104 | + | changes := []port.KeyChange{ | |
| 105 | + | {Type: port.KeyChangeAdded, NewKey: "key1"}, | |
| 106 | + | {Type: port.KeyChangeAdded, NewKey: "key2"}, | |
| 107 | + | } | |
| 108 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 109 | + | mockMigrator.EXPECT().GetConfigFile().Return("/path/to/config.toml", nil) | |
| 110 | + | mockMigrator.EXPECT().Migrate().Return([]string{"key1", "key2"}, nil) | |
| 111 | + | ||
| 112 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 113 | + | ctx := context.Background() | |
| 114 | + | ||
| 115 | + | result, err := uc.Execute(ctx, MigrateConfigInput{}) | |
| 116 | + | ||
| 117 | + | require.NoError(t, err) | |
| 118 | + | assert.Len(t, result.AddedKeys, 2) | |
| 119 | + | assert.Equal(t, "/path/to/config.toml", result.ConfigFile) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestMigrateConfigUseCase_Execute_MigrateError(t *testing.T) { | |
| 123 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 124 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 125 | + | ||
| 126 | + | changes := []port.KeyChange{ | |
| 127 | + | {Type: port.KeyChangeAdded, NewKey: "key1"}, | |
| 128 | + | } | |
| 129 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 130 | + | mockMigrator.EXPECT().GetConfigFile().Return("/path/to/config.toml", nil) | |
| 131 | + | ||
| 132 | + | expectedErr := errors.New("migrate failed") | |
| 133 | + | mockMigrator.EXPECT().Migrate().Return(nil, expectedErr) | |
| 134 | + | ||
| 135 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 136 | + | ctx := context.Background() | |
| 137 | + | ||
| 138 | + | result, err := uc.Execute(ctx, MigrateConfigInput{}) | |
| 139 | + | ||
| 140 | + | require.Error(t, err) | |
| 141 | + | assert.Nil(t, result) | |
| 142 | + | assert.Equal(t, expectedErr, err) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestMigrateConfigUseCase_Execute_DetectChangesError(t *testing.T) { | |
| 146 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 147 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 148 | + | ||
| 149 | + | expectedErr := errors.New("detect changes failed") | |
| 150 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr) | |
| 151 | + | ||
| 152 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 153 | + | ctx := context.Background() | |
| 154 | + | ||
| 155 | + | result, err := uc.Execute(ctx, MigrateConfigInput{}) | |
| 156 | + | ||
| 157 | + | require.Error(t, err) | |
| 158 | + | assert.Nil(t, result) | |
| 159 | + | assert.Equal(t, expectedErr, err) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestMigrateConfigUseCase_Check_EmptyMissingKeys(t *testing.T) { | |
| 163 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 164 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 165 | + | ||
| 166 | + | // Return a result with empty missing keys | |
| 167 | + | migrationResult := &port.MigrationResult{ | |
| 168 | + | MissingKeys: []string{}, | |
| 169 | + | ConfigFile: "/path/to/config.toml", | |
| 170 | + | } | |
| 171 | + | mockMigrator.EXPECT().CheckMigration().Return(migrationResult, nil) | |
| 172 | + | ||
| 173 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 174 | + | ctx := context.Background() | |
| 175 | + | ||
| 176 | + | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | |
| 177 | + | ||
| 178 | + | require.NoError(t, err) | |
| 179 | + | assert.False(t, result.NeedsMigration) | |
| 180 | + | assert.Empty(t, result.MissingKeys) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestNewMigrateConfigUseCase(t *testing.T) { | |
| 184 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 185 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 186 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 187 | + | ||
| 188 | + | assert.NotNil(t, uc) | |
| 189 | + | assert.Equal(t, mockMigrator, uc.migrator) | |
| 190 | + | assert.Equal(t, mockFormatter, uc.diffFormatter) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // Ensure mock expectations are set up correctly | |
| 194 | + | func TestMigrateConfigUseCase_MockExpectations(t *testing.T) { | |
| 195 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 196 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 197 | + | ||
| 198 | + | // Set up expectations with mock.Anything for flexibility | |
| 199 | + | mockMigrator.On("CheckMigration").Return(&port.MigrationResult{ | |
| 200 | + | MissingKeys: []string{"test.key"}, | |
| 201 | + | ConfigFile: "/test/config.toml", | |
| 202 | + | }, nil).Once() | |
| 203 | + | ||
| 204 | + | mockMigrator.On("GetKeyInfo", mock.AnythingOfType("string")).Return(port.KeyInfo{ | |
| 205 | + | Key: "test.key", | |
| 206 | + | Type: "string", | |
| 207 | + | DefaultValue: `"test"`, | |
| 208 | + | }).Once() | |
| 209 | + | ||
| 210 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 211 | + | ctx := context.Background() | |
| 212 | + | ||
| 213 | + | result, err := uc.Check(ctx, CheckConfigMigrationInput{}) | |
| 214 | + | ||
| 215 | + | require.NoError(t, err) | |
| 216 | + | assert.True(t, result.NeedsMigration) | |
| 217 | + | mockMigrator.AssertExpectations(t) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestMigrateConfigUseCase_DetectChanges_NoChanges(t *testing.T) { | |
| 221 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 222 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 223 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, nil) | |
| 224 | + | ||
| 225 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 226 | + | ctx := context.Background() | |
| 227 | + | ||
| 228 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 229 | + | ||
| 230 | + | require.NoError(t, err) | |
| 231 | + | assert.False(t, result.HasChanges) | |
| 232 | + | assert.Empty(t, result.Changes) | |
| 233 | + | assert.Equal(t, "No changes detected.", result.DiffText) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func TestMigrateConfigUseCase_DetectChanges_WithAddedKey(t *testing.T) { | |
| 237 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 238 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 239 | + | ||
| 240 | + | changes := []port.KeyChange{ | |
| 241 | + | { | |
| 242 | + | Type: port.KeyChangeAdded, | |
| 243 | + | NewKey: "workspace.styling.mode_indicator_toaster_enabled", | |
| 244 | + | NewValue: "true", | |
| 245 | + | }, | |
| 246 | + | } | |
| 247 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 248 | + | mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n + workspace.styling.mode_indicator_toaster_enabled = true\n") | |
| 249 | + | ||
| 250 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 251 | + | ctx := context.Background() | |
| 252 | + | ||
| 253 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 254 | + | ||
| 255 | + | require.NoError(t, err) | |
| 256 | + | assert.True(t, result.HasChanges) | |
| 257 | + | assert.Len(t, result.Changes, 1) | |
| 258 | + | assert.Contains(t, result.DiffText, "+ workspace.styling.mode_indicator_toaster_enabled") | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func TestMigrateConfigUseCase_DetectChanges_WithRenamedKey(t *testing.T) { | |
| 262 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 263 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 264 | + | ||
| 265 | + | changes := []port.KeyChange{ | |
| 266 | + | { | |
| 267 | + | Type: port.KeyChangeRenamed, | |
| 268 | + | OldKey: "workspace.styling.pane_mode_border_color", | |
| 269 | + | NewKey: "workspace.styling.pane_mode_color", | |
| 270 | + | OldValue: `"#4A90E2"`, | |
| 271 | + | }, | |
| 272 | + | } | |
| 273 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 274 | + | mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n ~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color\n (value: \"#4A90E2\")\n") | |
| 275 | + | ||
| 276 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 277 | + | ctx := context.Background() | |
| 278 | + | ||
| 279 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 280 | + | ||
| 281 | + | require.NoError(t, err) | |
| 282 | + | assert.True(t, result.HasChanges) | |
| 283 | + | assert.Len(t, result.Changes, 1) | |
| 284 | + | assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color") | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func TestMigrateConfigUseCase_DetectChanges_WithRemovedKey(t *testing.T) { | |
| 288 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 289 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 290 | + | ||
| 291 | + | changes := []port.KeyChange{ | |
| 292 | + | { | |
| 293 | + | Type: port.KeyChangeRemoved, | |
| 294 | + | OldKey: "workspace.styling.pane_mode_border_width", | |
| 295 | + | OldValue: "4", | |
| 296 | + | }, | |
| 297 | + | } | |
| 298 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 299 | + | mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return("Config migration changes:\n\n - workspace.styling.pane_mode_border_width = 4 (deprecated)\n") | |
| 300 | + | ||
| 301 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 302 | + | ctx := context.Background() | |
| 303 | + | ||
| 304 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 305 | + | ||
| 306 | + | require.NoError(t, err) | |
| 307 | + | assert.True(t, result.HasChanges) | |
| 308 | + | assert.Len(t, result.Changes, 1) | |
| 309 | + | assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width") | |
| 310 | + | assert.Contains(t, result.DiffText, "(deprecated)") | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func TestMigrateConfigUseCase_DetectChanges_Error(t *testing.T) { | |
| 314 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 315 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 316 | + | expectedErr := errors.New("detect changes failed") | |
| 317 | + | mockMigrator.EXPECT().DetectChanges().Return(nil, expectedErr) | |
| 318 | + | ||
| 319 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 320 | + | ctx := context.Background() | |
| 321 | + | ||
| 322 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 323 | + | ||
| 324 | + | require.Error(t, err) | |
| 325 | + | assert.Nil(t, result) | |
| 326 | + | assert.Equal(t, expectedErr, err) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func TestMigrateConfigUseCase_DetectChanges_MultipleChanges(t *testing.T) { | |
| 330 | + | mockMigrator := mocks.NewMockConfigMigrator(t) | |
| 331 | + | mockFormatter := mocks.NewMockDiffFormatter(t) | |
| 332 | + | ||
| 333 | + | changes := []port.KeyChange{ | |
| 334 | + | { | |
| 335 | + | Type: port.KeyChangeAdded, | |
| 336 | + | NewKey: "workspace.styling.mode_border_width", | |
| 337 | + | NewValue: "4", | |
| 338 | + | }, | |
| 339 | + | { | |
| 340 | + | Type: port.KeyChangeRenamed, | |
| 341 | + | OldKey: "workspace.styling.pane_mode_border_color", | |
| 342 | + | NewKey: "workspace.styling.pane_mode_color", | |
| 343 | + | OldValue: `"#4A90E2"`, | |
| 344 | + | }, | |
| 345 | + | { | |
| 346 | + | Type: port.KeyChangeRemoved, | |
| 347 | + | OldKey: "workspace.styling.pane_mode_border_width", | |
| 348 | + | OldValue: "4", | |
| 349 | + | }, | |
| 350 | + | } | |
| 351 | + | mockMigrator.EXPECT().DetectChanges().Return(changes, nil) | |
| 352 | + | mockFormatter.EXPECT().FormatChangesAsDiff(changes).Return( | |
| 353 | + | "Config migration changes:\n\n" + | |
| 354 | + | " + workspace.styling.mode_border_width = 4\n" + | |
| 355 | + | " ~ workspace.styling.pane_mode_border_color -> workspace.styling.pane_mode_color\n" + | |
| 356 | + | " (value: \"#4A90E2\")\n" + | |
| 357 | + | " - workspace.styling.pane_mode_border_width = 4 (deprecated)\n", | |
| 358 | + | ) | |
| 359 | + | ||
| 360 | + | uc := NewMigrateConfigUseCase(mockMigrator, mockFormatter) | |
| 361 | + | ctx := context.Background() | |
| 362 | + | ||
| 363 | + | result, err := uc.DetectChanges(ctx, DetectChangesInput{}) | |
| 364 | + | ||
| 365 | + | require.NoError(t, err) | |
| 366 | + | assert.True(t, result.HasChanges) | |
| 367 | + | assert.Len(t, result.Changes, 3) | |
| 368 | + | ||
| 369 | + | // Verify diff contains all change types | |
| 370 | + | assert.Contains(t, result.DiffText, "+ workspace.styling.mode_border_width") | |
| 371 | + | assert.Contains(t, result.DiffText, "~ workspace.styling.pane_mode_border_color") | |
| 372 | + | assert.Contains(t, result.DiffText, "- workspace.styling.pane_mode_border_width") | |
| 373 | + | } | |
A internal/application/usecase/move_pane_to_tab.go +382 -0
| @@ -0,0 +1,382 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // MovePaneToTabUseCase moves a pane from one tab's workspace to another. | |
| 10 | + | // | |
| 11 | + | // It is pure domain manipulation: it depends only on entities and an ID generator. | |
| 12 | + | type MovePaneToTabUseCase struct { | |
| 13 | + | idGenerator IDGenerator | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func NewMovePaneToTabUseCase(idGenerator IDGenerator) *MovePaneToTabUseCase { | |
| 17 | + | return &MovePaneToTabUseCase{idGenerator: idGenerator} | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | type MovePaneToTabInput struct { | |
| 21 | + | TabList *entity.TabList | |
| 22 | + | SourceTabID entity.TabID | |
| 23 | + | SourcePaneID entity.PaneID | |
| 24 | + | TargetTabID entity.TabID // empty means create new tab | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type MovePaneToTabOutput struct { | |
| 28 | + | TargetTab *entity.Tab | |
| 29 | + | MovedPaneNode *entity.PaneNode | |
| 30 | + | SourceTabClosed bool | |
| 31 | + | NewTabCreated bool | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (uc *MovePaneToTabUseCase) Execute(input MovePaneToTabInput) (*MovePaneToTabOutput, error) { | |
| 35 | + | if err := validateMovePaneToTabInput(uc, input); err != nil { | |
| 36 | + | return nil, err | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | sourceTab, err := findSourceTab(input.TabList, input.SourceTabID) | |
| 40 | + | if err != nil { | |
| 41 | + | return nil, err | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | movedPane, sourceNode, err := findSourcePane(sourceTab.Workspace, input.SourcePaneID) | |
| 45 | + | if err != nil { | |
| 46 | + | return nil, err | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | if detachErr := detachPaneFromWorkspace(sourceTab.Workspace, sourceNode); detachErr != nil { | |
| 50 | + | return nil, detachErr | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | sourceTabClosed := closeSourceTabIfEmpty(input.TabList, sourceTab) | |
| 54 | + | ||
| 55 | + | targetTab, newTabCreated, err := uc.resolveTargetTab(input.TabList, input.TargetTabID, movedPane) | |
| 56 | + | if err != nil { | |
| 57 | + | return nil, err | |
| 58 | + | } | |
| 59 | + | if targetTab == nil || targetTab.Workspace == nil { | |
| 60 | + | return nil, fmt.Errorf("target tab/workspace is nil") | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | if newTabCreated { | |
| 64 | + | return &MovePaneToTabOutput{ | |
| 65 | + | TargetTab: targetTab, | |
| 66 | + | MovedPaneNode: targetTab.Workspace.Root, | |
| 67 | + | SourceTabClosed: sourceTabClosed, | |
| 68 | + | NewTabCreated: true, | |
| 69 | + | }, nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | movedNode, err := uc.insertIntoTargetWorkspace(targetTab.Workspace, movedPane) | |
| 73 | + | if err != nil { | |
| 74 | + | return nil, err | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | return &MovePaneToTabOutput{ | |
| 78 | + | TargetTab: targetTab, | |
| 79 | + | MovedPaneNode: movedNode, | |
| 80 | + | SourceTabClosed: sourceTabClosed, | |
| 81 | + | NewTabCreated: false, | |
| 82 | + | }, nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func validateMovePaneToTabInput(uc *MovePaneToTabUseCase, input MovePaneToTabInput) error { | |
| 86 | + | if uc == nil { | |
| 87 | + | return fmt.Errorf("move pane to tab use case is nil") | |
| 88 | + | } | |
| 89 | + | if input.TabList == nil { | |
| 90 | + | return fmt.Errorf("tab list is required") | |
| 91 | + | } | |
| 92 | + | if input.SourceTabID == "" { | |
| 93 | + | return fmt.Errorf("source tab id is required") | |
| 94 | + | } | |
| 95 | + | if input.SourcePaneID == "" { | |
| 96 | + | return fmt.Errorf("source pane id is required") | |
| 97 | + | } | |
| 98 | + | if input.TargetTabID == input.SourceTabID { | |
| 99 | + | return fmt.Errorf("cannot move pane to same tab") | |
| 100 | + | } | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func findSourceTab(tl *entity.TabList, id entity.TabID) (*entity.Tab, error) { | |
| 105 | + | sourceTab := tl.Find(id) | |
| 106 | + | if sourceTab == nil { | |
| 107 | + | return nil, fmt.Errorf("source tab not found: %s", id) | |
| 108 | + | } | |
| 109 | + | if sourceTab.Workspace == nil { | |
| 110 | + | return nil, fmt.Errorf("source workspace is nil") | |
| 111 | + | } | |
| 112 | + | return sourceTab, nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func findSourcePane(ws *entity.Workspace, paneID entity.PaneID) (*entity.Pane, *entity.PaneNode, error) { | |
| 116 | + | if ws == nil { | |
| 117 | + | return nil, nil, fmt.Errorf("workspace is required") | |
| 118 | + | } | |
| 119 | + | sourceNode := ws.FindPane(paneID) | |
| 120 | + | if sourceNode == nil || sourceNode.Pane == nil { | |
| 121 | + | return nil, nil, fmt.Errorf("source pane not found: %s", paneID) | |
| 122 | + | } | |
| 123 | + | return sourceNode.Pane, sourceNode, nil | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func closeSourceTabIfEmpty(tl *entity.TabList, sourceTab *entity.Tab) bool { | |
| 127 | + | if tl == nil || sourceTab == nil || sourceTab.Workspace == nil { | |
| 128 | + | return false | |
| 129 | + | } | |
| 130 | + | if sourceTab.Workspace.PaneCount() != 0 { | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | return tl.Remove(sourceTab.ID) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (uc *MovePaneToTabUseCase) resolveTargetTab( | |
| 137 | + | tl *entity.TabList, | |
| 138 | + | targetID entity.TabID, | |
| 139 | + | movedPane *entity.Pane, | |
| 140 | + | ) (*entity.Tab, bool, error) { | |
| 141 | + | if tl == nil { | |
| 142 | + | return nil, false, fmt.Errorf("tab list is required") | |
| 143 | + | } | |
| 144 | + | if movedPane == nil { | |
| 145 | + | return nil, false, fmt.Errorf("moved pane is required") | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | if targetID != "" { | |
| 149 | + | if targetTab := tl.Find(targetID); targetTab != nil { | |
| 150 | + | return targetTab, false, nil | |
| 151 | + | } | |
| 152 | + | // Treat missing as "create new". | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | if uc.idGenerator == nil { | |
| 156 | + | return nil, false, fmt.Errorf("id generator is required to create new tab") | |
| 157 | + | } | |
| 158 | + | tabID := entity.TabID(uc.idGenerator()) | |
| 159 | + | wsID := entity.WorkspaceID(uc.idGenerator()) | |
| 160 | + | targetTab := entity.NewTab(tabID, wsID, movedPane) | |
| 161 | + | tl.Add(targetTab) | |
| 162 | + | return targetTab, true, nil | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (uc *MovePaneToTabUseCase) insertIntoTargetWorkspace(ws *entity.Workspace, movedPane *entity.Pane) (*entity.PaneNode, error) { | |
| 166 | + | if ws == nil { | |
| 167 | + | return nil, fmt.Errorf("workspace is required") | |
| 168 | + | } | |
| 169 | + | if movedPane == nil { | |
| 170 | + | return nil, fmt.Errorf("moved pane is required") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | movedNode := &entity.PaneNode{ID: string(movedPane.ID), Pane: movedPane} | |
| 174 | + | ||
| 175 | + | if ws.Root == nil { | |
| 176 | + | ws.Root = movedNode | |
| 177 | + | ws.ActivePaneID = movedPane.ID | |
| 178 | + | return movedNode, nil | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | targetActive := ws.ActivePane() | |
| 182 | + | if targetActive == nil || targetActive.Pane == nil { | |
| 183 | + | return nil, fmt.Errorf("target tab has no active pane") | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | if err := insertPaneRightOfActive(ws, targetActive, movedNode, uc.idGenerator); err != nil { | |
| 187 | + | return nil, err | |
| 188 | + | } | |
| 189 | + | ws.ActivePaneID = movedPane.ID | |
| 190 | + | return movedNode, nil | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func detachPaneFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error { | |
| 194 | + | if ws == nil { | |
| 195 | + | return fmt.Errorf("workspace is required") | |
| 196 | + | } | |
| 197 | + | if leaf == nil || leaf.Pane == nil { | |
| 198 | + | return fmt.Errorf("pane node is required") | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | detachedFromStack, err := detachFromStackIfNeeded(ws, leaf) | |
| 202 | + | if err != nil { | |
| 203 | + | return err | |
| 204 | + | } | |
| 205 | + | if detachedFromStack { | |
| 206 | + | return nil | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | if !leaf.IsLeaf() { | |
| 210 | + | return fmt.Errorf("can only move leaf panes") | |
| 211 | + | } | |
| 212 | + | return detachLeafFromWorkspace(ws, leaf) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func detachFromStackIfNeeded(ws *entity.Workspace, leaf *entity.PaneNode) (bool, error) { | |
| 216 | + | if ws == nil || leaf == nil { | |
| 217 | + | return false, nil | |
| 218 | + | } | |
| 219 | + | if leaf.Parent == nil || !leaf.Parent.IsStacked { | |
| 220 | + | return false, nil | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | stackNode := leaf.Parent | |
| 224 | + | ||
| 225 | + | idx := -1 | |
| 226 | + | for i, child := range stackNode.Children { | |
| 227 | + | if child == leaf { | |
| 228 | + | idx = i | |
| 229 | + | break | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | if idx < 0 { | |
| 233 | + | return false, fmt.Errorf("pane not found in stack") | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | stackNode.Children = append(stackNode.Children[:idx], stackNode.Children[idx+1:]...) | |
| 237 | + | if stackNode.ActiveStackIndex >= len(stackNode.Children) { | |
| 238 | + | stackNode.ActiveStackIndex = len(stackNode.Children) - 1 | |
| 239 | + | } | |
| 240 | + | if stackNode.ActiveStackIndex < 0 { | |
| 241 | + | stackNode.ActiveStackIndex = 0 | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // If only one pane remains in stack, dissolve it into a leaf node. | |
| 245 | + | if len(stackNode.Children) == 1 { | |
| 246 | + | remaining := stackNode.Children[0] | |
| 247 | + | stackNode.Pane = remaining.Pane | |
| 248 | + | stackNode.Children = nil | |
| 249 | + | stackNode.IsStacked = false | |
| 250 | + | stackNode.ActiveStackIndex = 0 | |
| 251 | + | if stackNode.Pane != nil { | |
| 252 | + | ws.ActivePaneID = stackNode.Pane.ID | |
| 253 | + | } else { | |
| 254 | + | ws.ActivePaneID = "" | |
| 255 | + | } | |
| 256 | + | return true, nil | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // Otherwise, set workspace active pane to the stack's current active. | |
| 260 | + | if stackNode.ActivePane() != nil && stackNode.ActivePane().Pane != nil { | |
| 261 | + | ws.ActivePaneID = stackNode.ActivePane().Pane.ID | |
| 262 | + | } | |
| 263 | + | return true, nil | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func detachLeafFromWorkspace(ws *entity.Workspace, leaf *entity.PaneNode) error { | |
| 267 | + | parent := leaf.Parent | |
| 268 | + | if parent == nil { | |
| 269 | + | ws.Root = nil | |
| 270 | + | ws.ActivePaneID = "" | |
| 271 | + | return nil | |
| 272 | + | } | |
| 273 | + | if !parent.IsSplit() { | |
| 274 | + | return fmt.Errorf("pane parent is not a split") | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | sibling := findSibling(parent, leaf) | |
| 278 | + | if sibling == nil { | |
| 279 | + | return fmt.Errorf("no sibling found") | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | promoteSibling(ws, parent, sibling) | |
| 283 | + | ws.ActivePaneID = findFirstLeafPaneID(sibling) | |
| 284 | + | return nil | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func findSibling(parent, leaf *entity.PaneNode) *entity.PaneNode { | |
| 288 | + | if parent == nil { | |
| 289 | + | return nil | |
| 290 | + | } | |
| 291 | + | for _, child := range parent.Children { | |
| 292 | + | if child != leaf { | |
| 293 | + | return child | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | return nil | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func promoteSibling(ws *entity.Workspace, parent, sibling *entity.PaneNode) { | |
| 300 | + | if ws == nil || sibling == nil { | |
| 301 | + | return | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | grandparent := parent.Parent | |
| 305 | + | if grandparent == nil { | |
| 306 | + | ws.Root = sibling | |
| 307 | + | sibling.Parent = nil | |
| 308 | + | return | |
| 309 | + | } | |
| 310 | + | for i, child := range grandparent.Children { | |
| 311 | + | if child == parent { | |
| 312 | + | grandparent.Children[i] = sibling | |
| 313 | + | break | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | sibling.Parent = grandparent | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func findFirstLeafPaneID(node *entity.PaneNode) entity.PaneID { | |
| 320 | + | if node == nil { | |
| 321 | + | return "" | |
| 322 | + | } | |
| 323 | + | var active entity.PaneID | |
| 324 | + | node.Walk(func(n *entity.PaneNode) bool { | |
| 325 | + | if n.IsLeaf() && n.Pane != nil { | |
| 326 | + | active = n.Pane.ID | |
| 327 | + | return false | |
| 328 | + | } | |
| 329 | + | return true | |
| 330 | + | }) | |
| 331 | + | return active | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | func insertPaneRightOfActive(ws *entity.Workspace, activeNode, newLeaf *entity.PaneNode, idGen IDGenerator) error { | |
| 335 | + | if ws == nil { | |
| 336 | + | return fmt.Errorf("workspace is required") | |
| 337 | + | } | |
| 338 | + | if activeNode == nil { | |
| 339 | + | return fmt.Errorf("active pane is required") | |
| 340 | + | } | |
| 341 | + | if newLeaf == nil || newLeaf.Pane == nil { | |
| 342 | + | return fmt.Errorf("new pane node is required") | |
| 343 | + | } | |
| 344 | + | if idGen == nil { | |
| 345 | + | return fmt.Errorf("id generator is required") | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | targetNode := activeNode | |
| 349 | + | // Split around stacks when inserting left/right. | |
| 350 | + | if targetNode.Parent != nil && targetNode.Parent.IsStacked { | |
| 351 | + | targetNode = targetNode.Parent | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | parentID := idGen() | |
| 355 | + | splitParent := &entity.PaneNode{ | |
| 356 | + | ID: parentID, | |
| 357 | + | SplitDir: entity.SplitHorizontal, | |
| 358 | + | SplitRatio: 0.5, | |
| 359 | + | Children: make([]*entity.PaneNode, 2), | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | splitParent.Children[0] = targetNode | |
| 363 | + | splitParent.Children[1] = newLeaf | |
| 364 | + | ||
| 365 | + | newLeaf.Parent = splitParent | |
| 366 | + | oldParent := targetNode.Parent | |
| 367 | + | targetNode.Parent = splitParent | |
| 368 | + | ||
| 369 | + | if oldParent == nil { | |
| 370 | + | ws.Root = splitParent | |
| 371 | + | } else { | |
| 372 | + | for i, child := range oldParent.Children { | |
| 373 | + | if child == targetNode { | |
| 374 | + | oldParent.Children[i] = splitParent | |
| 375 | + | break | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | splitParent.Parent = oldParent | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | return nil | |
| 382 | + | } | |
A internal/application/usecase/move_pane_to_tab_test.go +200 -0
| @@ -0,0 +1,200 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestMovePaneToTab_MoveToExistingTab(t *testing.T) { | |
| 12 | + | id := newTestIDGen() | |
| 13 | + | uc := NewMovePaneToTabUseCase(id) | |
| 14 | + | ||
| 15 | + | tabs := entity.NewTabList() | |
| 16 | + | ||
| 17 | + | paneA := entity.NewPane(entity.PaneID("pA")) | |
| 18 | + | tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA) | |
| 19 | + | tabs.Add(tabA) | |
| 20 | + | ||
| 21 | + | paneB := entity.NewPane(entity.PaneID("pB")) | |
| 22 | + | tabB := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), paneB) | |
| 23 | + | tabs.Add(tabB) | |
| 24 | + | // Ensure tabB active pane is paneB. | |
| 25 | + | tabB.Workspace.ActivePaneID = paneB.ID | |
| 26 | + | ||
| 27 | + | out, err := uc.Execute(MovePaneToTabInput{ | |
| 28 | + | TabList: tabs, | |
| 29 | + | SourceTabID: tabA.ID, | |
| 30 | + | SourcePaneID: paneA.ID, | |
| 31 | + | TargetTabID: tabB.ID, | |
| 32 | + | }) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.True(t, out.SourceTabClosed) | |
| 35 | + | require.False(t, out.NewTabCreated) | |
| 36 | + | require.Equal(t, tabB.ID, out.TargetTab.ID) | |
| 37 | + | require.NotNil(t, out.MovedPaneNode) | |
| 38 | + | ||
| 39 | + | require.Nil(t, tabs.Find(tabA.ID)) | |
| 40 | + | // Inserted as split in tabB. | |
| 41 | + | require.NotNil(t, tabB.Workspace.Root) | |
| 42 | + | require.True(t, tabB.Workspace.Root.IsSplit()) | |
| 43 | + | require.Equal(t, paneB.ID, tabB.Workspace.Root.Left().Pane.ID) | |
| 44 | + | require.Equal(t, paneA.ID, tabB.Workspace.Root.Right().Pane.ID) | |
| 45 | + | // Active pane becomes moved pane. | |
| 46 | + | require.Equal(t, paneA.ID, tabB.Workspace.ActivePaneID) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestMovePaneToTab_MoveCreatesNewTabWhenOnlyOneTab(t *testing.T) { | |
| 50 | + | id := newTestIDGen() | |
| 51 | + | uc := NewMovePaneToTabUseCase(id) | |
| 52 | + | ||
| 53 | + | tabs := entity.NewTabList() | |
| 54 | + | paneA := entity.NewPane(entity.PaneID("pA")) | |
| 55 | + | tabA := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA) | |
| 56 | + | tabs.Add(tabA) | |
| 57 | + | ||
| 58 | + | out, err := uc.Execute(MovePaneToTabInput{ | |
| 59 | + | TabList: tabs, | |
| 60 | + | SourceTabID: tabA.ID, | |
| 61 | + | SourcePaneID: paneA.ID, | |
| 62 | + | TargetTabID: "", | |
| 63 | + | }) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | require.True(t, out.NewTabCreated) | |
| 66 | + | require.True(t, out.SourceTabClosed) | |
| 67 | + | require.Equal(t, 1, tabs.Count()) | |
| 68 | + | require.Equal(t, paneA.ID, out.TargetTab.Workspace.ActivePaneID) | |
| 69 | + | require.NotNil(t, out.TargetTab.Workspace.Root) | |
| 70 | + | require.True(t, out.TargetTab.Workspace.Root.IsLeaf()) | |
| 71 | + | require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Pane.ID) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestMovePaneToTab_CannotMoveToSameTab(t *testing.T) { | |
| 75 | + | uc := NewMovePaneToTabUseCase(newTestIDGen()) | |
| 76 | + | abs := entity.NewTabList() | |
| 77 | + | pane := entity.NewPane(entity.PaneID("pA")) | |
| 78 | + | tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane) | |
| 79 | + | abs.Add(tab) | |
| 80 | + | ||
| 81 | + | _, err := uc.Execute(MovePaneToTabInput{ | |
| 82 | + | TabList: abs, | |
| 83 | + | SourceTabID: tab.ID, | |
| 84 | + | SourcePaneID: pane.ID, | |
| 85 | + | TargetTabID: tab.ID, | |
| 86 | + | }) | |
| 87 | + | require.Error(t, err) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestMovePaneToTab_SourcePaneNotFound(t *testing.T) { | |
| 91 | + | uc := NewMovePaneToTabUseCase(newTestIDGen()) | |
| 92 | + | tabs := entity.NewTabList() | |
| 93 | + | pane := entity.NewPane(entity.PaneID("pA")) | |
| 94 | + | tab := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), pane) | |
| 95 | + | tabs.Add(tab) | |
| 96 | + | ||
| 97 | + | _, err := uc.Execute(MovePaneToTabInput{ | |
| 98 | + | TabList: tabs, | |
| 99 | + | SourceTabID: tab.ID, | |
| 100 | + | SourcePaneID: entity.PaneID("missing"), | |
| 101 | + | TargetTabID: entity.TabID("tB"), | |
| 102 | + | }) | |
| 103 | + | require.Error(t, err) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestMovePaneToTab_MoveFromSplitClosesSourceTabIfLastPane(t *testing.T) { | |
| 107 | + | id := newTestIDGen() | |
| 108 | + | uc := NewMovePaneToTabUseCase(id) | |
| 109 | + | ||
| 110 | + | tabs := entity.NewTabList() | |
| 111 | + | ||
| 112 | + | // Source workspace: split with two panes; move one, leaving one. | |
| 113 | + | paneLeft := entity.NewPane(entity.PaneID("pL")) | |
| 114 | + | paneRight := entity.NewPane(entity.PaneID("pR")) | |
| 115 | + | source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneLeft) | |
| 116 | + | // Build a split root manually. | |
| 117 | + | source.Workspace.Root = &entity.PaneNode{ | |
| 118 | + | ID: "root", | |
| 119 | + | SplitDir: entity.SplitHorizontal, | |
| 120 | + | SplitRatio: 0.5, | |
| 121 | + | Children: []*entity.PaneNode{ | |
| 122 | + | {ID: string(paneLeft.ID), Pane: paneLeft}, | |
| 123 | + | {ID: string(paneRight.ID), Pane: paneRight}, | |
| 124 | + | }, | |
| 125 | + | } | |
| 126 | + | source.Workspace.Root.Children[0].Parent = source.Workspace.Root | |
| 127 | + | source.Workspace.Root.Children[1].Parent = source.Workspace.Root | |
| 128 | + | source.Workspace.ActivePaneID = paneRight.ID | |
| 129 | + | tabs.Add(source) | |
| 130 | + | ||
| 131 | + | targetPane := entity.NewPane(entity.PaneID("pT")) | |
| 132 | + | target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), targetPane) | |
| 133 | + | tabs.Add(target) | |
| 134 | + | ||
| 135 | + | out, err := uc.Execute(MovePaneToTabInput{ | |
| 136 | + | TabList: tabs, | |
| 137 | + | SourceTabID: source.ID, | |
| 138 | + | SourcePaneID: paneRight.ID, | |
| 139 | + | TargetTabID: target.ID, | |
| 140 | + | }) | |
| 141 | + | require.NoError(t, err) | |
| 142 | + | require.False(t, out.SourceTabClosed) | |
| 143 | + | // Source should now be single leaf. | |
| 144 | + | require.Equal(t, 1, source.Workspace.PaneCount()) | |
| 145 | + | require.NotNil(t, source.Workspace.Root) | |
| 146 | + | require.True(t, source.Workspace.Root.IsLeaf()) | |
| 147 | + | require.Equal(t, paneLeft.ID, source.Workspace.Root.Pane.ID) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestMovePaneToTab_InsertsRightOfActiveEvenWhenActiveInStack(t *testing.T) { | |
| 151 | + | id := newTestIDGen() | |
| 152 | + | uc := NewMovePaneToTabUseCase(id) | |
| 153 | + | ||
| 154 | + | tabs := entity.NewTabList() | |
| 155 | + | ||
| 156 | + | paneA := entity.NewPane(entity.PaneID("pA")) | |
| 157 | + | source := entity.NewTab(entity.TabID("tA"), entity.WorkspaceID("wA"), paneA) | |
| 158 | + | tabs.Add(source) | |
| 159 | + | ||
| 160 | + | // Target workspace: stacked container with two panes; active is second. | |
| 161 | + | pane1 := entity.NewPane(entity.PaneID("p1")) | |
| 162 | + | pane2 := entity.NewPane(entity.PaneID("p2")) | |
| 163 | + | target := entity.NewTab(entity.TabID("tB"), entity.WorkspaceID("wB"), pane1) | |
| 164 | + | stack := &entity.PaneNode{ | |
| 165 | + | ID: "stack", | |
| 166 | + | IsStacked: true, | |
| 167 | + | ActiveStackIndex: 1, | |
| 168 | + | Children: []*entity.PaneNode{ | |
| 169 | + | {ID: string(pane1.ID), Pane: pane1}, | |
| 170 | + | {ID: string(pane2.ID), Pane: pane2}, | |
| 171 | + | }, | |
| 172 | + | } | |
| 173 | + | stack.Children[0].Parent = stack | |
| 174 | + | stack.Children[1].Parent = stack | |
| 175 | + | target.Workspace.Root = stack | |
| 176 | + | target.Workspace.ActivePaneID = pane2.ID | |
| 177 | + | tabs.Add(target) | |
| 178 | + | ||
| 179 | + | out, err := uc.Execute(MovePaneToTabInput{ | |
| 180 | + | TabList: tabs, | |
| 181 | + | SourceTabID: source.ID, | |
| 182 | + | SourcePaneID: paneA.ID, | |
| 183 | + | TargetTabID: target.ID, | |
| 184 | + | }) | |
| 185 | + | require.NoError(t, err) | |
| 186 | + | require.NotNil(t, out.TargetTab.Workspace.Root) | |
| 187 | + | require.True(t, out.TargetTab.Workspace.Root.IsSplit()) | |
| 188 | + | // Left child should be the stack container. | |
| 189 | + | require.True(t, out.TargetTab.Workspace.Root.Left().IsStacked) | |
| 190 | + | // Right child is moved pane. | |
| 191 | + | require.Equal(t, paneA.ID, out.TargetTab.Workspace.Root.Right().Pane.ID) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func newTestIDGen() func() string { | |
| 195 | + | counter := 0 | |
| 196 | + | return func() string { | |
| 197 | + | counter++ | |
| 198 | + | return fmt.Sprintf("id%d", counter) | |
| 199 | + | } | |
| 200 | + | } | |
M internal/application/usecase/navigate.go +92 -11
| @@ -13,12 +13,30 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | + | const ( | |
| 17 | + | // historyQueueSize is the buffer size for the async history queue. | |
| 18 | + | // If the queue is full, new records are dropped with a warning. | |
| 19 | + | historyQueueSize = 100 | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // historyRecord holds data for async history recording. | |
| 23 | + | type historyRecord struct { | |
| 24 | + | url string | |
| 25 | + | timestamp time.Time | |
| 26 | + | } | |
| 27 | + | ||
| 16 | 28 | // NavigateUseCase handles URL navigation with history recording and zoom application. | |
| 17 | 29 | type NavigateUseCase struct { | |
| 18 | 30 | historyRepo repository.HistoryRepository | |
| 19 | 31 | zoomRepo repository.ZoomRepository | |
| 20 | 32 | defaultZoom float64 | |
| 21 | 33 | recentVisits sync.Map // key: url, value: time.Time - for deduplication | |
| 34 | + | ||
| 35 | + | // Async history recording | |
| 36 | + | historyQueue chan historyRecord | |
| 37 | + | done chan struct{} | |
| 38 | + | wg sync.WaitGroup | |
| 39 | + | ctx context.Context // Base context for background worker | |
| 22 | 40 | } | |
| 23 | 41 | ||
| 24 | 42 | // NewNavigateUseCase creates a new navigation use case. | |
| @@ -31,11 +49,28 @@ func NewNavigateUseCase( | |||
| 31 | 49 | if defaultZoom <= 0 { | |
| 32 | 50 | defaultZoom = entity.ZoomDefault | |
| 33 | 51 | } | |
| 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(), | |
| 38 | 60 | } | |
| 61 | + | ||
| 62 | + | // Start background history worker | |
| 63 | + | uc.wg.Add(1) | |
| 64 | + | go uc.historyWorker() | |
| 65 | + | ||
| 66 | + | return uc | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Close shuts down the background history worker and drains any pending records. | |
| 70 | + | // This should be called when the application is shutting down. | |
| 71 | + | func (uc *NavigateUseCase) Close() { | |
| 72 | + | close(uc.done) | |
| 73 | + | uc.wg.Wait() | |
| 39 | 74 | } | |
| 40 | 75 | ||
| 41 | 76 | // NavigateInput contains parameters for navigation. | |
| @@ -79,7 +114,8 @@ func (uc *NavigateUseCase) Execute(ctx context.Context, input NavigateInput) (*N | |||
| 79 | 114 | // This prevents inflation from redirects and rapid navigation. | |
| 80 | 115 | const historyDeduplicationWindow = 2 * time.Second | |
| 81 | 116 | ||
| 82 | - | // RecordHistory saves or updates the history entry. | |
| 117 | + | // RecordHistory queues a history entry for async recording. | |
| 118 | + | // This is non-blocking to avoid SQLite I/O on the GTK main thread. | |
| 83 | 119 | // Should be called on LoadCommitted when URI is guaranteed correct. | |
| 84 | 120 | func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) { | |
| 85 | 121 | log := logging.FromContext(ctx) | |
| @@ -88,6 +124,7 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) { | |||
| 88 | 124 | url = normalizeURLForHistory(url) | |
| 89 | 125 | ||
| 90 | 126 | // Time-based deduplication: skip if same URL was recorded recently | |
| 127 | + | // This check is fast (sync.Map lookup) and stays on the main thread | |
| 91 | 128 | now := time.Now() | |
| 92 | 129 | if lastTime, ok := uc.recentVisits.Load(url); ok { | |
| 93 | 130 | if now.Sub(lastTime.(time.Time)) < historyDeduplicationWindow { | |
| @@ -97,23 +134,67 @@ func (uc *NavigateUseCase) RecordHistory(ctx context.Context, url string) { | |||
| 97 | 134 | } | |
| 98 | 135 | uc.recentVisits.Store(url, now) | |
| 99 | 136 | ||
| 137 | + | // Non-blocking send to async queue | |
| 138 | + | select { | |
| 139 | + | case uc.historyQueue <- historyRecord{url: url, timestamp: now}: | |
| 140 | + | log.Debug().Str("url", url).Msg("history record queued") | |
| 141 | + | default: | |
| 142 | + | // Queue full - log warning but don't block navigation | |
| 143 | + | log.Warn().Str("url", url).Msg("history queue full, dropping record") | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // historyWorker is a background goroutine that processes history records. | |
| 148 | + | // It drains the queue and persists records to the database without blocking the UI. | |
| 149 | + | func (uc *NavigateUseCase) historyWorker() { | |
| 150 | + | defer uc.wg.Done() | |
| 151 | + | ||
| 152 | + | log := logging.FromContext(uc.ctx).With(). | |
| 153 | + | Str("component", "history-worker"). | |
| 154 | + | Logger() | |
| 155 | + | ||
| 156 | + | for { | |
| 157 | + | select { | |
| 158 | + | case record := <-uc.historyQueue: | |
| 159 | + | uc.persistHistory(uc.ctx, record) | |
| 160 | + | case <-uc.done: | |
| 161 | + | // Drain remaining records before shutdown | |
| 162 | + | log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue") | |
| 163 | + | for { | |
| 164 | + | select { | |
| 165 | + | case record := <-uc.historyQueue: | |
| 166 | + | uc.persistHistory(uc.ctx, record) | |
| 167 | + | default: | |
| 168 | + | log.Debug().Msg("history worker shutdown complete") | |
| 169 | + | return | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // persistHistory writes a history record to the database. | |
| 177 | + | // Called from the background worker goroutine. | |
| 178 | + | func (uc *NavigateUseCase) persistHistory(ctx context.Context, record historyRecord) { | |
| 179 | + | log := logging.FromContext(ctx) | |
| 180 | + | ||
| 100 | 181 | // Check if entry exists | |
| 101 | - | existing, err := uc.historyRepo.FindByURL(ctx, url) | |
| 182 | + | existing, err := uc.historyRepo.FindByURL(ctx, record.url) | |
| 102 | 183 | if err != nil { | |
| 103 | - | log.Warn().Err(err).Str("url", url).Msg("failed to check history") | |
| 184 | + | log.Warn().Err(err).Str("url", record.url).Msg("failed to check history") | |
| 104 | 185 | return | |
| 105 | 186 | } | |
| 106 | 187 | ||
| 107 | 188 | if existing != nil { | |
| 108 | 189 | // 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") | |
| 111 | 192 | } | |
| 112 | 193 | } else { | |
| 113 | 194 | // Create new entry | |
| 114 | - | entry := entity.NewHistoryEntry(url, "") | |
| 195 | + | entry := entity.NewHistoryEntry(record.url, "") | |
| 115 | 196 | if err := uc.historyRepo.Save(ctx, entry); err != nil { | |
| 116 | - | log.Warn().Err(err).Str("url", url).Msg("failed to save history") | |
| 197 | + | log.Warn().Err(err).Str("url", record.url).Msg("failed to save history") | |
| 117 | 198 | } | |
| 118 | 199 | } | |
| 119 | 200 | } | |
A internal/application/usecase/restore_session.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ErrSessionNotFound is returned when a session state cannot be found. | |
| 14 | + | var ErrSessionNotFound = errors.New("session not found") | |
| 15 | + | ||
| 16 | + | // ErrVersionMismatch is returned when the session state version is incompatible. | |
| 17 | + | var ErrVersionMismatch = errors.New("session state version mismatch") | |
| 18 | + | ||
| 19 | + | // RestoreSessionUseCase handles restoring session state from a snapshot. | |
| 20 | + | type RestoreSessionUseCase struct { | |
| 21 | + | stateRepo repository.SessionStateRepository | |
| 22 | + | sessionRepo repository.SessionRepository | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // NewRestoreSessionUseCase creates a new RestoreSessionUseCase. | |
| 26 | + | func NewRestoreSessionUseCase( | |
| 27 | + | stateRepo repository.SessionStateRepository, | |
| 28 | + | sessionRepo repository.SessionRepository, | |
| 29 | + | ) *RestoreSessionUseCase { | |
| 30 | + | return &RestoreSessionUseCase{ | |
| 31 | + | stateRepo: stateRepo, | |
| 32 | + | sessionRepo: sessionRepo, | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // RestoreInput contains the parameters for restoring a session. | |
| 37 | + | type RestoreInput struct { | |
| 38 | + | SessionID entity.SessionID | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // RestoreOutput contains the restored session state. | |
| 42 | + | type RestoreOutput struct { | |
| 43 | + | State *entity.SessionState | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Execute loads and validates a session state for restoration. | |
| 47 | + | func (uc *RestoreSessionUseCase) Execute(ctx context.Context, input RestoreInput) (*RestoreOutput, error) { | |
| 48 | + | log := logging.FromContext(ctx) | |
| 49 | + | ||
| 50 | + | if input.SessionID == "" { | |
| 51 | + | return nil, fmt.Errorf("session id required") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | log.Info(). | |
| 55 | + | Str("session_id", string(input.SessionID)). | |
| 56 | + | Msg("restoring session state") | |
| 57 | + | ||
| 58 | + | state, err := uc.stateRepo.GetSnapshot(ctx, input.SessionID) | |
| 59 | + | if err != nil { | |
| 60 | + | return nil, fmt.Errorf("get session snapshot: %w", err) | |
| 61 | + | } | |
| 62 | + | if state == nil { | |
| 63 | + | return nil, ErrSessionNotFound | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Validate version compatibility | |
| 67 | + | if state.Version > entity.SessionStateVersion { | |
| 68 | + | log.Warn(). | |
| 69 | + | Int("state_version", state.Version). | |
| 70 | + | Int("current_version", entity.SessionStateVersion). | |
| 71 | + | Msg("session state version is newer than current version") | |
| 72 | + | return nil, ErrVersionMismatch | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | log.Info(). | |
| 76 | + | Str("session_id", string(input.SessionID)). | |
| 77 | + | Int("tab_count", len(state.Tabs)). | |
| 78 | + | Int("pane_count", state.CountPanes()). | |
| 79 | + | Msg("session state loaded for restoration") | |
| 80 | + | ||
| 81 | + | return &RestoreOutput{State: state}, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // DeleteSnapshot removes a session's snapshot (for cleanup after failed restore or user deletion). | |
| 85 | + | func (uc *RestoreSessionUseCase) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error { | |
| 86 | + | return uc.stateRepo.DeleteSnapshot(ctx, sessionID) | |
| 87 | + | } | |
A internal/application/usecase/restore_session_test.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRestoreSessionUseCase_Execute_ReturnsState(t *testing.T) { | |
| 16 | + | ctx := testContext() | |
| 17 | + | ||
| 18 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 19 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 20 | + | ||
| 21 | + | sessionID := entity.SessionID("20251224_120000_restore") | |
| 22 | + | ||
| 23 | + | expectedState := &entity.SessionState{ | |
| 24 | + | Version: entity.SessionStateVersion, | |
| 25 | + | SessionID: sessionID, | |
| 26 | + | Tabs: []entity.TabSnapshot{ | |
| 27 | + | { | |
| 28 | + | ID: "tab-1", | |
| 29 | + | Name: "Test Tab", | |
| 30 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 31 | + | Root: &entity.PaneNodeSnapshot{ | |
| 32 | + | Pane: &entity.PaneSnapshot{ | |
| 33 | + | ID: "pane-1", | |
| 34 | + | URI: "https://example.com", | |
| 35 | + | Title: "Example", | |
| 36 | + | }, | |
| 37 | + | }, | |
| 38 | + | }, | |
| 39 | + | }, | |
| 40 | + | }, | |
| 41 | + | SavedAt: time.Now(), | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(expectedState, nil) | |
| 45 | + | ||
| 46 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 47 | + | ||
| 48 | + | output, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | require.NotNil(t, output) | |
| 51 | + | assert.Equal(t, expectedState, output.State) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestRestoreSessionUseCase_Execute_RequiresSessionID(t *testing.T) { | |
| 55 | + | ctx := testContext() | |
| 56 | + | ||
| 57 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 58 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 59 | + | ||
| 60 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 61 | + | ||
| 62 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: ""}) | |
| 63 | + | require.Error(t, err) | |
| 64 | + | assert.Contains(t, err.Error(), "session id required") | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestRestoreSessionUseCase_Execute_SessionNotFound(t *testing.T) { | |
| 68 | + | ctx := testContext() | |
| 69 | + | ||
| 70 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 71 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 72 | + | ||
| 73 | + | sessionID := entity.SessionID("20251224_120000_notfound") | |
| 74 | + | ||
| 75 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil) | |
| 76 | + | ||
| 77 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 78 | + | ||
| 79 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 80 | + | require.Error(t, err) | |
| 81 | + | assert.ErrorIs(t, err, usecase.ErrSessionNotFound) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestRestoreSessionUseCase_Execute_VersionMismatch(t *testing.T) { | |
| 85 | + | ctx := testContext() | |
| 86 | + | ||
| 87 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 88 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 89 | + | ||
| 90 | + | sessionID := entity.SessionID("20251224_120000_version") | |
| 91 | + | ||
| 92 | + | // Return a state with a future version | |
| 93 | + | futureState := &entity.SessionState{ | |
| 94 | + | Version: entity.SessionStateVersion + 1, | |
| 95 | + | SessionID: sessionID, | |
| 96 | + | Tabs: []entity.TabSnapshot{}, | |
| 97 | + | SavedAt: time.Now(), | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(futureState, nil) | |
| 101 | + | ||
| 102 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 103 | + | ||
| 104 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 105 | + | require.Error(t, err) | |
| 106 | + | assert.ErrorIs(t, err, usecase.ErrVersionMismatch) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestRestoreSessionUseCase_Execute_GetSnapshotError(t *testing.T) { | |
| 110 | + | ctx := testContext() | |
| 111 | + | ||
| 112 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 113 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 114 | + | ||
| 115 | + | sessionID := entity.SessionID("20251224_120000_error") | |
| 116 | + | ||
| 117 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, errors.New("db error")) | |
| 118 | + | ||
| 119 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 120 | + | ||
| 121 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 122 | + | require.Error(t, err) | |
| 123 | + | assert.Contains(t, err.Error(), "get session snapshot") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestRestoreSessionUseCase_DeleteSnapshot(t *testing.T) { | |
| 127 | + | ctx := testContext() | |
| 128 | + | ||
| 129 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 130 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 131 | + | ||
| 132 | + | sessionID := entity.SessionID("20251224_120000_delete") | |
| 133 | + | ||
| 134 | + | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil) | |
| 135 | + | ||
| 136 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 137 | + | ||
| 138 | + | err := uc.DeleteSnapshot(ctx, sessionID) | |
| 139 | + | require.NoError(t, err) | |
| 140 | + | } | |
M internal/application/usecase/search_history.go +50 -0
| @@ -75,6 +75,56 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int | |||
| 75 | 75 | return entries, nil | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | + | // GetRecentSince retrieves history entries visited within the last N days. | |
| 79 | + | // If days is 0, returns all history entries. | |
| 80 | + | // If days is negative, defaults to 30 days. | |
| 81 | + | func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 82 | + | if days < 0 { | |
| 83 | + | days = 30 // Default to 30 days for negative values | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | var entries []*entity.HistoryEntry | |
| 87 | + | var err error | |
| 88 | + | ||
| 89 | + | switch days { | |
| 90 | + | case 0: | |
| 91 | + | entries, err = uc.historyRepo.GetAllRecentHistory(ctx) | |
| 92 | + | default: | |
| 93 | + | entries, err = uc.historyRepo.GetRecentSince(ctx, days) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | if err != nil { | |
| 97 | + | return nil, fmt.Errorf("failed to get recent history: %w", err) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | return entries, nil | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // GetMostVisited retrieves history entries sorted by visit count within the last N days. | |
| 104 | + | // If days is 0, returns all history entries sorted by visit count. | |
| 105 | + | // If days is negative, defaults to 30 days. | |
| 106 | + | func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 107 | + | if days < 0 { | |
| 108 | + | days = 30 // Default to 30 days for negative values | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | var entries []*entity.HistoryEntry | |
| 112 | + | var err error | |
| 113 | + | ||
| 114 | + | switch days { | |
| 115 | + | case 0: | |
| 116 | + | entries, err = uc.historyRepo.GetAllMostVisited(ctx) | |
| 117 | + | default: | |
| 118 | + | entries, err = uc.historyRepo.GetMostVisited(ctx, days) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | if err != nil { | |
| 122 | + | return nil, fmt.Errorf("failed to get most visited history: %w", err) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | return entries, nil | |
| 126 | + | } | |
| 127 | + | ||
| 78 | 128 | // FindByURL retrieves a history entry by its URL. | |
| 79 | 129 | func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) { | |
| 80 | 130 | log := logging.FromContext(ctx) | |
A internal/application/usecase/search_history_test.go +153 -0
| @@ -0,0 +1,153 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) { | |
| 17 | + | ctx := testContext() | |
| 18 | + | ||
| 19 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 20 | + | ||
| 21 | + | entries := []*entity.HistoryEntry{ | |
| 22 | + | {ID: 1, URL: "https://example.com", Title: "Example", LastVisited: time.Now()}, | |
| 23 | + | {ID: 2, URL: "https://github.com", Title: "GitHub", LastVisited: time.Now().Add(-24 * time.Hour)}, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil) | |
| 27 | + | ||
| 28 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 29 | + | ||
| 30 | + | result, err := uc.GetRecentSince(ctx, 30) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | require.Len(t, result, 2) | |
| 33 | + | assert.Equal(t, "https://example.com", result[0].URL) | |
| 34 | + | assert.Equal(t, "https://github.com", result[1].URL) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestSearchHistoryUseCase_GetRecentSince_ZeroMeansAll(t *testing.T) { | |
| 38 | + | ctx := testContext() | |
| 39 | + | ||
| 40 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 41 | + | ||
| 42 | + | entries := []*entity.HistoryEntry{ | |
| 43 | + | {ID: 1, URL: "https://example.com", Title: "Example"}, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // When days == 0, should call GetAllRecentHistory | |
| 47 | + | historyRepo.EXPECT().GetAllRecentHistory(mock.Anything).Return(entries, nil) | |
| 48 | + | ||
| 49 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 50 | + | ||
| 51 | + | result, err := uc.GetRecentSince(ctx, 0) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | require.Len(t, result, 1) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestSearchHistoryUseCase_GetRecentSince_NegativeDaysDefaultsTo30(t *testing.T) { | |
| 57 | + | ctx := testContext() | |
| 58 | + | ||
| 59 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 60 | + | ||
| 61 | + | entries := []*entity.HistoryEntry{} | |
| 62 | + | ||
| 63 | + | historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil) | |
| 64 | + | ||
| 65 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 66 | + | ||
| 67 | + | result, err := uc.GetRecentSince(ctx, -5) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | require.Empty(t, result) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestSearchHistoryUseCase_GetRecentSince_ReturnsErrorOnRepoFailure(t *testing.T) { | |
| 73 | + | ctx := testContext() | |
| 74 | + | ||
| 75 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 76 | + | ||
| 77 | + | historyRepo.EXPECT().GetRecentSince(mock.Anything, 7).Return(nil, errors.New("db connection failed")) | |
| 78 | + | ||
| 79 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 80 | + | ||
| 81 | + | result, err := uc.GetRecentSince(ctx, 7) | |
| 82 | + | require.Error(t, err) | |
| 83 | + | assert.Nil(t, result) | |
| 84 | + | assert.Contains(t, err.Error(), "failed to get recent history") | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestSearchHistoryUseCase_GetMostVisited_ReturnsEntries(t *testing.T) { | |
| 88 | + | ctx := testContext() | |
| 89 | + | ||
| 90 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 91 | + | ||
| 92 | + | entries := []*entity.HistoryEntry{ | |
| 93 | + | {ID: 1, URL: "https://github.com", Title: "GitHub", VisitCount: 100}, | |
| 94 | + | {ID: 2, URL: "https://example.com", Title: "Example", VisitCount: 50}, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil) | |
| 98 | + | ||
| 99 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 100 | + | ||
| 101 | + | result, err := uc.GetMostVisited(ctx, 30) | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | require.Len(t, result, 2) | |
| 104 | + | assert.Equal(t, "https://github.com", result[0].URL) | |
| 105 | + | assert.Equal(t, int64(100), result[0].VisitCount) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestSearchHistoryUseCase_GetMostVisited_ZeroMeansAll(t *testing.T) { | |
| 109 | + | ctx := testContext() | |
| 110 | + | ||
| 111 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 112 | + | ||
| 113 | + | entries := []*entity.HistoryEntry{} | |
| 114 | + | ||
| 115 | + | // When days == 0, should call GetAllMostVisited | |
| 116 | + | historyRepo.EXPECT().GetAllMostVisited(mock.Anything).Return(entries, nil) | |
| 117 | + | ||
| 118 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 119 | + | ||
| 120 | + | result, err := uc.GetMostVisited(ctx, 0) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | require.Empty(t, result) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestSearchHistoryUseCase_GetMostVisited_ReturnsErrorOnRepoFailure(t *testing.T) { | |
| 126 | + | ctx := testContext() | |
| 127 | + | ||
| 128 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 129 | + | ||
| 130 | + | historyRepo.EXPECT().GetMostVisited(mock.Anything, 14).Return(nil, errors.New("query failed")) | |
| 131 | + | ||
| 132 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 133 | + | ||
| 134 | + | result, err := uc.GetMostVisited(ctx, 14) | |
| 135 | + | require.Error(t, err) | |
| 136 | + | assert.Nil(t, result) | |
| 137 | + | assert.Contains(t, err.Error(), "failed to get most visited history") | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testing.T) { | |
| 141 | + | ctx := testContext() | |
| 142 | + | ||
| 143 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 144 | + | ||
| 145 | + | historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return([]*entity.HistoryEntry{}, nil) | |
| 146 | + | ||
| 147 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 148 | + | ||
| 149 | + | result, err := uc.GetMostVisited(ctx, 30) | |
| 150 | + | require.NoError(t, err) | |
| 151 | + | require.NotNil(t, result) | |
| 152 | + | assert.Empty(t, result) | |
| 153 | + | } | |
A internal/application/usecase/search_shortcuts.go +226 -0
| @@ -0,0 +1,226 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sort" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/url" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // SearchShortcut represents a search shortcut configuration. | |
| 13 | + | // This is a domain-level type to avoid depending on infrastructure config. | |
| 14 | + | type SearchShortcut struct { | |
| 15 | + | URL string | |
| 16 | + | Description string | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // BangSuggestion represents a configured bang shortcut for display. | |
| 20 | + | type BangSuggestion struct { | |
| 21 | + | Key string | |
| 22 | + | Description string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // SearchShortcutsUseCase handles bang shortcut filtering and resolution. | |
| 26 | + | type SearchShortcutsUseCase struct { | |
| 27 | + | shortcuts map[string]SearchShortcut | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // NewSearchShortcutsUseCase creates a new search shortcuts use case. | |
| 31 | + | func NewSearchShortcutsUseCase(shortcuts map[string]SearchShortcut) *SearchShortcutsUseCase { | |
| 32 | + | return &SearchShortcutsUseCase{ | |
| 33 | + | shortcuts: shortcuts, | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // FilterBangsInput contains parameters for filtering bang suggestions. | |
| 38 | + | type FilterBangsInput struct { | |
| 39 | + | Query string // e.g., "!" or "!g" or "!g query" | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // FilterBangsOutput contains filtered bang suggestions. | |
| 43 | + | type FilterBangsOutput struct { | |
| 44 | + | Suggestions []BangSuggestion | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // FilterBangs returns bang shortcuts matching the query prefix. | |
| 48 | + | // Query should start with "!". The prefix is extracted before any space. | |
| 49 | + | func (uc *SearchShortcutsUseCase) FilterBangs(ctx context.Context, input FilterBangsInput) *FilterBangsOutput { | |
| 50 | + | log := logging.FromContext(ctx) | |
| 51 | + | ||
| 52 | + | suggestions := uc.buildBangSuggestions(input.Query) | |
| 53 | + | ||
| 54 | + | log.Debug(). | |
| 55 | + | Str("query", input.Query). | |
| 56 | + | Int("matches", len(suggestions)). | |
| 57 | + | Msg("filtered bang suggestions") | |
| 58 | + | ||
| 59 | + | return &FilterBangsOutput{Suggestions: suggestions} | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // DetectBangKeyInput contains parameters for detecting a completed bang key. | |
| 63 | + | type DetectBangKeyInput struct { | |
| 64 | + | Query string // e.g., "!gh query" | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // DetectBangKeyOutput contains the detected bang key if found. | |
| 68 | + | type DetectBangKeyOutput struct { | |
| 69 | + | Key string // The matched key (empty if not found) | |
| 70 | + | Description string // Description of the matched shortcut | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // DetectBangKey checks if the query contains a valid, completed bang key. | |
| 74 | + | // A completed bang key requires "!<key> " with a space after the key. | |
| 75 | + | func (uc *SearchShortcutsUseCase) DetectBangKey(ctx context.Context, input DetectBangKeyInput) *DetectBangKeyOutput { | |
| 76 | + | log := logging.FromContext(ctx) | |
| 77 | + | ||
| 78 | + | key := uc.detectBangKey(input.Query) | |
| 79 | + | if key == "" { | |
| 80 | + | return &DetectBangKeyOutput{} | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | shortcut, ok := uc.shortcuts[key] | |
| 84 | + | if !ok { | |
| 85 | + | return &DetectBangKeyOutput{} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | description := shortcut.Description | |
| 89 | + | if description == "" { | |
| 90 | + | description = shortcut.URL | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | log.Debug(). | |
| 94 | + | Str("query", input.Query). | |
| 95 | + | Str("detected_key", key). | |
| 96 | + | Msg("detected bang key") | |
| 97 | + | ||
| 98 | + | return &DetectBangKeyOutput{ | |
| 99 | + | Key: key, | |
| 100 | + | Description: description, | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // BuildNavigationTextInput contains parameters for building navigation text. | |
| 105 | + | type BuildNavigationTextInput struct { | |
| 106 | + | EntryText string // e.g., "!GH dumber" | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // BuildNavigationTextOutput contains the normalized navigation text. | |
| 110 | + | type BuildNavigationTextOutput struct { | |
| 111 | + | Text string // Normalized text (e.g., "!gh dumber") or empty if invalid | |
| 112 | + | Valid bool // Whether the entry text represents a valid bang navigation | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // BuildNavigationText normalizes a bang query for navigation. | |
| 116 | + | // Returns the normalized text with the canonical key case. | |
| 117 | + | func (uc *SearchShortcutsUseCase) BuildNavigationText(ctx context.Context, input BuildNavigationTextInput) *BuildNavigationTextOutput { | |
| 118 | + | log := logging.FromContext(ctx) | |
| 119 | + | ||
| 120 | + | text := uc.buildBangNavigationText(input.EntryText) | |
| 121 | + | if text == "" { | |
| 122 | + | return &BuildNavigationTextOutput{} | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | log.Debug(). | |
| 126 | + | Str("entry_text", input.EntryText). | |
| 127 | + | Str("navigation_text", text). | |
| 128 | + | Msg("built bang navigation text") | |
| 129 | + | ||
| 130 | + | return &BuildNavigationTextOutput{ | |
| 131 | + | Text: text, | |
| 132 | + | Valid: true, | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // GetShortcut returns a shortcut by key (case-insensitive). | |
| 137 | + | func (uc *SearchShortcutsUseCase) GetShortcut(key string) (SearchShortcut, bool) { | |
| 138 | + | normalizedKey, ok := uc.normalizeBangKey(key) | |
| 139 | + | if !ok { | |
| 140 | + | return SearchShortcut{}, false | |
| 141 | + | } | |
| 142 | + | shortcut, ok := uc.shortcuts[normalizedKey] | |
| 143 | + | return shortcut, ok | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // ShortcutURLs returns a map of shortcut keys to URL templates. | |
| 147 | + | // This is useful for passing to url.BuildSearchURL. | |
| 148 | + | func (uc *SearchShortcutsUseCase) ShortcutURLs() map[string]string { | |
| 149 | + | result := make(map[string]string, len(uc.shortcuts)) | |
| 150 | + | for key, shortcut := range uc.shortcuts { | |
| 151 | + | result[key] = shortcut.URL | |
| 152 | + | } | |
| 153 | + | return result | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // buildBangSuggestions filters and sorts shortcuts matching the query prefix. | |
| 157 | + | func (uc *SearchShortcutsUseCase) buildBangSuggestions(query string) []BangSuggestion { | |
| 158 | + | prefix := strings.TrimPrefix(query, "!") | |
| 159 | + | if idx := strings.Index(prefix, " "); idx >= 0 { | |
| 160 | + | prefix = prefix[:idx] | |
| 161 | + | } | |
| 162 | + | prefix = strings.ToLower(prefix) | |
| 163 | + | ||
| 164 | + | suggestions := make([]BangSuggestion, 0, len(uc.shortcuts)) | |
| 165 | + | for key, shortcut := range uc.shortcuts { | |
| 166 | + | if !strings.HasPrefix(strings.ToLower(key), prefix) { | |
| 167 | + | continue | |
| 168 | + | } | |
| 169 | + | description := shortcut.Description | |
| 170 | + | if description == "" { | |
| 171 | + | description = shortcut.URL | |
| 172 | + | } | |
| 173 | + | suggestions = append(suggestions, BangSuggestion{ | |
| 174 | + | Key: key, | |
| 175 | + | Description: description, | |
| 176 | + | }) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | sort.Slice(suggestions, func(i, j int) bool { | |
| 180 | + | return strings.ToLower(suggestions[i].Key) < strings.ToLower(suggestions[j].Key) | |
| 181 | + | }) | |
| 182 | + | ||
| 183 | + | return suggestions | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // detectBangKey returns the matched shortcut key if the query has a valid bang prefix. | |
| 187 | + | func (uc *SearchShortcutsUseCase) detectBangKey(query string) string { | |
| 188 | + | spaceIdx := strings.Index(query, " ") | |
| 189 | + | if !strings.HasPrefix(query, "!") || spaceIdx <= 1 { | |
| 190 | + | return "" | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | candidate := query[1:spaceIdx] | |
| 194 | + | for key := range uc.shortcuts { | |
| 195 | + | if strings.EqualFold(key, candidate) { | |
| 196 | + | return key | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | return "" | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // normalizeBangKey returns the canonical key for a shortcut (case-insensitive lookup). | |
| 204 | + | func (uc *SearchShortcutsUseCase) normalizeBangKey(shortcutKey string) (string, bool) { | |
| 205 | + | for key := range uc.shortcuts { | |
| 206 | + | if strings.EqualFold(key, shortcutKey) { | |
| 207 | + | return key, true | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | return "", false | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // buildBangNavigationText normalizes a bang query for navigation. | |
| 214 | + | func (uc *SearchShortcutsUseCase) buildBangNavigationText(entryText string) string { | |
| 215 | + | shortcutKey, query, found := url.ParseBangShortcut(entryText) | |
| 216 | + | if !found || query == "" { | |
| 217 | + | return "" | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | resolvedKey, ok := uc.normalizeBangKey(shortcutKey) | |
| 221 | + | if !ok { | |
| 222 | + | return "" | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | return "!" + resolvedKey + " " + query | |
| 226 | + | } | |
A internal/application/usecase/search_shortcuts_test.go +175 -0
| @@ -0,0 +1,175 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestFilterBangs(t *testing.T) { | |
| 9 | + | shortcuts := map[string]SearchShortcut{ | |
| 10 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo search"}, | |
| 11 | + | "g": {URL: "https://google.com/search?q=%s", Description: "Google search"}, | |
| 12 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"}, | |
| 13 | + | "n": {URL: "https://news.ycombinator.com/", Description: ""}, | |
| 14 | + | } | |
| 15 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |
| 16 | + | ctx := context.Background() | |
| 17 | + | ||
| 18 | + | cases := []struct { | |
| 19 | + | name string | |
| 20 | + | query string | |
| 21 | + | wantKeys []string | |
| 22 | + | wantDescr map[string]string | |
| 23 | + | }{ | |
| 24 | + | { | |
| 25 | + | name: "bang only returns all sorted", | |
| 26 | + | query: "!", | |
| 27 | + | wantKeys: []string{"ddg", "g", "gh", "n"}, | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | name: "filters by prefix", | |
| 31 | + | query: "!g", | |
| 32 | + | wantKeys: []string{"g", "gh"}, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | name: "filters by prefix case-insensitive", | |
| 36 | + | query: "!DD", | |
| 37 | + | wantKeys: []string{"ddg"}, | |
| 38 | + | }, | |
| 39 | + | { | |
| 40 | + | name: "stops prefix at space", | |
| 41 | + | query: "!g query", | |
| 42 | + | wantKeys: []string{"g", "gh"}, | |
| 43 | + | }, | |
| 44 | + | { | |
| 45 | + | name: "falls back to url when description empty", | |
| 46 | + | query: "!n", | |
| 47 | + | wantKeys: []string{"n"}, | |
| 48 | + | wantDescr: map[string]string{ | |
| 49 | + | "n": "https://news.ycombinator.com/", | |
| 50 | + | }, | |
| 51 | + | }, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | for _, tc := range cases { | |
| 55 | + | t.Run(tc.name, func(t *testing.T) { | |
| 56 | + | output := uc.FilterBangs(ctx, FilterBangsInput{Query: tc.query}) | |
| 57 | + | got := output.Suggestions | |
| 58 | + | if len(got) != len(tc.wantKeys) { | |
| 59 | + | t.Fatalf("len=%d want=%d", len(got), len(tc.wantKeys)) | |
| 60 | + | } | |
| 61 | + | for i, wantKey := range tc.wantKeys { | |
| 62 | + | if got[i].Key != wantKey { | |
| 63 | + | t.Fatalf("idx %d key=%q want=%q", i, got[i].Key, wantKey) | |
| 64 | + | } | |
| 65 | + | if tc.wantDescr != nil { | |
| 66 | + | if wantD, ok := tc.wantDescr[wantKey]; ok { | |
| 67 | + | if got[i].Description != wantD { | |
| 68 | + | t.Fatalf("key %q description=%q want=%q", wantKey, got[i].Description, wantD) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestDetectBangKey(t *testing.T) { | |
| 78 | + | shortcuts := map[string]SearchShortcut{ | |
| 79 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub search"}, | |
| 80 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: ""}, | |
| 81 | + | } | |
| 82 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |
| 83 | + | ctx := context.Background() | |
| 84 | + | ||
| 85 | + | cases := []struct { | |
| 86 | + | name string | |
| 87 | + | query string | |
| 88 | + | wantKey string | |
| 89 | + | wantDescr string | |
| 90 | + | }{ | |
| 91 | + | {name: "no bang prefix", query: "gh test", wantKey: "", wantDescr: ""}, | |
| 92 | + | {name: "bang only has no space", query: "!gh", wantKey: "", wantDescr: ""}, | |
| 93 | + | {name: "space at position 1", query: "! test", wantKey: "", wantDescr: ""}, | |
| 94 | + | {name: "unknown bang", query: "!nope test", wantKey: "", wantDescr: ""}, | |
| 95 | + | {name: "case-insensitive match", query: "!GH test", wantKey: "gh", wantDescr: "GitHub search"}, | |
| 96 | + | {name: "valid bang key", query: "!ddg test", wantKey: "ddg", wantDescr: "https://duckduckgo.com/?q=%s"}, | |
| 97 | + | {name: "description fallback to URL", query: "!DDG query", wantKey: "ddg", wantDescr: "https://duckduckgo.com/?q=%s"}, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | for _, tc := range cases { | |
| 101 | + | t.Run(tc.name, func(t *testing.T) { | |
| 102 | + | output := uc.DetectBangKey(ctx, DetectBangKeyInput{Query: tc.query}) | |
| 103 | + | if output.Key != tc.wantKey { | |
| 104 | + | t.Fatalf("DetectBangKey(%q).Key=%q want=%q", tc.query, output.Key, tc.wantKey) | |
| 105 | + | } | |
| 106 | + | if output.Description != tc.wantDescr { | |
| 107 | + | t.Fatalf("DetectBangKey(%q).Description=%q want=%q", tc.query, output.Description, tc.wantDescr) | |
| 108 | + | } | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestBuildNavigationText(t *testing.T) { | |
| 114 | + | shortcuts := map[string]SearchShortcut{ | |
| 115 | + | "gh": {URL: "https://github.com/search?q=%s"}, | |
| 116 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s"}, | |
| 117 | + | } | |
| 118 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |
| 119 | + | ctx := context.Background() | |
| 120 | + | ||
| 121 | + | cases := []struct { | |
| 122 | + | name string | |
| 123 | + | entryText string | |
| 124 | + | want string | |
| 125 | + | wantValid bool | |
| 126 | + | }{ | |
| 127 | + | {name: "not a bang shortcut", entryText: "example.com", want: "", wantValid: false}, | |
| 128 | + | {name: "bang key without query", entryText: "!gh ", want: "", wantValid: false}, | |
| 129 | + | {name: "unknown bang key", entryText: "!nope test", want: "", wantValid: false}, | |
| 130 | + | {name: "normalizes key case", entryText: "!GH dumber", want: "!gh dumber", wantValid: true}, | |
| 131 | + | {name: "keeps query unchanged", entryText: "!ddg some query", want: "!ddg some query", wantValid: true}, | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | for _, tc := range cases { | |
| 135 | + | t.Run(tc.name, func(t *testing.T) { | |
| 136 | + | output := uc.BuildNavigationText(ctx, BuildNavigationTextInput{EntryText: tc.entryText}) | |
| 137 | + | if output.Text != tc.want { | |
| 138 | + | t.Fatalf("BuildNavigationText(%q).Text=%q want=%q", tc.entryText, output.Text, tc.want) | |
| 139 | + | } | |
| 140 | + | if output.Valid != tc.wantValid { | |
| 141 | + | t.Fatalf("BuildNavigationText(%q).Valid=%v want=%v", tc.entryText, output.Valid, tc.wantValid) | |
| 142 | + | } | |
| 143 | + | }) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestGetShortcut(t *testing.T) { | |
| 148 | + | shortcuts := map[string]SearchShortcut{ | |
| 149 | + | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"}, | |
| 150 | + | } | |
| 151 | + | uc := NewSearchShortcutsUseCase(shortcuts) | |
| 152 | + | ||
| 153 | + | cases := []struct { | |
| 154 | + | name string | |
| 155 | + | key string | |
| 156 | + | wantOK bool | |
| 157 | + | wantURL string | |
| 158 | + | }{ | |
| 159 | + | {name: "exact match", key: "gh", wantOK: true, wantURL: "https://github.com/search?q=%s"}, | |
| 160 | + | {name: "case insensitive", key: "GH", wantOK: true, wantURL: "https://github.com/search?q=%s"}, | |
| 161 | + | {name: "not found", key: "unknown", wantOK: false, wantURL: ""}, | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | for _, tc := range cases { | |
| 165 | + | t.Run(tc.name, func(t *testing.T) { | |
| 166 | + | shortcut, ok := uc.GetShortcut(tc.key) | |
| 167 | + | if ok != tc.wantOK { | |
| 168 | + | t.Fatalf("GetShortcut(%q) ok=%v want=%v", tc.key, ok, tc.wantOK) | |
| 169 | + | } | |
| 170 | + | if shortcut.URL != tc.wantURL { | |
| 171 | + | t.Fatalf("GetShortcut(%q).URL=%q want=%q", tc.key, shortcut.URL, tc.wantURL) | |
| 172 | + | } | |
| 173 | + | }) | |
| 174 | + | } | |
| 175 | + | } | |
A internal/application/usecase/session_lock.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // isSessionLocked checks if a session has an active lock file. | |
| 12 | + | // Lock files are created in lockDir with format: session_<sessionID>.lock | |
| 13 | + | // This matches the format used by bootstrap.sessionLockPath(). | |
| 14 | + | func isSessionLocked(lockDir string, sessionID entity.SessionID) bool { | |
| 15 | + | if lockDir == "" { | |
| 16 | + | return false | |
| 17 | + | } | |
| 18 | + | lockPath := filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID)) | |
| 19 | + | _, err := os.Stat(lockPath) | |
| 20 | + | return err == nil | |
| 21 | + | } | |
A internal/application/usecase/snapshot_session.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // SnapshotSessionUseCase handles saving session state snapshots. | |
| 13 | + | type SnapshotSessionUseCase struct { | |
| 14 | + | stateRepo repository.SessionStateRepository | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // NewSnapshotSessionUseCase creates a new SnapshotSessionUseCase. | |
| 18 | + | func NewSnapshotSessionUseCase(stateRepo repository.SessionStateRepository) *SnapshotSessionUseCase { | |
| 19 | + | return &SnapshotSessionUseCase{stateRepo: stateRepo} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // SnapshotInput contains the parameters for creating a session snapshot. | |
| 23 | + | type SnapshotInput struct { | |
| 24 | + | SessionID entity.SessionID | |
| 25 | + | TabList *entity.TabList | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Execute creates a snapshot of the current session state and saves it. | |
| 29 | + | func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error { | |
| 30 | + | log := logging.FromContext(ctx) | |
| 31 | + | ||
| 32 | + | if input.SessionID == "" { | |
| 33 | + | return fmt.Errorf("session id required") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | state := entity.SnapshotFromTabList(input.SessionID, input.TabList) | |
| 37 | + | ||
| 38 | + | log.Debug(). | |
| 39 | + | Str("session_id", string(input.SessionID)). | |
| 40 | + | Int("tab_count", len(state.Tabs)). | |
| 41 | + | Int("pane_count", state.CountPanes()). | |
| 42 | + | Msg("creating session snapshot") | |
| 43 | + | ||
| 44 | + | if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil { | |
| 45 | + | return fmt.Errorf("save session snapshot: %w", err) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | return nil | |
| 49 | + | } | |
A internal/application/usecase/snapshot_session_test.go +173 -0
| @@ -0,0 +1,173 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestSnapshotSessionUseCase_Execute_SavesSnapshot(t *testing.T) { | |
| 17 | + | ctx := testContext() | |
| 18 | + | ||
| 19 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 20 | + | ||
| 21 | + | // Create a tab list with some tabs | |
| 22 | + | tabList := entity.NewTabList() | |
| 23 | + | pane := entity.NewPane("pane-1") | |
| 24 | + | pane.URI = "https://example.com" | |
| 25 | + | pane.Title = "Example" | |
| 26 | + | tab := entity.NewTab("tab-1", "ws-1", pane) | |
| 27 | + | tab.Name = "Test Tab" | |
| 28 | + | tabList.Add(tab) | |
| 29 | + | ||
| 30 | + | sessionID := entity.SessionID("20251224_120000_test") | |
| 31 | + | ||
| 32 | + | stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |
| 33 | + | Run(func(_ context.Context, state *entity.SessionState) { | |
| 34 | + | require.Equal(t, sessionID, state.SessionID) | |
| 35 | + | require.Len(t, state.Tabs, 1) | |
| 36 | + | require.Equal(t, "Test Tab", state.Tabs[0].Name) | |
| 37 | + | require.Equal(t, entity.SessionStateVersion, state.Version) | |
| 38 | + | }). | |
| 39 | + | Return(nil) | |
| 40 | + | ||
| 41 | + | uc := usecase.NewSnapshotSessionUseCase(stateRepo) | |
| 42 | + | ||
| 43 | + | err := uc.Execute(ctx, usecase.SnapshotInput{ | |
| 44 | + | SessionID: sessionID, | |
| 45 | + | TabList: tabList, | |
| 46 | + | }) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestSnapshotSessionUseCase_Execute_EmptyTabList(t *testing.T) { | |
| 51 | + | ctx := testContext() | |
| 52 | + | ||
| 53 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 54 | + | ||
| 55 | + | sessionID := entity.SessionID("20251224_120000_empty") | |
| 56 | + | ||
| 57 | + | stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |
| 58 | + | Run(func(_ context.Context, state *entity.SessionState) { | |
| 59 | + | require.Equal(t, sessionID, state.SessionID) | |
| 60 | + | require.Empty(t, state.Tabs) | |
| 61 | + | }). | |
| 62 | + | Return(nil) | |
| 63 | + | ||
| 64 | + | uc := usecase.NewSnapshotSessionUseCase(stateRepo) | |
| 65 | + | ||
| 66 | + | err := uc.Execute(ctx, usecase.SnapshotInput{ | |
| 67 | + | SessionID: sessionID, | |
| 68 | + | TabList: nil, | |
| 69 | + | }) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestSnapshotSessionUseCase_Execute_RequiresSessionID(t *testing.T) { | |
| 74 | + | ctx := testContext() | |
| 75 | + | ||
| 76 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 77 | + | ||
| 78 | + | uc := usecase.NewSnapshotSessionUseCase(stateRepo) | |
| 79 | + | ||
| 80 | + | err := uc.Execute(ctx, usecase.SnapshotInput{ | |
| 81 | + | SessionID: "", | |
| 82 | + | TabList: entity.NewTabList(), | |
| 83 | + | }) | |
| 84 | + | require.Error(t, err) | |
| 85 | + | assert.Contains(t, err.Error(), "session id required") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) { | |
| 89 | + | sessionID := entity.SessionID("20251224_120000_snap") | |
| 90 | + | ||
| 91 | + | // Create a workspace with multiple panes | |
| 92 | + | pane1 := entity.NewPane("pane-1") | |
| 93 | + | pane1.URI = "https://github.com" | |
| 94 | + | pane1.Title = "GitHub" | |
| 95 | + | pane1.ZoomFactor = 1.2 | |
| 96 | + | ||
| 97 | + | pane2 := entity.NewPane("pane-2") | |
| 98 | + | pane2.URI = "https://google.com" | |
| 99 | + | pane2.Title = "Google" | |
| 100 | + | ||
| 101 | + | tab := entity.NewTab("tab-1", "ws-1", pane1) | |
| 102 | + | tab.Name = "Dev Tab" | |
| 103 | + | tab.IsPinned = true | |
| 104 | + | ||
| 105 | + | tabList := entity.NewTabList() | |
| 106 | + | tabList.Add(tab) | |
| 107 | + | ||
| 108 | + | state := entity.SnapshotFromTabList(sessionID, tabList) | |
| 109 | + | ||
| 110 | + | require.NotNil(t, state) | |
| 111 | + | assert.Equal(t, entity.SessionStateVersion, state.Version) | |
| 112 | + | assert.Equal(t, sessionID, state.SessionID) | |
| 113 | + | assert.Len(t, state.Tabs, 1) | |
| 114 | + | assert.Equal(t, "Dev Tab", state.Tabs[0].Name) | |
| 115 | + | assert.True(t, state.Tabs[0].IsPinned) | |
| 116 | + | assert.NotNil(t, state.Tabs[0].Workspace.Root) | |
| 117 | + | assert.Equal(t, "https://github.com", state.Tabs[0].Workspace.Root.Pane.URI) | |
| 118 | + | assert.InDelta(t, 1.2, state.Tabs[0].Workspace.Root.Pane.ZoomFactor, 0.001) | |
| 119 | + | assert.False(t, state.SavedAt.IsZero()) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestSessionState_CountPanes(t *testing.T) { | |
| 123 | + | state := &entity.SessionState{ | |
| 124 | + | Tabs: []entity.TabSnapshot{ | |
| 125 | + | { | |
| 126 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 127 | + | Root: &entity.PaneNodeSnapshot{ | |
| 128 | + | Pane: &entity.PaneSnapshot{ID: "pane-1"}, | |
| 129 | + | }, | |
| 130 | + | }, | |
| 131 | + | }, | |
| 132 | + | { | |
| 133 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 134 | + | Root: &entity.PaneNodeSnapshot{ | |
| 135 | + | Children: []*entity.PaneNodeSnapshot{ | |
| 136 | + | {Pane: &entity.PaneSnapshot{ID: "pane-2"}}, | |
| 137 | + | {Pane: &entity.PaneSnapshot{ID: "pane-3"}}, | |
| 138 | + | }, | |
| 139 | + | }, | |
| 140 | + | }, | |
| 141 | + | }, | |
| 142 | + | }, | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | assert.Equal(t, 3, state.CountPanes()) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestGetRelativeTime(t *testing.T) { | |
| 149 | + | now := time.Now() | |
| 150 | + | ||
| 151 | + | tests := []struct { | |
| 152 | + | name string | |
| 153 | + | t time.Time | |
| 154 | + | expected string | |
| 155 | + | }{ | |
| 156 | + | {"just now", now.Add(-30 * time.Second), "just now"}, | |
| 157 | + | {"1 minute", now.Add(-1 * time.Minute), "1m ago"}, | |
| 158 | + | {"5 minutes", now.Add(-5 * time.Minute), "05m ago"}, | |
| 159 | + | {"1 hour", now.Add(-1 * time.Hour), "1h ago"}, | |
| 160 | + | {"3 hours", now.Add(-3 * time.Hour), "03h ago"}, | |
| 161 | + | {"1 day", now.Add(-24 * time.Hour), "1d ago"}, | |
| 162 | + | {"2 days", now.Add(-48 * time.Hour), "02d ago"}, | |
| 163 | + | {"1 week", now.Add(-7 * 24 * time.Hour), "1w ago"}, | |
| 164 | + | {"2 weeks", now.Add(-14 * 24 * time.Hour), "02w ago"}, | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | for _, tt := range tests { | |
| 168 | + | t.Run(tt.name, func(t *testing.T) { | |
| 169 | + | result := usecase.GetRelativeTime(tt.t) | |
| 170 | + | assert.Equal(t, tt.expected, result) | |
| 171 | + | }) | |
| 172 | + | } | |
| 173 | + | } | |
A internal/bootstrap/gui.go +337 -0
| @@ -0,0 +1,337 @@ | |||
| 1 | + | // Package bootstrap provides initialization utilities for the browser. | |
| 2 | + | package bootstrap | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "database/sql" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "sync" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/dumber/internal/application/port" | |
| 14 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 15 | + | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 17 | + | "github.com/bnema/dumber/internal/infrastructure/deps" | |
| 18 | + | "github.com/bnema/dumber/internal/infrastructure/media" | |
| 19 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 20 | + | "github.com/bnema/dumber/internal/logging" | |
| 21 | + | "github.com/bnema/dumber/internal/ui/theme" | |
| 22 | + | sqlite3 "github.com/ncruces/go-sqlite3" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // DatabaseResult holds database connection and cleanup function. | |
| 26 | + | type DatabaseResult struct { | |
| 27 | + | DB *sql.DB | |
| 28 | + | Cleanup func() | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // ParallelInitResult holds the results of parallel initialization phase. | |
| 32 | + | type ParallelInitResult struct { | |
| 33 | + | DataDir string | |
| 34 | + | CacheDir string | |
| 35 | + | ThemeManager *theme.Manager | |
| 36 | + | ColorResolver port.ColorSchemeResolver | |
| 37 | + | AdwaitaDetector *colorscheme.AdwaitaDetector | |
| 38 | + | Duration time.Duration | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // DeferredInitResult holds results from deferred initialization checks. | |
| 42 | + | type DeferredInitResult struct { | |
| 43 | + | RuntimeErr error | |
| 44 | + | MediaErr error | |
| 45 | + | SQLiteErr error | |
| 46 | + | Duration time.Duration | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // ParallelInitInput holds the input for parallel initialization. | |
| 50 | + | type ParallelInitInput struct { | |
| 51 | + | Ctx context.Context | |
| 52 | + | Config *config.Config | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // DeferredInitInput holds the input for deferred initialization. | |
| 56 | + | type DeferredInitInput struct { | |
| 57 | + | Ctx context.Context | |
| 58 | + | Config *config.Config | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // RuntimeRequirementsError contains details about missing runtime dependencies. | |
| 62 | + | type RuntimeRequirementsError struct { | |
| 63 | + | Checks []usecase.RuntimeDependencyStatus | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (*RuntimeRequirementsError) Error() string { | |
| 67 | + | return "runtime requirements not met" | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // LogDetails logs detailed information about missing dependencies. | |
| 71 | + | func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) { | |
| 72 | + | log := logging.FromContext(ctx) | |
| 73 | + | for _, c := range e.Checks { | |
| 74 | + | if c.Installed { | |
| 75 | + | log.Error(). | |
| 76 | + | Str("dependency", c.PkgConfigName). | |
| 77 | + | Str("have", c.Version). | |
| 78 | + | Str("need", c.RequiredVersion). | |
| 79 | + | Bool("ok", c.MeetsRequirement). | |
| 80 | + | Msg("runtime dependency") | |
| 81 | + | } else { | |
| 82 | + | log.Error(). | |
| 83 | + | Str("dependency", c.PkgConfigName). | |
| 84 | + | Str("need", c.RequiredVersion). | |
| 85 | + | Msg("runtime dependency missing") | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // RunParallelInit runs the essential parallel initialization phase. | |
| 91 | + | // This includes directory resolution, color scheme resolver creation, and theme creation. | |
| 92 | + | // Returns the first fatal error encountered, or nil with the results. | |
| 93 | + | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |
| 94 | + | var ( | |
| 95 | + | dataDir, cacheDir string | |
| 96 | + | dirsErr error | |
| 97 | + | wg sync.WaitGroup | |
| 98 | + | ) | |
| 99 | + | ||
| 100 | + | start := time.Now() | |
| 101 | + | ||
| 102 | + | // Create color scheme resolver with initial detectors. | |
| 103 | + | // AdwaitaDetector is created but NOT marked available yet (requires adw.Init()). | |
| 104 | + | configAdapter := colorscheme.NewConfigAdapter(input.Config) | |
| 105 | + | resolver := colorscheme.NewResolver(configAdapter) | |
| 106 | + | ||
| 107 | + | // Register detectors that are available before GTK init | |
| 108 | + | resolver.RegisterDetector(colorscheme.NewEnvDetector()) | |
| 109 | + | resolver.RegisterDetector(colorscheme.NewGsettingsDetector()) | |
| 110 | + | ||
| 111 | + | // Create adwaita detector (will be marked available after adw.Init()) | |
| 112 | + | adwaitaDetector := colorscheme.NewAdwaitaDetector() | |
| 113 | + | resolver.RegisterDetector(adwaitaDetector) | |
| 114 | + | ||
| 115 | + | wg.Add(2) | |
| 116 | + | ||
| 117 | + | // Resolve directories | |
| 118 | + | go func() { | |
| 119 | + | defer wg.Done() | |
| 120 | + | dataDir, cacheDir, dirsErr = resolveWebKitDirs() | |
| 121 | + | }() | |
| 122 | + | ||
| 123 | + | // Theme manager (CPU-bound, no I/O) | |
| 124 | + | var themeManager *theme.Manager | |
| 125 | + | go func() { | |
| 126 | + | defer wg.Done() | |
| 127 | + | themeManager = theme.NewManager(input.Ctx, input.Config, resolver) | |
| 128 | + | }() | |
| 129 | + | ||
| 130 | + | wg.Wait() | |
| 131 | + | duration := time.Since(start) | |
| 132 | + | ||
| 133 | + | // Return first fatal error | |
| 134 | + | if dirsErr != nil { | |
| 135 | + | return nil, fmt.Errorf("resolve directories: %w", dirsErr) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | return &ParallelInitResult{ | |
| 139 | + | DataDir: dataDir, | |
| 140 | + | CacheDir: cacheDir, | |
| 141 | + | ThemeManager: themeManager, | |
| 142 | + | ColorResolver: resolver, | |
| 143 | + | AdwaitaDetector: adwaitaDetector, | |
| 144 | + | Duration: duration, | |
| 145 | + | }, nil | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // RunDeferredInit runs deferred initialization checks off the critical path. | |
| 149 | + | // This includes SQLite WASM precompile, runtime requirements, and media checks. | |
| 150 | + | func RunDeferredInit(input DeferredInitInput) DeferredInitResult { | |
| 151 | + | var ( | |
| 152 | + | runtimeErr error | |
| 153 | + | mediaErr error | |
| 154 | + | sqliteErr error | |
| 155 | + | wg sync.WaitGroup | |
| 156 | + | ) | |
| 157 | + | ||
| 158 | + | start := time.Now() | |
| 159 | + | wg.Add(3) | |
| 160 | + | ||
| 161 | + | go func() { | |
| 162 | + | defer wg.Done() | |
| 163 | + | sqliteErr = sqlite3.Initialize() | |
| 164 | + | }() | |
| 165 | + | ||
| 166 | + | go func() { | |
| 167 | + | defer wg.Done() | |
| 168 | + | runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config) | |
| 169 | + | }() | |
| 170 | + | ||
| 171 | + | go func() { | |
| 172 | + | defer wg.Done() | |
| 173 | + | mediaErr = CheckMediaRequirements(input.Ctx, input.Config) | |
| 174 | + | }() | |
| 175 | + | ||
| 176 | + | wg.Wait() | |
| 177 | + | ||
| 178 | + | return DeferredInitResult{ | |
| 179 | + | RuntimeErr: runtimeErr, | |
| 180 | + | MediaErr: mediaErr, | |
| 181 | + | SQLiteErr: sqliteErr, | |
| 182 | + | Duration: time.Since(start), | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies. | |
| 187 | + | // Returns error if requirements are not met; caller should log details and exit. | |
| 188 | + | func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error { | |
| 189 | + | probe := deps.NewPkgConfigProbe() | |
| 190 | + | checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe) | |
| 191 | + | runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{ | |
| 192 | + | Prefix: cfg.Runtime.Prefix, | |
| 193 | + | }) | |
| 194 | + | if err != nil { | |
| 195 | + | return fmt.Errorf("runtime check execution failed: %w", err) | |
| 196 | + | } | |
| 197 | + | if runtimeOut.OK { | |
| 198 | + | return nil | |
| 199 | + | } | |
| 200 | + | return &RuntimeRequirementsError{Checks: runtimeOut.Checks} | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // CheckMediaRequirements verifies GStreamer and media codec availability. | |
| 204 | + | // Returns error if media subsystem initialization fails. | |
| 205 | + | func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error { | |
| 206 | + | mediaDiagAdapter := media.New() | |
| 207 | + | checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter) | |
| 208 | + | if _, err := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{ | |
| 209 | + | ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup, | |
| 210 | + | }); err != nil { | |
| 211 | + | return fmt.Errorf("media check failed: %w", err) | |
| 212 | + | } | |
| 213 | + | return nil | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // resolveWebKitDirs resolves and creates the data and cache directories for WebKit. | |
| 217 | + | func resolveWebKitDirs() (dataDir, cacheDir string, err error) { | |
| 218 | + | const cacheDirPerm = 0o755 | |
| 219 | + | dataDir, err = config.GetDataDir() | |
| 220 | + | if err != nil { | |
| 221 | + | return "", "", fmt.Errorf("resolve data directory: %w", err) | |
| 222 | + | } | |
| 223 | + | stateDir, err := config.GetStateDir() | |
| 224 | + | if err != nil { | |
| 225 | + | return "", "", fmt.Errorf("resolve state directory: %w", err) | |
| 226 | + | } | |
| 227 | + | cacheDir = filepath.Join(stateDir, "webkit-cache") | |
| 228 | + | if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil { | |
| 229 | + | return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr) | |
| 230 | + | } | |
| 231 | + | return dataDir, cacheDir, nil | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // OpenDatabase opens and initializes the SQLite database using XDG paths. | |
| 235 | + | // Returns error if database cannot be opened or migrations fail. | |
| 236 | + | func OpenDatabase(ctx context.Context) (*DatabaseResult, error) { | |
| 237 | + | dbPath, err := config.GetDatabaseFile() | |
| 238 | + | if err != nil { | |
| 239 | + | return nil, fmt.Errorf("resolve database path: %w", err) | |
| 240 | + | } | |
| 241 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 242 | + | if err != nil { | |
| 243 | + | return nil, fmt.Errorf("initialize database at %s: %w", dbPath, err) | |
| 244 | + | } | |
| 245 | + | return &DatabaseResult{ | |
| 246 | + | DB: db, | |
| 247 | + | Cleanup: func() { | |
| 248 | + | _ = sqlite.Close(db) | |
| 249 | + | }, | |
| 250 | + | }, nil | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // CreateLazyDatabase creates a lazy database provider that defers initialization. | |
| 254 | + | // The database is initialized on first access, allowing the UI to render faster. | |
| 255 | + | // | |
| 256 | + | // Currently unused: the application uses OpenDatabase with RunParallelDBWebKit for | |
| 257 | + | // eager initialization. This function is kept for potential future use when lazy | |
| 258 | + | // initialization past first paint becomes beneficial. | |
| 259 | + | func CreateLazyDatabase() (*sqlite.LazyDB, error) { | |
| 260 | + | dbPath, err := config.GetDatabaseFile() | |
| 261 | + | if err != nil { | |
| 262 | + | return nil, fmt.Errorf("resolve database path: %w", err) | |
| 263 | + | } | |
| 264 | + | return sqlite.NewLazyDB(dbPath), nil | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // ParallelDBWebKitResult holds results from parallel DB and WebKit initialization. | |
| 268 | + | type ParallelDBWebKitResult struct { | |
| 269 | + | DB *sql.DB | |
| 270 | + | DBCleanup func() | |
| 271 | + | Stack WebKitStack | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // ParallelDBWebKitInput holds inputs for parallel DB and WebKit initialization. | |
| 275 | + | type ParallelDBWebKitInput struct { | |
| 276 | + | Ctx context.Context | |
| 277 | + | Config *config.Config | |
| 278 | + | DataDir string // For WebKit context | |
| 279 | + | CacheDir string // For WebKit cache | |
| 280 | + | ThemeManager *theme.Manager | |
| 281 | + | ColorResolver port.ColorSchemeResolver | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // Note: Database path is resolved via config.GetDatabaseFile() internally. | |
| 285 | + | ||
| 286 | + | // dbInitResult holds the result of database initialization for channel communication. | |
| 287 | + | type dbInitResult struct { | |
| 288 | + | db *sql.DB | |
| 289 | + | cleanup func() | |
| 290 | + | err error | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // RunParallelDBWebKit initializes database in background while WebKit runs on main thread. | |
| 294 | + | // Database init happens in goroutine (pure Go/WASM), WebKit must stay on main thread (GTK). | |
| 295 | + | // This saves ~150ms by overlapping DB migrations with WebKit context creation. | |
| 296 | + | func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult, error) { | |
| 297 | + | log := logging.FromContext(input.Ctx) | |
| 298 | + | ||
| 299 | + | dbCh := make(chan dbInitResult, 1) | |
| 300 | + | ||
| 301 | + | // Database initialization in background (WASM is thread-safe) | |
| 302 | + | go func() { | |
| 303 | + | dbResult, err := OpenDatabase(input.Ctx) | |
| 304 | + | if err != nil { | |
| 305 | + | dbCh <- dbInitResult{err: err} | |
| 306 | + | return | |
| 307 | + | } | |
| 308 | + | dbCh <- dbInitResult{ | |
| 309 | + | db: dbResult.DB, | |
| 310 | + | cleanup: dbResult.Cleanup, | |
| 311 | + | } | |
| 312 | + | }() | |
| 313 | + | ||
| 314 | + | // WebKit stack on main thread (GTK requirement) | |
| 315 | + | stack := BuildWebKitStack(WebKitStackInput{ | |
| 316 | + | Ctx: input.Ctx, | |
| 317 | + | Config: input.Config, | |
| 318 | + | DataDir: input.DataDir, | |
| 319 | + | CacheDir: input.CacheDir, | |
| 320 | + | ThemeManager: input.ThemeManager, | |
| 321 | + | ColorResolver: input.ColorResolver, | |
| 322 | + | Logger: *log, | |
| 323 | + | }) | |
| 324 | + | ||
| 325 | + | // Wait for database | |
| 326 | + | dbRes := <-dbCh | |
| 327 | + | ||
| 328 | + | if dbRes.err != nil { | |
| 329 | + | return nil, fmt.Errorf("database initialization: %w", dbRes.err) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | return &ParallelDBWebKitResult{ | |
| 333 | + | DB: dbRes.db, | |
| 334 | + | DBCleanup: dbRes.cleanup, | |
| 335 | + | Stack: stack, | |
| 336 | + | }, nil | |
| 337 | + | } | |
M internal/bootstrap/session.go +148 -39
| @@ -2,20 +2,20 @@ package bootstrap | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "database/sql" | |
| 6 | 5 | "errors" | |
| 7 | 6 | "fmt" | |
| 8 | 7 | "os" | |
| 9 | 8 | "path/filepath" | |
| 9 | + | "sync" | |
| 10 | 10 | "syscall" | |
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/dumber/internal/application/port" | |
| 14 | 14 | "github.com/bnema/dumber/internal/application/usecase" | |
| 15 | 15 | "github.com/bnema/dumber/internal/domain/entity" | |
| 16 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 17 | 18 | infralogging "github.com/bnema/dumber/internal/infrastructure/logging" | |
| 18 | - | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 19 | 19 | corelogging "github.com/bnema/dumber/internal/logging" | |
| 20 | 20 | "github.com/rs/zerolog" | |
| 21 | 21 | ) | |
| @@ -31,11 +31,20 @@ type BrowserSession struct { | |||
| 31 | 31 | Logger zerolog.Logger | |
| 32 | 32 | LogCleanup func() | |
| 33 | 33 | ||
| 34 | - | End func(context.Context) error | |
| 34 | + | Persist func(context.Context) error | |
| 35 | + | End func(context.Context) error | |
| 35 | 36 | } | |
| 36 | 37 | ||
| 37 | - | func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*BrowserSession, context.Context, error) { | |
| 38 | + | func StartBrowserSession( | |
| 39 | + | ctx context.Context, | |
| 40 | + | cfg *config.Config, | |
| 41 | + | sessionRepo repository.SessionRepository, | |
| 42 | + | deferPersist bool, | |
| 43 | + | ) (*BrowserSession, context.Context, error) { | |
| 38 | 44 | log := corelogging.FromContext(ctx) | |
| 45 | + | if sessionRepo == nil { | |
| 46 | + | return nil, ctx, errors.New("session repository is nil") | |
| 47 | + | } | |
| 39 | 48 | ||
| 40 | 49 | lockDir := cfg.Logging.LogDir | |
| 41 | 50 | if lockDir == "" { | |
| @@ -44,58 +53,112 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (* | |||
| 44 | 53 | } | |
| 45 | 54 | } | |
| 46 | 55 | ||
| 47 | - | sessionRepo := sqlite.NewSessionRepository(db) | |
| 48 | 56 | sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter() | |
| 49 | 57 | sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter) | |
| 58 | + | cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 50 | 59 | ||
| 51 | - | // If a previous browser process died without cleanup, it may have left sessions "active". | |
| 52 | - | // Use per-session lock files to distinguish truly running sessions from stale ones. | |
| 53 | - | if lockDir != "" { | |
| 54 | - | if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil { | |
| 55 | - | log.Warn().Err(err).Msg("failed to mark stale sessions ended") | |
| 56 | - | } | |
| 60 | + | now := time.Now() | |
| 61 | + | session := &entity.Session{ | |
| 62 | + | ID: entity.SessionID(corelogging.GenerateSessionID()), | |
| 63 | + | Type: entity.SessionTypeBrowser, | |
| 64 | + | StartedAt: now.UTC(), | |
| 65 | + | } | |
| 66 | + | if err := session.Validate(); err != nil { | |
| 67 | + | return nil, ctx, err | |
| 57 | 68 | } | |
| 58 | 69 | ||
| 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, | |
| 71 | 77 | }) | |
| 72 | 78 | if err != nil { | |
| 73 | - | return nil, ctx, err | |
| 79 | + | log.Warn().Err(err).Msg("failed to create session logger") | |
| 74 | 80 | } | |
| 75 | 81 | ||
| 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) | |
| 81 | 83 | ||
| 82 | - | sessionCtx := corelogging.WithContext(context.Background(), out.Logger) | |
| 84 | + | var ( | |
| 85 | + | lockMu sync.Mutex | |
| 86 | + | lockFile *os.File | |
| 87 | + | lockPath string | |
| 88 | + | persistMu sync.Once | |
| 89 | + | persistErr error | |
| 90 | + | ) | |
| 91 | + | ||
| 92 | + | // persistFn ensures the session is saved exactly once. Using sync.Once is intentional: | |
| 93 | + | // if the first persist fails, we cache the error and return it on subsequent calls | |
| 94 | + | // rather than retrying. This prevents duplicate session records and ensures consistent | |
| 95 | + | // error handling during shutdown sequences where persistFn may be called multiple times. | |
| 96 | + | persistFn := func(persistCtx context.Context) error { | |
| 97 | + | persistMu.Do(func() { | |
| 98 | + | saveCtx := corelogging.WithContext(context.Background(), logger) | |
| 99 | + | if err := sessionRepo.Save(saveCtx, session); err != nil { | |
| 100 | + | persistErr = fmt.Errorf("save session: %w", err) | |
| 101 | + | return | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | logger.Info(). | |
| 105 | + | Str("session_id", string(session.ID)). | |
| 106 | + | Str("type", string(session.Type)). | |
| 107 | + | Msg("session started") | |
| 108 | + | ||
| 109 | + | lf, lp, lockErr := lockSessionFile(lockDir, session.ID) | |
| 110 | + | if lockErr != nil { | |
| 111 | + | // Don’t fail startup if locking fails; it only affects stale-session cleanup. | |
| 112 | + | logger.Warn().Err(lockErr).Msg("failed to acquire session lock") | |
| 113 | + | } else { | |
| 114 | + | lockMu.Lock() | |
| 115 | + | lockFile = lf | |
| 116 | + | lockPath = lp | |
| 117 | + | lockMu.Unlock() | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Defer stale session cleanup and old session pruning to background. | |
| 121 | + | // This avoids blocking startup for non-critical housekeeping tasks. | |
| 122 | + | runSessionCleanupAsync(persistCtx, sessionUC, cleanupUC, cfg, lockDir, log) | |
| 123 | + | }) | |
| 124 | + | return persistErr | |
| 125 | + | } | |
| 83 | 126 | ||
| 84 | 127 | 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) | |
| 89 | 139 | } | |
| 90 | 140 | return endErr | |
| 91 | 141 | } | |
| 92 | 142 | ||
| 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, | |
| 97 | 147 | 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 | |
| 99 | 162 | } | |
| 100 | 163 | ||
| 101 | 164 | func endStaleActiveBrowserSessions( | |
| @@ -199,3 +262,49 @@ func unlockAndClose(f *os.File) error { | |||
| 199 | 262 | _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) | |
| 200 | 263 | return f.Close() | |
| 201 | 264 | } | |
| 265 | + | ||
| 266 | + | // runSessionCleanupAsync performs stale session cleanup and old session pruning | |
| 267 | + | // in a background goroutine. This avoids blocking startup for non-critical tasks. | |
| 268 | + | // Uses context.Background() to ensure cleanup completes even if startup context is canceled. | |
| 269 | + | func runSessionCleanupAsync( | |
| 270 | + | startupCtx context.Context, | |
| 271 | + | sessionUC *usecase.ManageSessionUseCase, | |
| 272 | + | cleanupUC *usecase.CleanupSessionsUseCase, | |
| 273 | + | cfg *config.Config, | |
| 274 | + | lockDir string, | |
| 275 | + | log *zerolog.Logger, | |
| 276 | + | ) { | |
| 277 | + | // Silence unused parameter warning - startupCtx is intentionally unused. | |
| 278 | + | _ = startupCtx | |
| 279 | + | ||
| 280 | + | go func() { | |
| 281 | + | // Use a detached background context instead of the startup context: | |
| 282 | + | // session cleanup is critical and must run to completion even if the | |
| 283 | + | // startup context is canceled or times out. | |
| 284 | + | bgCtx := context.Background() | |
| 285 | + | ||
| 286 | + | // End stale active sessions (orphaned from crashed processes) | |
| 287 | + | if lockDir != "" { | |
| 288 | + | if err := endStaleActiveBrowserSessions(bgCtx, sessionUC, lockDir, log); err != nil { | |
| 289 | + | if log != nil { | |
| 290 | + | log.Warn().Err(err).Msg("background: failed to mark stale sessions ended") | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | // Clean up old exited sessions based on config limits | |
| 296 | + | cleanupOutput, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{ | |
| 297 | + | MaxExitedSessions: cfg.Session.MaxExitedSessions, | |
| 298 | + | MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays, | |
| 299 | + | }) | |
| 300 | + | if err != nil { | |
| 301 | + | if log != nil { | |
| 302 | + | log.Warn().Err(err).Msg("background: failed to cleanup old sessions") | |
| 303 | + | } | |
| 304 | + | } else if cleanupOutput.TotalDeleted > 0 { | |
| 305 | + | if log != nil { | |
| 306 | + | log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions") | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | }() | |
| 310 | + | } | |
A internal/bootstrap/timing.go +94 -0
| @@ -0,0 +1,94 @@ | |||
| 1 | + | // Package bootstrap provides initialization utilities for the browser. | |
| 2 | + | package bootstrap | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "sync" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // StartupTimer tracks timing for cold start phases. | |
| 13 | + | // Thread-safe for use with parallel initialization. | |
| 14 | + | type StartupTimer struct { | |
| 15 | + | start time.Time | |
| 16 | + | phases map[string]time.Duration | |
| 17 | + | order []string // Track insertion order for logging | |
| 18 | + | last time.Time | |
| 19 | + | mu sync.Mutex | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // NewStartupTimer creates a new timer starting from now. | |
| 23 | + | func NewStartupTimer() *StartupTimer { | |
| 24 | + | now := time.Now() | |
| 25 | + | return &StartupTimer{ | |
| 26 | + | start: now, | |
| 27 | + | phases: make(map[string]time.Duration), | |
| 28 | + | order: make([]string, 0), | |
| 29 | + | last: now, | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // Mark records the duration since the last mark (or start) for the given phase. | |
| 34 | + | func (t *StartupTimer) Mark(phase string) { | |
| 35 | + | t.mu.Lock() | |
| 36 | + | defer t.mu.Unlock() | |
| 37 | + | ||
| 38 | + | now := time.Now() | |
| 39 | + | t.phases[phase] = now.Sub(t.last) | |
| 40 | + | t.order = append(t.order, phase) | |
| 41 | + | t.last = now | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // MarkDuration records a specific duration for a phase. | |
| 45 | + | // Useful for operations timed independently (e.g., parallel goroutines). | |
| 46 | + | func (t *StartupTimer) MarkDuration(phase string, d time.Duration) { | |
| 47 | + | t.mu.Lock() | |
| 48 | + | defer t.mu.Unlock() | |
| 49 | + | ||
| 50 | + | t.phases[phase] = d | |
| 51 | + | t.order = append(t.order, phase) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Total returns the total elapsed time since timer creation. | |
| 55 | + | // Thread-safe: uses mutex for memory visibility guarantees. | |
| 56 | + | func (t *StartupTimer) Total() time.Duration { | |
| 57 | + | t.mu.Lock() | |
| 58 | + | defer t.mu.Unlock() | |
| 59 | + | return time.Since(t.start) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Log outputs all timing information to the context logger. | |
| 63 | + | func (t *StartupTimer) Log(ctx context.Context) { | |
| 64 | + | t.mu.Lock() | |
| 65 | + | defer t.mu.Unlock() | |
| 66 | + | ||
| 67 | + | log := logging.FromContext(ctx) | |
| 68 | + | total := time.Since(t.start) | |
| 69 | + | ||
| 70 | + | event := log.Info().Dur("total", total) | |
| 71 | + | for _, phase := range t.order { | |
| 72 | + | if dur, ok := t.phases[phase]; ok { | |
| 73 | + | event = event.Dur(phase, dur) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | event.Msg("startup timing") | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // LogDebug outputs timing as debug level (for less verbose production logs). | |
| 80 | + | func (t *StartupTimer) LogDebug(ctx context.Context) { | |
| 81 | + | t.mu.Lock() | |
| 82 | + | defer t.mu.Unlock() | |
| 83 | + | ||
| 84 | + | log := logging.FromContext(ctx) | |
| 85 | + | total := time.Since(t.start) | |
| 86 | + | ||
| 87 | + | event := log.Debug().Dur("total", total) | |
| 88 | + | for _, phase := range t.order { | |
| 89 | + | if dur, ok := t.phases[phase]; ok { | |
| 90 | + | event = event.Dur(phase, dur) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | event.Msg("startup timing") | |
| 94 | + | } | |
M internal/bootstrap/webkit_stack.go +141 -39
| @@ -15,6 +15,17 @@ import ( | |||
| 15 | 15 | "github.com/rs/zerolog" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | + | // WebKitStackInput holds the input for BuildWebKitStack. | |
| 19 | + | type WebKitStackInput struct { | |
| 20 | + | Ctx context.Context | |
| 21 | + | Config *config.Config | |
| 22 | + | DataDir string | |
| 23 | + | CacheDir string | |
| 24 | + | ThemeManager *theme.Manager | |
| 25 | + | ColorResolver port.ColorSchemeResolver | |
| 26 | + | Logger zerolog.Logger | |
| 27 | + | } | |
| 28 | + | ||
| 18 | 29 | type WebKitStack struct { | |
| 19 | 30 | Context *webkit.WebKitContext | |
| 20 | 31 | Settings *webkit.SettingsManager | |
| @@ -24,50 +35,64 @@ type WebKitStack struct { | |||
| 24 | 35 | FilterManager *filtering.Manager | |
| 25 | 36 | } | |
| 26 | 37 | ||
| 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") | |
| 46 | 56 | ||
| 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") | |
| 51 | 65 | ||
| 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) | |
| 56 | 68 | ||
| 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, | |
| 61 | 73 | } | |
| 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 | + | } | |
| 64 | 93 | } | |
| 65 | - | logger.Info(). | |
| 66 | - | Str("gpu", string(gpuVendor)). | |
| 67 | - | Interface("vars", renderEnv.GetAppliedVars()). | |
| 68 | - | Msg("rendering environment configured") | |
| 69 | 94 | ||
| 70 | - | wkCtx, err := webkit.NewWebKitContext(ctx, dataDir, cacheDir) | |
| 95 | + | wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts) | |
| 71 | 96 | if err != nil { | |
| 72 | 97 | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | |
| 73 | 98 | } | |
| @@ -93,7 +118,7 @@ func BuildWebKitStack( | |||
| 93 | 118 | schemeHandler.RegisterWithContext(wkCtx) | |
| 94 | 119 | ||
| 95 | 120 | settings := webkit.NewSettingsManager(ctx, cfg) | |
| 96 | - | injector := webkit.NewContentInjector(themeManager.PrefersDark()) | |
| 121 | + | injector := webkit.NewContentInjector(colorResolver) | |
| 97 | 122 | ||
| 98 | 123 | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector) | |
| 99 | 124 | themeCSSText := themeManager.GetWebUIThemeCSS() | |
| @@ -103,12 +128,26 @@ func BuildWebKitStack( | |||
| 103 | 128 | ||
| 104 | 129 | messageRouter := webkit.NewMessageRouter(ctx) | |
| 105 | 130 | poolCfg := webkit.DefaultPoolConfig() | |
| 131 | + | // Override prewarm count from resolved profile settings | |
| 132 | + | if perfSettings.WebViewPoolPrewarmCount > 0 { | |
| 133 | + | poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount | |
| 134 | + | } | |
| 106 | 135 | pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter) | |
| 136 | + | // Ensure prewarmed WebViews pick up the theme background color. | |
| 137 | + | bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA() | |
| 138 | + | pool.SetBackgroundColor(bgR, bgG, bgB, bgA) | |
| 107 | 139 | ||
| 108 | 140 | if filterManager != nil { | |
| 109 | 141 | pool.SetFilterApplier(filterManager) | |
| 110 | 142 | } | |
| 111 | 143 | ||
| 144 | + | // Pre-create ONE WebView synchronously for instant first navigation. | |
| 145 | + | // This is the heaviest operation but happens before window is shown, | |
| 146 | + | // so users perceive it as part of the normal "loading" phase. | |
| 147 | + | if err := pool.PrewarmFirst(ctx); err != nil { | |
| 148 | + | logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower") | |
| 149 | + | } | |
| 150 | + | ||
| 112 | 151 | return WebKitStack{ | |
| 113 | 152 | Context: wkCtx, | |
| 114 | 153 | Settings: settings, | |
| @@ -118,3 +157,66 @@ func BuildWebKitStack( | |||
| 118 | 157 | FilterManager: filterManager, | |
| 119 | 158 | } | |
| 120 | 159 | } | |
| 160 | + | ||
| 161 | + | // hasWebProcessMemoryConfig returns true if any web process memory setting is configured. | |
| 162 | + | func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool { | |
| 163 | + | return p.WebProcessMemoryLimitMB > 0 || | |
| 164 | + | p.WebProcessMemoryPollIntervalSec > 0 || | |
| 165 | + | p.WebProcessMemoryConservativeThreshold > 0 || | |
| 166 | + | p.WebProcessMemoryStrictThreshold > 0 | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // hasNetworkProcessMemoryConfig returns true if any network process memory setting is configured. | |
| 170 | + | func hasNetworkProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool { | |
| 171 | + | return p.NetworkProcessMemoryLimitMB > 0 || | |
| 172 | + | p.NetworkProcessMemoryPollIntervalSec > 0 || | |
| 173 | + | p.NetworkProcessMemoryConservativeThreshold > 0 || | |
| 174 | + | p.NetworkProcessMemoryStrictThreshold > 0 | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // configureRenderingEnvironment sets up the rendering environment before WebKit/GTK initialization. | |
| 178 | + | // Environment variables must be set before GTK/WebKit/GStreamer initializes. | |
| 179 | + | func configureRenderingEnvironment( | |
| 180 | + | ctx context.Context, | |
| 181 | + | cfg *config.Config, | |
| 182 | + | perfSettings *config.ResolvedPerformanceSettings, | |
| 183 | + | logger zerolog.Logger, | |
| 184 | + | ) { | |
| 185 | + | renderEnv := env.NewManager() | |
| 186 | + | gpuVendor := renderEnv.DetectGPUVendor(ctx) | |
| 187 | + | renderSettings := port.RenderingEnvSettings{ | |
| 188 | + | // GStreamer settings | |
| 189 | + | ForceVSync: cfg.Media.ForceVSync, | |
| 190 | + | GLRenderingMode: string(cfg.Media.GLRenderingMode), | |
| 191 | + | GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel, | |
| 192 | + | VideoBufferSizeMB: cfg.Media.VideoBufferSizeMB, | |
| 193 | + | QueueBufferTimeSec: cfg.Media.QueueBufferTimeSec, | |
| 194 | + | ||
| 195 | + | // WebKit compositor settings | |
| 196 | + | DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer, | |
| 197 | + | ForceCompositingMode: cfg.Rendering.ForceCompositingMode, | |
| 198 | + | DisableCompositingMode: cfg.Rendering.DisableCompositingMode, | |
| 199 | + | ||
| 200 | + | // GTK/GSK settings | |
| 201 | + | GSKRenderer: string(cfg.Rendering.GSKRenderer), | |
| 202 | + | DisableMipmaps: cfg.Rendering.DisableMipmaps, | |
| 203 | + | PreferGL: cfg.Rendering.PreferGL, | |
| 204 | + | ||
| 205 | + | // Debug settings | |
| 206 | + | ShowFPS: cfg.Rendering.ShowFPS, | |
| 207 | + | SampleMemory: cfg.Rendering.SampleMemory, | |
| 208 | + | DebugFrames: cfg.Rendering.DebugFrames, | |
| 209 | + | ||
| 210 | + | // Skia rendering thread settings (from resolved profile) | |
| 211 | + | SkiaCPUPaintingThreads: perfSettings.SkiaCPUPaintingThreads, | |
| 212 | + | SkiaGPUPaintingThreads: perfSettings.SkiaGPUPaintingThreads, | |
| 213 | + | SkiaEnableCPURendering: perfSettings.SkiaEnableCPURendering, | |
| 214 | + | } | |
| 215 | + | if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil { | |
| 216 | + | logger.Warn().Err(err).Msg("failed to apply rendering environment") | |
| 217 | + | } | |
| 218 | + | logger.Info(). | |
| 219 | + | Str("gpu", string(gpuVendor)). | |
| 220 | + | Interface("vars", renderEnv.GetAppliedVars()). | |
| 221 | + | Msg("rendering environment configured") | |
| 222 | + | } | |
M internal/cli/app.go +10 -0
| @@ -31,6 +31,9 @@ type App struct { | |||
| 31 | 31 | // Use cases | |
| 32 | 32 | SearchHistoryUC *usecase.SearchHistoryUseCase | |
| 33 | 33 | SessionUC *usecase.ManageSessionUseCase | |
| 34 | + | ListSessionsUC *usecase.ListSessionsUseCase | |
| 35 | + | RestoreUC *usecase.RestoreSessionUseCase | |
| 36 | + | DeleteSessionUC *usecase.DeleteSessionUseCase | |
| 34 | 37 | ||
| 35 | 38 | // Services | |
| 36 | 39 | FaviconService *favicon.Service | |
| @@ -105,9 +108,13 @@ func NewApp() (*App, error) { | |||
| 105 | 108 | ||
| 106 | 109 | // Create repositories | |
| 107 | 110 | historyRepo := sqlite.NewHistoryRepository(db) | |
| 111 | + | sessionStateRepo := sqlite.NewSessionStateRepository(db) | |
| 108 | 112 | ||
| 109 | 113 | // Create use cases | |
| 110 | 114 | searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 115 | + | listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo, cfg.Logging.LogDir) | |
| 116 | + | restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo) | |
| 117 | + | deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo, cfg.Logging.LogDir) | |
| 111 | 118 | ||
| 112 | 119 | // Create favicon service for CLI (path resolution for dmenu/fuzzel) | |
| 113 | 120 | faviconCacheDir, _ := config.GetFaviconCacheDir() | |
| @@ -120,6 +127,9 @@ func NewApp() (*App, error) { | |||
| 120 | 127 | History: historyRepo, | |
| 121 | 128 | SearchHistoryUC: searchHistoryUC, | |
| 122 | 129 | SessionUC: sessionUC, | |
| 130 | + | ListSessionsUC: listSessionsUC, | |
| 131 | + | RestoreUC: restoreUC, | |
| 132 | + | DeleteSessionUC: deleteSessionUC, | |
| 123 | 133 | FaviconService: faviconService, | |
| 124 | 134 | ctx: ctx, | |
| 125 | 135 | logCleanup: logCleanup, | |
M internal/cli/cmd/about.go +40 -0
| @@ -1,11 +1,16 @@ | |||
| 1 | 1 | package cmd | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/spf13/cobra" | |
| 7 | 8 | ||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/cli" | |
| 8 | 11 | "github.com/bnema/dumber/internal/cli/styles" | |
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/updater" | |
| 13 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | 14 | ) | |
| 10 | 15 | ||
| 11 | 16 | var aboutCmd = &cobra.Command{ | |
| @@ -29,5 +34,40 @@ func runAbout(_ *cobra.Command, _ []string) error { | |||
| 29 | 34 | output := renderer.Render(app.BuildInfo) | |
| 30 | 35 | ||
| 31 | 36 | fmt.Println(output) | |
| 37 | + | ||
| 38 | + | // Check for updates | |
| 39 | + | if err := checkForUpdates(app); err != nil { | |
| 40 | + | // Log but don't fail - update check is optional | |
| 41 | + | ctx := logging.WithContext(context.Background(), logging.NewFromConfigValues("warn", "text")) | |
| 42 | + | log := logging.FromContext(ctx) | |
| 43 | + | log.Debug().Err(err).Msg("update check failed") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | return nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func checkForUpdates(app *cli.App) error { | |
| 50 | + | ctx := context.Background() | |
| 51 | + | ||
| 52 | + | // Create update checker | |
| 53 | + | checker := updater.NewGitHubChecker() | |
| 54 | + | applier, err := updater.NewApplierFromXDG() | |
| 55 | + | if err != nil { | |
| 56 | + | return fmt.Errorf("failed to create applier: %w", err) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | checkUC := usecase.NewCheckUpdateUseCase(checker, applier, app.BuildInfo) | |
| 60 | + | ||
| 61 | + | result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{}) | |
| 62 | + | if err != nil { | |
| 63 | + | return err | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | if result.UpdateAvailable { | |
| 67 | + | fmt.Println() | |
| 68 | + | fmt.Printf(" Update available: %s -> %s\n", result.CurrentVersion, result.LatestVersion) | |
| 69 | + | fmt.Printf(" %s\n", result.ReleaseURL) | |
| 70 | + | } | |
| 71 | + | ||
| 32 | 72 | return nil | |
| 33 | 73 | } | |
A internal/cli/cmd/config.go +416 -0
| @@ -0,0 +1,416 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | ||
| 9 | + | "github.com/charmbracelet/bubbles/spinner" | |
| 10 | + | tea "github.com/charmbracelet/bubbletea" | |
| 11 | + | "github.com/charmbracelet/lipgloss" | |
| 12 | + | "github.com/spf13/cobra" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/dumber/internal/application/port" | |
| 15 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 16 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 17 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | var configYes bool | |
| 21 | + | ||
| 22 | + | var configCmd = &cobra.Command{ | |
| 23 | + | Use: "config", | |
| 24 | + | Short: "Manage configuration", | |
| 25 | + | Long: `View configuration status and migrate to add new default settings.`, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | var configOpenCmd = &cobra.Command{ | |
| 29 | + | Use: "open", | |
| 30 | + | Short: "Open config file in your editor", | |
| 31 | + | Long: `Opens the config file using your preferred editor. | |
| 32 | + | ||
| 33 | + | Editor selection order: | |
| 34 | + | 1. $EDITOR environment variable | |
| 35 | + | 2. $VISUAL environment variable | |
| 36 | + | 3. Fallback to: nano, vim, vi (first found)`, | |
| 37 | + | RunE: runConfigOpen, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | var configStatusCmd = &cobra.Command{ | |
| 41 | + | Use: "status", | |
| 42 | + | Short: "Show config file status and migration availability", | |
| 43 | + | Long: `Display the config file path and check if any new settings are available.`, | |
| 44 | + | RunE: runConfigStatus, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | var configMigrateCmd = &cobra.Command{ | |
| 48 | + | Use: "migrate", | |
| 49 | + | Short: "Add missing default settings to config file", | |
| 50 | + | Long: `Compares your config file with available defaults and adds any missing settings. | |
| 51 | + | ||
| 52 | + | Existing settings are never modified - only missing keys are added with default values.`, | |
| 53 | + | RunE: runConfigMigrate, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | var configSchemaJSON bool | |
| 57 | + | ||
| 58 | + | var configSchemaCmd = &cobra.Command{ | |
| 59 | + | Use: "schema", | |
| 60 | + | Short: "Show all config keys with types and valid values", | |
| 61 | + | Long: `Display a reference of all configuration keys with their types, defaults, | |
| 62 | + | descriptions, and valid values. | |
| 63 | + | ||
| 64 | + | Use --json for machine-readable output.`, | |
| 65 | + | RunE: runConfigSchema, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func init() { | |
| 69 | + | rootCmd.AddCommand(configCmd) | |
| 70 | + | configCmd.AddCommand(configOpenCmd) | |
| 71 | + | configCmd.AddCommand(configStatusCmd) | |
| 72 | + | configCmd.AddCommand(configMigrateCmd) | |
| 73 | + | configCmd.AddCommand(configSchemaCmd) | |
| 74 | + | configMigrateCmd.Flags().BoolVarP(&configYes, "yes", "y", false, "skip confirmation prompt") | |
| 75 | + | configSchemaCmd.Flags().BoolVar(&configSchemaJSON, "json", false, "output as JSON") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // runConfigSchema displays all configuration keys with their metadata. | |
| 79 | + | func runConfigSchema(_ *cobra.Command, _ []string) error { | |
| 80 | + | app := GetApp() | |
| 81 | + | if app == nil { | |
| 82 | + | return fmt.Errorf("app not initialized") | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | provider := config.NewSchemaProvider() | |
| 86 | + | uc := usecase.NewGetConfigSchemaUseCase(provider) | |
| 87 | + | ||
| 88 | + | ctx := context.Background() | |
| 89 | + | result, err := uc.Execute(ctx, usecase.GetConfigSchemaInput{}) | |
| 90 | + | if err != nil { | |
| 91 | + | return fmt.Errorf("get config schema: %w", err) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | renderer := styles.NewConfigSchemaRenderer(app.Theme) | |
| 95 | + | ||
| 96 | + | if configSchemaJSON { | |
| 97 | + | output, err := renderer.RenderJSON(result.Keys) | |
| 98 | + | if err != nil { | |
| 99 | + | return err | |
| 100 | + | } | |
| 101 | + | fmt.Println(output) | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | fmt.Println(renderer.Render(result.Keys)) | |
| 106 | + | return nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // runConfigOpen opens the config file in the user's preferred editor. | |
| 110 | + | func runConfigOpen(_ *cobra.Command, _ []string) error { | |
| 111 | + | configFile, err := config.GetConfigFile() | |
| 112 | + | if err != nil { | |
| 113 | + | return fmt.Errorf("get config file: %w", err) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Check if config file exists | |
| 117 | + | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |
| 118 | + | return fmt.Errorf("config file does not exist: %s", configFile) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | editor := getEditor() | |
| 122 | + | if editor == "" { | |
| 123 | + | return fmt.Errorf("no editor found: set $EDITOR or $VISUAL environment variable") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | cmd := exec.Command(editor, configFile) | |
| 127 | + | cmd.Stdin = os.Stdin | |
| 128 | + | cmd.Stdout = os.Stdout | |
| 129 | + | cmd.Stderr = os.Stderr | |
| 130 | + | ||
| 131 | + | return cmd.Run() | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // getEditor returns the user's preferred editor. | |
| 135 | + | func getEditor() string { | |
| 136 | + | // Check $EDITOR first | |
| 137 | + | if editor := os.Getenv("EDITOR"); editor != "" { | |
| 138 | + | return editor | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Then $VISUAL | |
| 142 | + | if editor := os.Getenv("VISUAL"); editor != "" { | |
| 143 | + | return editor | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Fallback to common editors | |
| 147 | + | fallbacks := []string{"nano", "vim", "vi"} | |
| 148 | + | for _, editor := range fallbacks { | |
| 149 | + | if path, err := exec.LookPath(editor); err == nil { | |
| 150 | + | return path | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | return "" | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // runConfigStatus shows config file path and migration status. | |
| 158 | + | func runConfigStatus(_ *cobra.Command, _ []string) error { | |
| 159 | + | app := GetApp() | |
| 160 | + | if app == nil { | |
| 161 | + | return fmt.Errorf("app not initialized") | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | renderer := styles.NewConfigRenderer(app.Theme) | |
| 165 | + | migrator := config.NewMigrator() | |
| 166 | + | diffFormatter := config.NewDiffFormatter() | |
| 167 | + | uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter) | |
| 168 | + | ||
| 169 | + | // Get config file path | |
| 170 | + | configFile, err := config.GetConfigFile() | |
| 171 | + | if err != nil { | |
| 172 | + | fmt.Println(renderer.RenderError(err)) | |
| 173 | + | return nil | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // Check if config file exists | |
| 177 | + | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |
| 178 | + | fmt.Println(renderer.RenderNoConfigFile(configFile)) | |
| 179 | + | return nil | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // Check for migration | |
| 183 | + | ctx := context.Background() | |
| 184 | + | result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{}) | |
| 185 | + | if err != nil { | |
| 186 | + | fmt.Println(renderer.RenderError(err)) | |
| 187 | + | return nil | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | if !result.NeedsMigration { | |
| 191 | + | fmt.Println(renderer.RenderUpToDate(configFile)) | |
| 192 | + | return nil | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // Show status with missing count | |
| 196 | + | fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys))) | |
| 197 | + | fmt.Println(renderer.RenderMigrateHint()) | |
| 198 | + | ||
| 199 | + | return nil | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // runConfigMigrate runs the migration with optional confirmation. | |
| 203 | + | func runConfigMigrate(_ *cobra.Command, _ []string) error { | |
| 204 | + | app := GetApp() | |
| 205 | + | if app == nil { | |
| 206 | + | return fmt.Errorf("app not initialized") | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | renderer := styles.NewConfigRenderer(app.Theme) | |
| 210 | + | migrator := config.NewMigrator() | |
| 211 | + | diffFormatter := config.NewDiffFormatter() | |
| 212 | + | uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter) | |
| 213 | + | ||
| 214 | + | // Get config file path | |
| 215 | + | configFile, err := config.GetConfigFile() | |
| 216 | + | if err != nil { | |
| 217 | + | fmt.Println(renderer.RenderError(err)) | |
| 218 | + | return nil | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // Check if config file exists | |
| 222 | + | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |
| 223 | + | fmt.Println(renderer.RenderNoConfigFile(configFile)) | |
| 224 | + | return nil | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // Detect changes (includes renames, additions, and removals) | |
| 228 | + | ctx := context.Background() | |
| 229 | + | detectResult, err := uc.DetectChanges(ctx, usecase.DetectChangesInput{}) | |
| 230 | + | if err != nil { | |
| 231 | + | fmt.Println(renderer.RenderError(err)) | |
| 232 | + | return nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | if !detectResult.HasChanges { | |
| 236 | + | fmt.Println(renderer.RenderUpToDate(configFile)) | |
| 237 | + | return nil | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // Show config info with change summary | |
| 241 | + | fmt.Println(renderer.RenderConfigInfo(configFile, len(detectResult.Changes))) | |
| 242 | + | fmt.Println(renderer.RenderChangesSummary(detectResult.Changes)) | |
| 243 | + | fmt.Println(renderer.RenderChanges(detectResult.Changes)) | |
| 244 | + | ||
| 245 | + | // If --yes flag, proceed without confirmation | |
| 246 | + | if configYes { | |
| 247 | + | return executeMigration(ctx, uc, renderer) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // Show confirmation dialog | |
| 251 | + | return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, detectResult.Changes) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // executeMigration performs the actual migration. | |
| 255 | + | func executeMigration(ctx context.Context, uc *usecase.MigrateConfigUseCase, renderer *styles.ConfigRenderer) error { | |
| 256 | + | result, err := uc.Execute(ctx, usecase.MigrateConfigInput{}) | |
| 257 | + | if err != nil { | |
| 258 | + | fmt.Println(renderer.RenderError(err)) | |
| 259 | + | return nil | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | if len(result.AddedKeys) > 0 { | |
| 263 | + | fmt.Println(renderer.RenderMigrationSuccess(len(result.AddedKeys), result.ConfigFile)) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | return nil | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // migrateState represents the current state of the migrate confirmation. | |
| 270 | + | type migrateState int | |
| 271 | + | ||
| 272 | + | const ( | |
| 273 | + | migrateStateConfirm migrateState = iota | |
| 274 | + | migrateStateDone | |
| 275 | + | ) | |
| 276 | + | ||
| 277 | + | // migrateModel is the bubbletea model for the migrate confirmation. | |
| 278 | + | type migrateModel struct { | |
| 279 | + | spinner spinner.Model | |
| 280 | + | renderer *styles.ConfigRenderer | |
| 281 | + | confirm styles.ConfirmModel | |
| 282 | + | state migrateState | |
| 283 | + | uc *usecase.MigrateConfigUseCase | |
| 284 | + | changes []port.KeyChange | |
| 285 | + | ||
| 286 | + | result string | |
| 287 | + | err error | |
| 288 | + | quitting bool | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // migrateResultMsg is sent when the migration completes. | |
| 292 | + | type migrateResultMsg struct { | |
| 293 | + | output *usecase.MigrateConfigOutput | |
| 294 | + | err error | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func newMigrateModel( | |
| 298 | + | renderer *styles.ConfigRenderer, | |
| 299 | + | theme *styles.Theme, | |
| 300 | + | uc *usecase.MigrateConfigUseCase, | |
| 301 | + | changes []port.KeyChange, | |
| 302 | + | ) migrateModel { | |
| 303 | + | s := spinner.New() | |
| 304 | + | s.Spinner = spinner.Dot | |
| 305 | + | s.Style = lipgloss.NewStyle().Foreground(theme.Accent) | |
| 306 | + | ||
| 307 | + | confirm := styles.NewConfirm(theme, "Apply these changes?") | |
| 308 | + | ||
| 309 | + | return migrateModel{ | |
| 310 | + | spinner: s, | |
| 311 | + | renderer: renderer, | |
| 312 | + | confirm: confirm, | |
| 313 | + | state: migrateStateConfirm, | |
| 314 | + | uc: uc, | |
| 315 | + | changes: changes, | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func (m migrateModel) Init() tea.Cmd { | |
| 320 | + | return m.spinner.Tick | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 324 | + | switch msg := msg.(type) { | |
| 325 | + | case tea.KeyMsg: | |
| 326 | + | switch msg.String() { | |
| 327 | + | case "ctrl+c": | |
| 328 | + | m.quitting = true | |
| 329 | + | return m, tea.Quit | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | case spinner.TickMsg: | |
| 333 | + | var cmd tea.Cmd | |
| 334 | + | m.spinner, cmd = m.spinner.Update(msg) | |
| 335 | + | return m, cmd | |
| 336 | + | ||
| 337 | + | case migrateResultMsg: | |
| 338 | + | m.state = migrateStateDone | |
| 339 | + | if msg.err != nil { | |
| 340 | + | m.err = msg.err | |
| 341 | + | return m, tea.Quit | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | if len(msg.output.AddedKeys) > 0 { | |
| 345 | + | m.result = m.renderer.RenderMigrationSuccess(len(msg.output.AddedKeys), msg.output.ConfigFile) | |
| 346 | + | } | |
| 347 | + | return m, tea.Quit | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // Handle confirm dialog | |
| 351 | + | if m.state == migrateStateConfirm { | |
| 352 | + | var cmd tea.Cmd | |
| 353 | + | m.confirm, cmd = m.confirm.Update(msg) | |
| 354 | + | ||
| 355 | + | if m.confirm.Done() { | |
| 356 | + | if m.confirm.Result() { | |
| 357 | + | // User confirmed, run migration | |
| 358 | + | return m, m.runMigration() | |
| 359 | + | } | |
| 360 | + | // User canceled | |
| 361 | + | m.quitting = true | |
| 362 | + | return m, tea.Quit | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | return m, cmd | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | return m, nil | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func (m migrateModel) View() string { | |
| 372 | + | if m.quitting { | |
| 373 | + | return "" | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | if m.err != nil { | |
| 377 | + | return m.renderer.RenderError(m.err) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | if m.state == migrateStateDone { | |
| 381 | + | return m.result | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // Show confirmation dialog | |
| 385 | + | return m.confirm.View() | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func (m migrateModel) runMigration() tea.Cmd { | |
| 389 | + | return func() tea.Msg { | |
| 390 | + | ctx := context.Background() | |
| 391 | + | result, err := m.uc.Execute(ctx, usecase.MigrateConfigInput{}) | |
| 392 | + | return migrateResultMsg{output: result, err: err} | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | // runMigrateWithConfirmation runs the migrate with an interactive confirmation dialog. | |
| 397 | + | func runMigrateWithConfirmation( | |
| 398 | + | ctx context.Context, | |
| 399 | + | uc *usecase.MigrateConfigUseCase, | |
| 400 | + | renderer *styles.ConfigRenderer, | |
| 401 | + | theme *styles.Theme, | |
| 402 | + | changes []port.KeyChange, | |
| 403 | + | ) error { | |
| 404 | + | // Suppress unused ctx warning - kept for consistency with other run functions | |
| 405 | + | _ = ctx | |
| 406 | + | ||
| 407 | + | m := newMigrateModel(renderer, theme, uc, changes) | |
| 408 | + | p := tea.NewProgram(m) | |
| 409 | + | ||
| 410 | + | _, err := p.Run() | |
| 411 | + | if err != nil { | |
| 412 | + | return fmt.Errorf("migration failed: %w", err) | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | return nil | |
| 416 | + | } | |
M internal/cli/cmd/dmenu.go +30 -7
| @@ -11,16 +11,20 @@ import ( | |||
| 11 | 11 | tea "github.com/charmbracelet/bubbletea" | |
| 12 | 12 | "github.com/spf13/cobra" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/dumber/assets" | |
| 14 | 15 | "github.com/bnema/dumber/internal/cli/model" | |
| 16 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 15 | 17 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 16 | 18 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 19 | + | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 17 | 20 | "github.com/bnema/dumber/internal/logging" | |
| 18 | 21 | ) | |
| 19 | 22 | ||
| 20 | 23 | var ( | |
| 21 | 24 | dmenuInteractive bool | |
| 22 | 25 | dmenuSelect bool | |
| 23 | - | dmenuMax int | |
| 26 | + | dmenuDays int | |
| 27 | + | dmenuMostVisited bool | |
| 24 | 28 | ) | |
| 25 | 29 | ||
| 26 | 30 | var dmenuCmd = &cobra.Command{ | |
| @@ -41,7 +45,8 @@ func init() { | |||
| 41 | 45 | ||
| 42 | 46 | dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode") | |
| 43 | 47 | dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin") | |
| 44 | - | dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)") | |
| 48 | + | dmenuCmd.Flags().IntVar(&dmenuDays, "days", 0, "number of days of history to show (default from config)") | |
| 49 | + | dmenuCmd.Flags().BoolVarP(&dmenuMostVisited, "most-visited", "m", false, "sort by visit count instead of recency") | |
| 45 | 50 | } | |
| 46 | 51 | ||
| 47 | 52 | func runDmenu(_ *cobra.Command, _ []string) error { | |
| @@ -69,9 +74,14 @@ func getDmenuConfig() config.DmenuConfig { | |||
| 69 | 74 | app := GetApp() | |
| 70 | 75 | cfg := app.Config.Dmenu | |
| 71 | 76 | ||
| 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 | |
| 75 | 85 | } | |
| 76 | 86 | ||
| 77 | 87 | return cfg | |
| @@ -87,6 +97,13 @@ func getFaviconPath(rawURL string) string { | |||
| 87 | 97 | return "" | |
| 88 | 98 | } | |
| 89 | 99 | ||
| 100 | + | const normalizedSize = 32 | |
| 101 | + | ||
| 102 | + | // Handle internal dumb:// URLs - use embedded app logo | |
| 103 | + | if favicon.IsInternalURL(rawURL) { | |
| 104 | + | return app.FaviconService.EnsureInternalFaviconPNG(assets.LogoPNG32, normalizedSize) | |
| 105 | + | } | |
| 106 | + | ||
| 90 | 107 | domain := domainurl.ExtractDomain(rawURL) | |
| 91 | 108 | if domain == "" { | |
| 92 | 109 | return "" | |
| @@ -94,7 +111,6 @@ func getFaviconPath(rawURL string) string { | |||
| 94 | 111 | ||
| 95 | 112 | ctx := app.Ctx() | |
| 96 | 113 | log := logging.FromContext(ctx) | |
| 97 | - | const normalizedSize = 32 | |
| 98 | 114 | ||
| 99 | 115 | // Check if sized PNG already exists | |
| 100 | 116 | if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) { | |
| @@ -123,7 +139,14 @@ func runDmenuPipe() error { | |||
| 123 | 139 | ctx := context.Background() | |
| 124 | 140 | cfg := getDmenuConfig() | |
| 125 | 141 | ||
| 126 | - | entries, err := app.SearchHistoryUC.GetRecent(ctx, cfg.MaxHistoryItems, 0) | |
| 142 | + | var entries []*entity.HistoryEntry | |
| 143 | + | var err error | |
| 144 | + | ||
| 145 | + | if cfg.SortByVisitCount { | |
| 146 | + | entries, err = app.SearchHistoryUC.GetMostVisited(ctx, cfg.MaxHistoryDays) | |
| 147 | + | } else { | |
| 148 | + | entries, err = app.SearchHistoryUC.GetRecentSince(ctx, cfg.MaxHistoryDays) | |
| 149 | + | } | |
| 127 | 150 | if err != nil { | |
| 128 | 151 | return fmt.Errorf("get history: %w", err) | |
| 129 | 152 | } | |
A internal/cli/cmd/gen_docs.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/spf13/cobra" | |
| 10 | + | "github.com/spf13/cobra/doc" | |
| 11 | + | ||
| 12 | + | xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const dirPerm = 0o755 | |
| 16 | + | ||
| 17 | + | var ( | |
| 18 | + | genDocsOutputDir string | |
| 19 | + | genDocsFormat string | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | var genDocsCmd = &cobra.Command{ | |
| 23 | + | Use: "gen-docs", | |
| 24 | + | Short: "Generate documentation from CLI commands", | |
| 25 | + | Long: `Generate documentation (man pages or markdown) from CLI command definitions. | |
| 26 | + | ||
| 27 | + | The documentation is auto-generated from the command structure, including: | |
| 28 | + | - Command names and aliases | |
| 29 | + | - Short and long descriptions | |
| 30 | + | - Flags and their descriptions | |
| 31 | + | - Usage examples | |
| 32 | + | ||
| 33 | + | Supported formats: | |
| 34 | + | man Unix manual pages (groff format) | |
| 35 | + | markdown Markdown files (for websites/wikis) | |
| 36 | + | ||
| 37 | + | By default, man pages are installed to ~/.local/share/man/man1/ so they | |
| 38 | + | are immediately available via 'man dumber'. You may need to run 'mandb' | |
| 39 | + | to update the man page index. | |
| 40 | + | ||
| 41 | + | Examples: | |
| 42 | + | dumber gen-docs # Install man pages to ~/.local/share/man/man1/ | |
| 43 | + | dumber gen-docs --format markdown # Generate markdown docs | |
| 44 | + | dumber gen-docs --output ./man # Generate to local directory`, | |
| 45 | + | RunE: runGenDocs, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func init() { | |
| 49 | + | rootCmd.AddCommand(genDocsCmd) | |
| 50 | + | genDocsCmd.Flags().StringVarP(&genDocsOutputDir, "output", "o", "", "Output directory for generated docs") | |
| 51 | + | genDocsCmd.Flags().StringVarP(&genDocsFormat, "format", "f", "man", "Output format: man, markdown") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func runGenDocs(_ *cobra.Command, _ []string) error { | |
| 55 | + | // Resolve output directory | |
| 56 | + | outputDir := genDocsOutputDir | |
| 57 | + | if outputDir == "" { | |
| 58 | + | switch genDocsFormat { | |
| 59 | + | case "man": | |
| 60 | + | xdg := xdgadapter.New() | |
| 61 | + | manDir, err := xdg.ManDir() | |
| 62 | + | if err != nil { | |
| 63 | + | return fmt.Errorf("resolve man directory: %w", err) | |
| 64 | + | } | |
| 65 | + | outputDir = manDir | |
| 66 | + | case "markdown": | |
| 67 | + | outputDir = "./docs" | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Create output directory if it doesn't exist | |
| 72 | + | if err := os.MkdirAll(outputDir, dirPerm); err != nil { | |
| 73 | + | return fmt.Errorf("create output directory: %w", err) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | switch genDocsFormat { | |
| 77 | + | case "man": | |
| 78 | + | return generateManPages(outputDir) | |
| 79 | + | case "markdown": | |
| 80 | + | return generateMarkdown(outputDir) | |
| 81 | + | default: | |
| 82 | + | return fmt.Errorf("unsupported format %q (use: man, markdown)", genDocsFormat) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func generateManPages(outputDir string) error { | |
| 87 | + | header := &doc.GenManHeader{ | |
| 88 | + | Title: "DUMBER", | |
| 89 | + | Section: "1", | |
| 90 | + | Source: "dumber " + buildInfo.Version, | |
| 91 | + | Manual: "Dumber Manual", | |
| 92 | + | Date: func() *time.Time { t := time.Now(); return &t }(), | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Disable auto-generation timestamp in the footer for reproducible builds | |
| 96 | + | rootCmd.DisableAutoGenTag = true | |
| 97 | + | ||
| 98 | + | if err := doc.GenManTree(rootCmd, header, outputDir); err != nil { | |
| 99 | + | return fmt.Errorf("generate man pages: %w", err) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | fmt.Printf("Installed man pages to %s\n", outputDir) | |
| 103 | + | fmt.Println("Run 'mandb' if 'man dumber' doesn't work immediately.") | |
| 104 | + | ||
| 105 | + | // List generated files | |
| 106 | + | entries, err := os.ReadDir(outputDir) | |
| 107 | + | if err != nil { | |
| 108 | + | return nil // Non-fatal | |
| 109 | + | } | |
| 110 | + | for _, e := range entries { | |
| 111 | + | if filepath.Ext(e.Name()) == ".1" { | |
| 112 | + | fmt.Printf(" - %s\n", e.Name()) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | return nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func generateMarkdown(outputDir string) error { | |
| 120 | + | // Disable auto-generation timestamp for reproducible builds | |
| 121 | + | rootCmd.DisableAutoGenTag = true | |
| 122 | + | ||
| 123 | + | if err := doc.GenMarkdownTree(rootCmd, outputDir); err != nil { | |
| 124 | + | return fmt.Errorf("generate markdown docs: %w", err) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | fmt.Printf("Generated markdown docs in %s\n", outputDir) | |
| 128 | + | ||
| 129 | + | // List generated files | |
| 130 | + | entries, err := os.ReadDir(outputDir) | |
| 131 | + | if err != nil { | |
| 132 | + | return nil // Non-fatal | |
| 133 | + | } | |
| 134 | + | for _, e := range entries { | |
| 135 | + | if filepath.Ext(e.Name()) == ".md" { | |
| 136 | + | fmt.Printf(" - %s\n", e.Name()) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | return nil | |
| 141 | + | } | |
M internal/cli/cmd/purge.go +34 -5
| @@ -24,11 +24,12 @@ var purgeCmd = &cobra.Command{ | |||
| 24 | 24 | ||
| 25 | 25 | This can remove: | |
| 26 | 26 | - Config directory | |
| 27 | - | - Data directory | |
| 27 | + | - Data directory (includes database) | |
| 28 | 28 | - State directory | |
| 29 | 29 | - Cache directory | |
| 30 | 30 | - Content filter caches | |
| 31 | 31 | - Desktop integration files | |
| 32 | + | - Inactive browser sessions | |
| 32 | 33 | ||
| 33 | 34 | Use --force to remove everything without prompting.`, | |
| 34 | 35 | RunE: runPurge, | |
| @@ -54,20 +55,48 @@ func runPurge(_ *cobra.Command, _ []string) error { | |||
| 54 | 55 | return runPurgeForce(app, purgeUC) | |
| 55 | 56 | } | |
| 56 | 57 | ||
| 57 | - | m := model.NewPurgeModel(app.Theme, purgeUC) | |
| 58 | + | // Build session config from app dependencies | |
| 59 | + | cfg := model.PurgeModelConfig{ | |
| 60 | + | ListSessionsUC: app.ListSessionsUC, | |
| 61 | + | DeleteSessionUC: app.DeleteSessionUC, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg) | |
| 58 | 65 | p := tea.NewProgram(m, tea.WithAltScreen()) | |
| 59 | 66 | _, err := p.Run() | |
| 60 | 67 | return err | |
| 61 | 68 | } | |
| 62 | 69 | ||
| 63 | 70 | func runPurgeForce(app *cli.App, purgeUC *usecase.PurgeDataUseCase) error { | |
| 64 | - | out, err := purgeUC.PurgeAll(app.Ctx()) | |
| 71 | + | ctx := app.Ctx() | |
| 72 | + | theme := app.Theme | |
| 73 | + | ||
| 74 | + | // First, delete all inactive sessions (before we delete the database) | |
| 75 | + | if app.ListSessionsUC != nil && app.DeleteSessionUC != nil { | |
| 76 | + | output, err := app.ListSessionsUC.GetPurgeableSessions(ctx) | |
| 77 | + | if err == nil && output != nil { | |
| 78 | + | for _, s := range output.Sessions { | |
| 79 | + | sessionID := s.Info.Session.ID | |
| 80 | + | if delErr := app.DeleteSessionUC.Execute(ctx, usecase.DeleteSessionInput{ | |
| 81 | + | SessionID: sessionID, | |
| 82 | + | CurrentSessionID: "", // CLI has no current session | |
| 83 | + | }); delErr != nil { | |
| 84 | + | fmt.Printf("%s Session %s: %v\n", theme.ErrorStyle.Render(styles.IconX), sessionID, delErr) | |
| 85 | + | } else { | |
| 86 | + | fmt.Printf("%s Session %s\n", theme.SuccessStyle.Render(styles.IconCheck), sessionID) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Then purge all directories | |
| 93 | + | out, err := purgeUC.PurgeAll(ctx) | |
| 65 | 94 | if out != nil { | |
| 66 | 95 | for _, r := range out.Results { | |
| 67 | 96 | if r.Success { | |
| 68 | - | fmt.Printf("%s %s\n", app.Theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path) | |
| 97 | + | fmt.Printf("%s %s\n", theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path) | |
| 69 | 98 | } else { | |
| 70 | - | fmt.Printf("%s %s: %v\n", app.Theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error) | |
| 99 | + | fmt.Printf("%s %s: %v\n", theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error) | |
| 71 | 100 | } | |
| 72 | 101 | } | |
| 73 | 102 | } | |
M internal/cli/cmd/root.go +21 -4
| @@ -16,11 +16,28 @@ var ( | |||
| 16 | 16 | buildInfo build.Info | |
| 17 | 17 | rootCmd = &cobra.Command{ | |
| 18 | 18 | Use: "dumber", | |
| 19 | - | Short: "A minimal web browser", | |
| 20 | - | Long: `Dumber is a minimal, keyboard-driven web browser built with GTK4 and WebKitGTK.`, | |
| 19 | + | Short: "A fully unfeatured unbloated browser for tiling WMs", | |
| 20 | + | Long: `Dumber - a dumb browser that works like your favorite terminal multiplexer. | |
| 21 | + | ||
| 22 | + | A fully unfeatured unbloated browser for tiling WMs, built with GTK4 and WebKitGTK. | |
| 23 | + | ||
| 24 | + | Features: | |
| 25 | + | - Wayland native (Sway, Hyprland, River, Niri, etc.) | |
| 26 | + | - Tabs and workspaces with split or stacked panes | |
| 27 | + | - Keyboard-driven workflow inspired by Zellij | |
| 28 | + | - GPU rendering with automatic VA-API/VDPAU detection | |
| 29 | + | - Built-in ad blocking (UBlock-based network + cosmetic filtering) | |
| 30 | + | - Launcher integration (rofi/fuzzel) with favicons | |
| 31 | + | - Search shortcuts via bangs (!g, !gi, etc.) | |
| 32 | + | - Session management with auto-save and restore | |
| 33 | + | ||
| 34 | + | Use 'dumber browse' to launch the graphical browser, or explore the | |
| 35 | + | subcommands for CLI-based operations like history search and session | |
| 36 | + | management.`, | |
| 21 | 37 | PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { | |
| 22 | - | // Skip initialization for help commands | |
| 23 | - | if cmd.Name() == "help" || cmd.Name() == "completion" { | |
| 38 | + | // Skip initialization for commands that don't need app context | |
| 39 | + | switch cmd.Name() { | |
| 40 | + | case "help", "completion", "gen-docs": | |
| 24 | 41 | return nil | |
| 25 | 42 | } | |
| 26 | 43 | ||
A internal/cli/cmd/sessions.go +308 -0
| @@ -0,0 +1,308 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "strings" | |
| 8 | + | "text/tabwriter" | |
| 9 | + | ||
| 10 | + | tea "github.com/charmbracelet/bubbletea" | |
| 11 | + | "github.com/spf13/cobra" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 14 | + | "github.com/bnema/dumber/internal/cli/model" | |
| 15 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | const defaultSessionsLimit = 20 | |
| 20 | + | ||
| 21 | + | var ( | |
| 22 | + | sessionsJSON bool | |
| 23 | + | sessionsLimit int | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | var sessionsCmd = &cobra.Command{ | |
| 27 | + | Use: "sessions", | |
| 28 | + | Short: "Manage browser sessions", | |
| 29 | + | Long: `View, restore, and manage browser sessions. | |
| 30 | + | ||
| 31 | + | Sessions are automatically saved when the browser runs. You can list | |
| 32 | + | past sessions and restore them to continue where you left off. | |
| 33 | + | ||
| 34 | + | Run without arguments to open the interactive session browser.`, | |
| 35 | + | RunE: runSessions, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func init() { | |
| 39 | + | rootCmd.AddCommand(sessionsCmd) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func runSessions(_ *cobra.Command, _ []string) error { | |
| 43 | + | app := GetApp() | |
| 44 | + | if app == nil { | |
| 45 | + | return fmt.Errorf("app not initialized") | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | if app.ListSessionsUC == nil { | |
| 49 | + | return fmt.Errorf("session management not available") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Get current session ID (empty if not running as browser) | |
| 53 | + | var currentSessionID entity.SessionID | |
| 54 | + | if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil { | |
| 55 | + | currentSessionID = active.ID | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Run interactive TUI | |
| 59 | + | m := model.NewSessionsModel(app.Ctx(), app.Theme, model.SessionsModelConfig{ | |
| 60 | + | ListSessionsUC: app.ListSessionsUC, | |
| 61 | + | RestoreUC: app.RestoreUC, | |
| 62 | + | DeleteSessionUC: app.DeleteSessionUC, | |
| 63 | + | CurrentSession: currentSessionID, | |
| 64 | + | MaxExitedSessions: app.Config.Session.MaxExitedSessions, | |
| 65 | + | }) | |
| 66 | + | ||
| 67 | + | p := tea.NewProgram(m, tea.WithAltScreen()) | |
| 68 | + | _, err := p.Run() | |
| 69 | + | return err | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // sessions list | |
| 73 | + | var sessionsListCmd = &cobra.Command{ | |
| 74 | + | Use: "list", | |
| 75 | + | Short: "List saved sessions", | |
| 76 | + | Long: `List all saved browser sessions with their tab/pane counts. | |
| 77 | + | ||
| 78 | + | Sessions are marked as: | |
| 79 | + | ● current - the currently running session | |
| 80 | + | ○ active - another running dumber instance | |
| 81 | + | (blank) - exited session available for restoration`, | |
| 82 | + | RunE: runSessionsList, | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func init() { | |
| 86 | + | sessionsCmd.AddCommand(sessionsListCmd) | |
| 87 | + | sessionsListCmd.Flags().BoolVar(&sessionsJSON, "json", false, "output as JSON") | |
| 88 | + | sessionsListCmd.Flags().IntVar(&sessionsLimit, "limit", defaultSessionsLimit, "maximum sessions to show") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func runSessionsList(_ *cobra.Command, _ []string) error { | |
| 92 | + | app := GetApp() | |
| 93 | + | if app == nil { | |
| 94 | + | return fmt.Errorf("app not initialized") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | if app.ListSessionsUC == nil { | |
| 98 | + | return fmt.Errorf("session management not available") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Get current session ID (empty if not running as browser) | |
| 102 | + | var currentSessionID entity.SessionID | |
| 103 | + | if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil { | |
| 104 | + | currentSessionID = active.ID | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit) | |
| 108 | + | if err != nil { | |
| 109 | + | return fmt.Errorf("list sessions: %w", err) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | if sessionsJSON { | |
| 113 | + | return outputSessionsJSON(output.Sessions) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | return outputSessionsTable(output.Sessions) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func outputSessionsJSON(sessions []entity.SessionInfo) error { | |
| 120 | + | enc := json.NewEncoder(os.Stdout) | |
| 121 | + | enc.SetIndent("", " ") | |
| 122 | + | return enc.Encode(sessions) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func outputSessionsTable(sessions []entity.SessionInfo) error { | |
| 126 | + | if len(sessions) == 0 { | |
| 127 | + | fmt.Println("No saved sessions found.") | |
| 128 | + | return nil | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 132 | + | _, _ = fmt.Fprintln(w, "STATUS\tSESSION ID\tTABS\tPANES\tLAST UPDATED") | |
| 133 | + | ||
| 134 | + | for _, info := range sessions { | |
| 135 | + | status := " " | |
| 136 | + | switch { | |
| 137 | + | case info.IsCurrent: | |
| 138 | + | status = "●" | |
| 139 | + | case info.IsActive: | |
| 140 | + | status = "○" | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | relTime := usecase.GetRelativeTime(info.UpdatedAt) | |
| 144 | + | _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n", | |
| 145 | + | status, | |
| 146 | + | info.Session.ID, | |
| 147 | + | info.TabCount, | |
| 148 | + | info.PaneCount, | |
| 149 | + | relTime, | |
| 150 | + | ) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | return w.Flush() | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // sessions restore <id> | |
| 157 | + | var sessionsRestoreCmd = &cobra.Command{ | |
| 158 | + | Use: "restore <session-id>", | |
| 159 | + | Short: "Restore a saved session", | |
| 160 | + | Long: `Restore a previously saved browser session. | |
| 161 | + | ||
| 162 | + | This launches a new browser window with all tabs and panes from the | |
| 163 | + | saved session. The session ID can be found using 'dumber sessions list'. | |
| 164 | + | ||
| 165 | + | You can use a short suffix of the session ID as long as it's unique. | |
| 166 | + | ||
| 167 | + | Example: | |
| 168 | + | dumber sessions restore 20251224_143022_abc1 | |
| 169 | + | dumber sessions restore abc1`, | |
| 170 | + | Args: cobra.ExactArgs(1), | |
| 171 | + | RunE: runSessionsRestore, | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func init() { | |
| 175 | + | sessionsCmd.AddCommand(sessionsRestoreCmd) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func runSessionsRestore(_ *cobra.Command, args []string) error { | |
| 179 | + | app := GetApp() | |
| 180 | + | if app == nil { | |
| 181 | + | return fmt.Errorf("app not initialized") | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | if app.RestoreUC == nil || app.ListSessionsUC == nil { | |
| 185 | + | return fmt.Errorf("session restoration not available") | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // Find session by ID or suffix | |
| 189 | + | sessionInfo, err := findSessionByIDOrSuffix(args[0]) | |
| 190 | + | if err != nil { | |
| 191 | + | return err | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | sessionID := sessionInfo.Session.ID | |
| 195 | + | ||
| 196 | + | // Validate the session has restorable state | |
| 197 | + | _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID}) | |
| 198 | + | if err != nil { | |
| 199 | + | return fmt.Errorf("restore session: %w", err) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // Spawn a new dumber instance with the session | |
| 203 | + | spawner := desktop.NewSessionSpawner(app.Ctx()) | |
| 204 | + | if err := spawner.SpawnWithSession(sessionID); err != nil { | |
| 205 | + | return fmt.Errorf("spawn browser: %w", err) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | fmt.Printf("Restoring session %s...\n", sessionID) | |
| 209 | + | return nil | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // sessions delete <id> | |
| 213 | + | var sessionsDeleteCmd = &cobra.Command{ | |
| 214 | + | Use: "delete <session-id>", | |
| 215 | + | Short: "Delete a saved session", | |
| 216 | + | Long: `Delete a saved browser session and its state. | |
| 217 | + | ||
| 218 | + | This permanently removes the session snapshot. Active sessions cannot | |
| 219 | + | be deleted - close the browser first. | |
| 220 | + | ||
| 221 | + | You can use a short suffix of the session ID as long as it's unique. | |
| 222 | + | ||
| 223 | + | Example: | |
| 224 | + | dumber sessions delete 20251224_143022_abc1 | |
| 225 | + | dumber sessions delete abc1`, | |
| 226 | + | Args: cobra.ExactArgs(1), | |
| 227 | + | RunE: runSessionsDelete, | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func init() { | |
| 231 | + | sessionsCmd.AddCommand(sessionsDeleteCmd) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func runSessionsDelete(_ *cobra.Command, args []string) error { | |
| 235 | + | app := GetApp() | |
| 236 | + | if app == nil { | |
| 237 | + | return fmt.Errorf("app not initialized") | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | if app.DeleteSessionUC == nil || app.ListSessionsUC == nil { | |
| 241 | + | return fmt.Errorf("session management not available") | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // Find session by ID or suffix | |
| 245 | + | sessionInfo, err := findSessionByIDOrSuffix(args[0]) | |
| 246 | + | if err != nil { | |
| 247 | + | return err | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // Get current session ID | |
| 251 | + | var currentSessionID entity.SessionID | |
| 252 | + | if active, activeErr := app.SessionUC.GetActiveSession(app.Ctx()); activeErr == nil && active != nil { | |
| 253 | + | currentSessionID = active.ID | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // Delete using use case (handles validation internally) | |
| 257 | + | if err := app.DeleteSessionUC.Execute(app.Ctx(), usecase.DeleteSessionInput{ | |
| 258 | + | SessionID: sessionInfo.Session.ID, | |
| 259 | + | CurrentSessionID: currentSessionID, | |
| 260 | + | }); err != nil { | |
| 261 | + | return fmt.Errorf("delete session: %w", err) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | fmt.Printf("Session %s deleted.\n", sessionInfo.Session.ID) | |
| 265 | + | return nil | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // findSessionByIDOrSuffix finds a session by exact ID or unique suffix. | |
| 269 | + | // Users typically identify sessions by the last few characters (e.g., "dee5"). | |
| 270 | + | func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) { | |
| 271 | + | app := GetApp() | |
| 272 | + | if app == nil || app.ListSessionsUC == nil { | |
| 273 | + | return nil, fmt.Errorf("app not initialized") | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // Get current session ID (empty if not running as browser) | |
| 277 | + | var currentSessionID entity.SessionID | |
| 278 | + | if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil { | |
| 279 | + | currentSessionID = active.ID | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // List all sessions | |
| 283 | + | output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, defaultSessionsLimit*5) | |
| 284 | + | if err != nil { | |
| 285 | + | return nil, fmt.Errorf("list sessions: %w", err) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // Find matching sessions | |
| 289 | + | var matches []entity.SessionInfo | |
| 290 | + | for _, info := range output.Sessions { | |
| 291 | + | if string(info.Session.ID) == idOrSuffix { | |
| 292 | + | // Exact match - return immediately | |
| 293 | + | return &info, nil | |
| 294 | + | } | |
| 295 | + | if strings.HasSuffix(string(info.Session.ID), idOrSuffix) { | |
| 296 | + | matches = append(matches, info) | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | switch len(matches) { | |
| 301 | + | case 0: | |
| 302 | + | return nil, fmt.Errorf("no session found matching '%s'", idOrSuffix) | |
| 303 | + | case 1: | |
| 304 | + | return &matches[0], nil | |
| 305 | + | default: | |
| 306 | + | return nil, fmt.Errorf("ambiguous session ID '%s' matches %d sessions - be more specific", idOrSuffix, len(matches)) | |
| 307 | + | } | |
| 308 | + | } | |
A internal/cli/cmd/update.go +289 -0
| @@ -0,0 +1,289 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/charmbracelet/bubbles/spinner" | |
| 8 | + | tea "github.com/charmbracelet/bubbletea" | |
| 9 | + | "github.com/charmbracelet/lipgloss" | |
| 10 | + | "github.com/spf13/cobra" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 13 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 14 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 15 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/updater" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | var updateForce bool | |
| 20 | + | ||
| 21 | + | var updateCmd = &cobra.Command{ | |
| 22 | + | Use: "update", | |
| 23 | + | Short: "Check for and install updates", | |
| 24 | + | Long: `Check for available updates and download/install them. | |
| 25 | + | ||
| 26 | + | Use --force to reinstall even if already up-to-date (skips version check).`, | |
| 27 | + | RunE: runUpdate, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func init() { | |
| 31 | + | rootCmd.AddCommand(updateCmd) | |
| 32 | + | updateCmd.Flags().BoolVarP(&updateForce, "force", "f", false, "force reinstall (skips version check)") | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // updateState represents the current state of the update process. | |
| 36 | + | type updateState int | |
| 37 | + | ||
| 38 | + | const ( | |
| 39 | + | stateChecking updateState = iota | |
| 40 | + | stateDownloading | |
| 41 | + | stateDone | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | // updateModel is the bubbletea model for the update command. | |
| 45 | + | type updateModel struct { | |
| 46 | + | spinner spinner.Model | |
| 47 | + | renderer *styles.UpdateRenderer | |
| 48 | + | state updateState | |
| 49 | + | force bool | |
| 50 | + | ||
| 51 | + | // Results from check. | |
| 52 | + | updateAvailable bool | |
| 53 | + | canAutoUpdate bool | |
| 54 | + | currentVersion string | |
| 55 | + | latestVersion string | |
| 56 | + | releaseURL string | |
| 57 | + | downloadURL string | |
| 58 | + | ||
| 59 | + | // Final result. | |
| 60 | + | result string | |
| 61 | + | err error | |
| 62 | + | quitting bool | |
| 63 | + | updateStaged bool // tracks if an update was actually downloaded and staged | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // checkResultMsg is sent when the update check completes. | |
| 67 | + | type checkResultMsg struct { | |
| 68 | + | output *usecase.CheckUpdateOutput | |
| 69 | + | err error | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // downloadResultMsg is sent when the download completes. | |
| 73 | + | type downloadResultMsg struct { | |
| 74 | + | output *usecase.ApplyUpdateOutput | |
| 75 | + | err error | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func newUpdateModel(renderer *styles.UpdateRenderer, accentColor lipgloss.Color, force bool) updateModel { | |
| 79 | + | s := spinner.New() | |
| 80 | + | s.Spinner = spinner.Dot | |
| 81 | + | s.Style = lipgloss.NewStyle().Foreground(accentColor) | |
| 82 | + | ||
| 83 | + | return updateModel{ | |
| 84 | + | spinner: s, | |
| 85 | + | renderer: renderer, | |
| 86 | + | state: stateChecking, | |
| 87 | + | force: force, | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (m updateModel) Init() tea.Cmd { | |
| 92 | + | return tea.Batch(m.spinner.Tick, m.checkForUpdates()) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (m updateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 96 | + | switch msg := msg.(type) { | |
| 97 | + | case tea.KeyMsg: | |
| 98 | + | switch msg.String() { | |
| 99 | + | case "q", "ctrl+c": | |
| 100 | + | m.quitting = true | |
| 101 | + | return m, tea.Quit | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | case spinner.TickMsg: | |
| 105 | + | var cmd tea.Cmd | |
| 106 | + | m.spinner, cmd = m.spinner.Update(msg) | |
| 107 | + | return m, cmd | |
| 108 | + | ||
| 109 | + | case checkResultMsg: | |
| 110 | + | if msg.err != nil { | |
| 111 | + | m.err = msg.err | |
| 112 | + | m.state = stateDone | |
| 113 | + | return m, tea.Quit | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | m.updateAvailable = msg.output.UpdateAvailable | |
| 117 | + | m.canAutoUpdate = msg.output.CanAutoUpdate | |
| 118 | + | m.currentVersion = msg.output.CurrentVersion | |
| 119 | + | m.latestVersion = msg.output.LatestVersion | |
| 120 | + | m.releaseURL = msg.output.ReleaseURL | |
| 121 | + | m.downloadURL = msg.output.DownloadURL | |
| 122 | + | ||
| 123 | + | // Handle dev builds when force is used. | |
| 124 | + | if m.force && (m.currentVersion == "" || m.currentVersion == "dev") { | |
| 125 | + | m.result = m.renderer.RenderDevBuild() | |
| 126 | + | m.state = stateDone | |
| 127 | + | return m, tea.Quit | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // Decide next action. | |
| 131 | + | switch { | |
| 132 | + | case !m.updateAvailable && !m.force: | |
| 133 | + | // Already up to date. | |
| 134 | + | m.result = m.renderer.RenderUpToDate(m.currentVersion) | |
| 135 | + | m.state = stateDone | |
| 136 | + | return m, tea.Quit | |
| 137 | + | ||
| 138 | + | case m.updateAvailable && !m.canAutoUpdate: | |
| 139 | + | // Update available but can't auto-update. | |
| 140 | + | m.result = m.renderer.RenderCannotAutoUpdate(m.currentVersion, m.latestVersion, m.releaseURL) | |
| 141 | + | m.state = stateDone | |
| 142 | + | return m, tea.Quit | |
| 143 | + | ||
| 144 | + | default: | |
| 145 | + | // Proceed with download (either update available or force mode). | |
| 146 | + | m.state = stateDownloading | |
| 147 | + | return m, m.downloadUpdate() | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | case downloadResultMsg: | |
| 151 | + | m.state = stateDone | |
| 152 | + | if msg.err != nil { | |
| 153 | + | m.err = msg.err | |
| 154 | + | return m, tea.Quit | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | if msg.output.Status == entity.UpdateStatusFailed { | |
| 158 | + | m.result = m.renderer.RenderError(fmt.Errorf("%s", msg.output.Message)) | |
| 159 | + | } else { | |
| 160 | + | m.result = m.renderer.RenderStaged(m.latestVersion) | |
| 161 | + | m.updateStaged = true | |
| 162 | + | } | |
| 163 | + | return m, tea.Quit | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | return m, nil | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (m updateModel) View() string { | |
| 170 | + | if m.quitting { | |
| 171 | + | return "" | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | if m.err != nil { | |
| 175 | + | return m.renderer.RenderError(m.err) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | if m.state == stateDone { | |
| 179 | + | return m.result | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | switch m.state { | |
| 183 | + | case stateChecking: | |
| 184 | + | return m.renderer.RenderChecking(m.spinner.View()) | |
| 185 | + | case stateDownloading: | |
| 186 | + | version := m.latestVersion | |
| 187 | + | if m.force && !m.updateAvailable { | |
| 188 | + | version = m.currentVersion | |
| 189 | + | } | |
| 190 | + | return m.renderer.RenderDownloading(m.spinner.View(), version) | |
| 191 | + | default: | |
| 192 | + | return "" | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (updateModel) checkForUpdates() tea.Cmd { | |
| 197 | + | return func() tea.Msg { | |
| 198 | + | ctx := context.Background() | |
| 199 | + | ||
| 200 | + | checker := updater.NewGitHubChecker() | |
| 201 | + | applier, err := updater.NewApplierFromXDG() | |
| 202 | + | if err != nil { | |
| 203 | + | return checkResultMsg{err: fmt.Errorf("failed to create applier: %w", err)} | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | app := GetApp() | |
| 207 | + | if app == nil { | |
| 208 | + | return checkResultMsg{err: fmt.Errorf("app not initialized")} | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | checkUC := usecase.NewCheckUpdateUseCase(checker, applier, app.BuildInfo) | |
| 212 | + | result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{}) | |
| 213 | + | ||
| 214 | + | return checkResultMsg{output: result, err: err} | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func (m updateModel) downloadUpdate() tea.Cmd { | |
| 219 | + | return func() tea.Msg { | |
| 220 | + | ctx := context.Background() | |
| 221 | + | ||
| 222 | + | downloader := updater.NewGitHubDownloader() | |
| 223 | + | applier, err := updater.NewApplierFromXDG() | |
| 224 | + | if err != nil { | |
| 225 | + | return downloadResultMsg{err: fmt.Errorf("failed to create applier: %w", err)} | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | dirs, err := config.GetXDGDirs() | |
| 229 | + | if err != nil { | |
| 230 | + | return downloadResultMsg{err: fmt.Errorf("failed to get cache dir: %w", err)} | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | applyUC := usecase.NewApplyUpdateUseCase(downloader, applier, dirs.CacheHome) | |
| 234 | + | result, err := applyUC.Execute(ctx, usecase.ApplyUpdateInput{ | |
| 235 | + | DownloadURL: m.downloadURL, | |
| 236 | + | }) | |
| 237 | + | ||
| 238 | + | return downloadResultMsg{output: result, err: err} | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func runUpdate(_ *cobra.Command, _ []string) error { | |
| 243 | + | app := GetApp() | |
| 244 | + | if app == nil { | |
| 245 | + | return fmt.Errorf("app not initialized") | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | renderer := styles.NewUpdateRenderer(app.Theme) | |
| 249 | + | ||
| 250 | + | // Create and run the bubbletea program. | |
| 251 | + | m := newUpdateModel(renderer, app.Theme.Accent, updateForce) | |
| 252 | + | p := tea.NewProgram(m) | |
| 253 | + | ||
| 254 | + | finalModel, err := p.Run() | |
| 255 | + | if err != nil { | |
| 256 | + | return fmt.Errorf("update failed: %w", err) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // Check if the update was staged and we need to finalize on exit. | |
| 260 | + | if model, ok := finalModel.(updateModel); ok { | |
| 261 | + | if model.updateStaged { | |
| 262 | + | // If update was successfully staged, finalize it. | |
| 263 | + | if err := finalizeUpdate(); err != nil { | |
| 264 | + | fmt.Println(renderer.RenderError(err)) | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | return nil | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // finalizeUpdate applies any staged update. | |
| 273 | + | func finalizeUpdate() error { | |
| 274 | + | ctx := context.Background() | |
| 275 | + | ||
| 276 | + | downloader := updater.NewGitHubDownloader() | |
| 277 | + | applier, err := updater.NewApplierFromXDG() | |
| 278 | + | if err != nil { | |
| 279 | + | return fmt.Errorf("failed to create applier: %w", err) | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | dirs, err := config.GetXDGDirs() | |
| 283 | + | if err != nil { | |
| 284 | + | return fmt.Errorf("failed to get cache dir: %w", err) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | applyUC := usecase.NewApplyUpdateUseCase(downloader, applier, dirs.CacheHome) | |
| 288 | + | return applyUC.FinalizeOnExit(ctx) | |
| 289 | + | } | |
M internal/cli/model/purge.go +242 -69
| @@ -14,23 +14,57 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | // PurgeModel wraps styles.PurgeModel for standalone CLI use. | |
| 16 | 16 | 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 | |
| 19 | 21 | ||
| 20 | - | loading bool | |
| 21 | - | purging bool | |
| 22 | - | done bool | |
| 22 | + | loading bool | |
| 23 | + | loadingSessions bool | |
| 24 | + | purging bool | |
| 25 | + | done bool | |
| 23 | 26 | ||
| 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 | |
| 27 | 38 | ||
| 28 | 39 | theme *styles.Theme | |
| 40 | + | ctx context.Context | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // sessionPurgeResult tracks the result of deleting a session. | |
| 44 | + | type sessionPurgeResult struct { | |
| 45 | + | SessionID entity.SessionID | |
| 46 | + | Success bool | |
| 47 | + | Error error | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // PurgeModelConfig contains optional dependencies for session purging. | |
| 51 | + | type PurgeModelConfig struct { | |
| 52 | + | ListSessionsUC *usecase.ListSessionsUseCase | |
| 53 | + | DeleteSessionUC *usecase.DeleteSessionUseCase | |
| 29 | 54 | } | |
| 30 | 55 | ||
| 31 | 56 | // 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 | |
| 34 | 68 | } | |
| 35 | 69 | ||
| 36 | 70 | type purgeTargetsLoadedMsg struct { | |
| @@ -38,14 +72,25 @@ type purgeTargetsLoadedMsg struct { | |||
| 38 | 72 | err error | |
| 39 | 73 | } | |
| 40 | 74 | ||
| 75 | + | type purgeSessionsLoadedMsg struct { | |
| 76 | + | sessions []entity.SessionPurgeItem | |
| 77 | + | size int64 | |
| 78 | + | err error | |
| 79 | + | } | |
| 80 | + | ||
| 41 | 81 | type purgeCompleteMsg struct { | |
| 42 | - | out *usecase.PurgeOutput | |
| 43 | - | err error | |
| 82 | + | targetResults []entity.PurgeResult | |
| 83 | + | sessionResults []sessionPurgeResult | |
| 84 | + | err error | |
| 44 | 85 | } | |
| 45 | 86 | ||
| 46 | 87 | // Init implements tea.Model. | |
| 47 | 88 | func (m PurgeModel) Init() tea.Cmd { | |
| 48 | - | return m.loadTargets() | |
| 89 | + | cmds := []tea.Cmd{m.loadTargets()} | |
| 90 | + | if m.listSessionsUC != nil { | |
| 91 | + | cmds = append(cmds, m.loadSessions()) | |
| 92 | + | } | |
| 93 | + | return tea.Batch(cmds...) | |
| 49 | 94 | } | |
| 50 | 95 | ||
| 51 | 96 | func (m PurgeModel) loadTargets() tea.Cmd { | |
| @@ -55,68 +100,172 @@ func (m PurgeModel) loadTargets() tea.Cmd { | |||
| 55 | 100 | } | |
| 56 | 101 | } | |
| 57 | 102 | ||
| 103 | + | func (m PurgeModel) loadSessions() tea.Cmd { | |
| 104 | + | return func() tea.Msg { | |
| 105 | + | output, err := m.listSessionsUC.GetPurgeableSessions(context.Background()) | |
| 106 | + | if err != nil { | |
| 107 | + | return purgeSessionsLoadedMsg{err: err} | |
| 108 | + | } | |
| 109 | + | return purgeSessionsLoadedMsg{ | |
| 110 | + | sessions: output.Sessions, | |
| 111 | + | size: output.TotalSize, | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 58 | 116 | // Update implements tea.Model. | |
| 59 | 117 | func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 60 | 118 | switch msg := msg.(type) { | |
| 61 | 119 | 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 | |
| 72 | 123 | 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 | |
| 81 | 125 | case tea.KeyMsg: | |
| 82 | 126 | if m.done { | |
| 83 | 127 | return m, tea.Quit | |
| 84 | 128 | } | |
| 85 | - | if m.loading || m.purging { | |
| 129 | + | if m.isBlocked() { | |
| 86 | 130 | return m, nil | |
| 87 | 131 | } | |
| 132 | + | // Fall through to updateSelector | |
| 88 | 133 | } | |
| 89 | 134 | ||
| 90 | - | if m.loading || m.purging { | |
| 135 | + | if m.isBlocked() { | |
| 91 | 136 | return m, nil | |
| 92 | 137 | } | |
| 93 | 138 | ||
| 139 | + | return m.updateSelector(msg) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (m PurgeModel) handleTargetsLoaded(msg purgeTargetsLoadedMsg) PurgeModel { | |
| 143 | + | if msg.err != nil { | |
| 144 | + | m.err = msg.err | |
| 145 | + | m.done = true | |
| 146 | + | m.loading = false | |
| 147 | + | return m | |
| 148 | + | } | |
| 149 | + | m.targetsLoaded = true | |
| 150 | + | m.targets = msg.targets | |
| 151 | + | return m.checkLoadingComplete() | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (m PurgeModel) handleSessionsLoaded(msg purgeSessionsLoadedMsg) PurgeModel { | |
| 155 | + | if msg.err != nil { | |
| 156 | + | // Session loading failure is non-fatal, continue without sessions | |
| 157 | + | m.sessionsLoaded = true | |
| 158 | + | m.loadingSessions = false | |
| 159 | + | return m.checkLoadingComplete() | |
| 160 | + | } | |
| 161 | + | m.sessionsLoaded = true | |
| 162 | + | m.sessions = msg.sessions | |
| 163 | + | m.sessionsSize = msg.size | |
| 164 | + | return m.checkLoadingComplete() | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (m PurgeModel) handlePurgeComplete(msg purgeCompleteMsg) PurgeModel { | |
| 168 | + | m.purging = false | |
| 169 | + | m.done = true | |
| 170 | + | if msg.targetResults != nil { | |
| 171 | + | m.results = &usecase.PurgeOutput{Results: msg.targetResults} | |
| 172 | + | for _, r := range msg.targetResults { | |
| 173 | + | if r.Success { | |
| 174 | + | m.results.SuccessCount++ | |
| 175 | + | } else { | |
| 176 | + | m.results.FailureCount++ | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | m.sessionResults = msg.sessionResults | |
| 181 | + | if msg.err != nil { | |
| 182 | + | m.err = msg.err | |
| 183 | + | } | |
| 184 | + | return m | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (m PurgeModel) isBlocked() bool { | |
| 188 | + | return m.loading || m.loadingSessions || m.purging | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func (m PurgeModel) updateSelector(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 94 | 192 | selector, cmd := m.selector.Update(msg) | |
| 95 | 193 | m.selector = selector | |
| 96 | 194 | ||
| 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 | |
| 110 | 210 | } | |
| 211 | + | m.purging = true | |
| 212 | + | return m, m.performPurge(targetTypes, sessionIDs) | |
| 111 | 213 | } | |
| 112 | 214 | ||
| 113 | 215 | return m, cmd | |
| 114 | 216 | } | |
| 115 | 217 | ||
| 116 | - | func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd { | |
| 218 | + | func (m PurgeModel) checkLoadingComplete() PurgeModel { | |
| 219 | + | // If sessions aren't being loaded, only wait for targets | |
| 220 | + | needSessions := m.listSessionsUC != nil | |
| 221 | + | if m.targetsLoaded && (!needSessions || m.sessionsLoaded) { | |
| 222 | + | m.loading = false | |
| 223 | + | m.loadingSessions = false | |
| 224 | + | m.selector = styles.NewPurgeWithSessions(m.theme, m.targets, m.sessions, m.sessionsSize) | |
| 225 | + | } | |
| 226 | + | return m | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType, sessionIDs []entity.SessionID) tea.Cmd { | |
| 117 | 230 | 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 | + | } | |
| 120 | 269 | } | |
| 121 | 270 | } | |
| 122 | 271 | ||
| @@ -124,7 +273,7 @@ func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd { | |||
| 124 | 273 | func (m PurgeModel) View() string { | |
| 125 | 274 | t := m.theme | |
| 126 | 275 | ||
| 127 | - | if m.loading { | |
| 276 | + | if m.loading || m.loadingSessions { | |
| 128 | 277 | spinner := styles.NewLoading(t, "Scanning purge targets...") | |
| 129 | 278 | return t.Box.Render(spinner.View()) | |
| 130 | 279 | } | |
| @@ -145,7 +294,7 @@ func (m PurgeModel) View() string { | |||
| 145 | 294 | return t.Box.Render(content) | |
| 146 | 295 | } | |
| 147 | 296 | ||
| 148 | - | if m.err != nil && m.results == nil { | |
| 297 | + | if m.err != nil && m.results == nil && len(m.sessionResults) == 0 { | |
| 149 | 298 | content := lipgloss.JoinVertical( | |
| 150 | 299 | lipgloss.Left, | |
| 151 | 300 | t.ErrorStyle.Render("Error: "+m.err.Error()), | |
| @@ -155,28 +304,52 @@ func (m PurgeModel) View() string { | |||
| 155 | 304 | return t.Box.Render(content) | |
| 156 | 305 | } | |
| 157 | 306 | ||
| 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++ | |
| 166 | 329 | } | |
| 167 | - | contentLines = append( | |
| 168 | - | contentLines, | |
| 169 | - | "", | |
| 170 | - | t.Subtle.Render( | |
| 171 | - | fmt.Sprintf("%d succeeded, %d failed", m.results.SuccessCount, m.results.FailureCount), | |
| 172 | - | ), | |
| 173 | - | ) | |
| 174 | 330 | } | |
| 175 | - | contentLines = append(contentLines, "", t.Subtle.Render("Press any key to exit")) | |
| 176 | - | return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...)) | |
| 177 | 331 | } | |
| 178 | 332 | ||
| 179 | - | return m.selector.View() | |
| 333 | + | // Session results | |
| 334 | + | for _, r := range m.sessionResults { | |
| 335 | + | if r.Success { | |
| 336 | + | contentLines = append(contentLines, fmt.Sprintf("%s Session %s", t.SuccessStyle.Render(styles.IconCheck), r.SessionID)) | |
| 337 | + | successCount++ | |
| 338 | + | } else { | |
| 339 | + | contentLines = append(contentLines, fmt.Sprintf("%s Session %s: %v", t.ErrorStyle.Render(styles.IconX), r.SessionID, r.Error)) | |
| 340 | + | failureCount++ | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | contentLines = append( | |
| 345 | + | contentLines, | |
| 346 | + | "", | |
| 347 | + | t.Subtle.Render(fmt.Sprintf("%d succeeded, %d failed", successCount, failureCount)), | |
| 348 | + | "", | |
| 349 | + | t.Subtle.Render("Press any key to exit"), | |
| 350 | + | ) | |
| 351 | + | ||
| 352 | + | return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...)) | |
| 180 | 353 | } | |
| 181 | 354 | ||
| 182 | 355 | var _ tea.Model = (*PurgeModel)(nil) | |
A internal/cli/model/sessions.go +642 -0
| @@ -0,0 +1,642 @@ | |||
| 1 | + | // Package model provides Bubble Tea models for CLI commands. | |
| 2 | + | package model | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/charmbracelet/bubbles/help" | |
| 10 | + | "github.com/charmbracelet/bubbles/key" | |
| 11 | + | tea "github.com/charmbracelet/bubbletea" | |
| 12 | + | "github.com/charmbracelet/lipgloss" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 15 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 16 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 17 | + | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 18 | + | "github.com/bnema/dumber/internal/logging" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // SessionsModel is the Bubble Tea model for interactive session browser. | |
| 22 | + | type SessionsModel struct { | |
| 23 | + | // UI components | |
| 24 | + | help help.Model | |
| 25 | + | keys sessionsKeyMap | |
| 26 | + | confirm *styles.ConfirmModel | |
| 27 | + | ||
| 28 | + | // State | |
| 29 | + | sessions []entity.SessionInfo | |
| 30 | + | selectedIdx int | |
| 31 | + | expandedIdx int // -1 means none expanded | |
| 32 | + | width int | |
| 33 | + | height int | |
| 34 | + | err error | |
| 35 | + | statusMessage string | |
| 36 | + | currentSession entity.SessionID | |
| 37 | + | ||
| 38 | + | // Config | |
| 39 | + | maxListedSessions int | |
| 40 | + | ||
| 41 | + | // Dependencies | |
| 42 | + | ctx context.Context | |
| 43 | + | listSessionsUC *usecase.ListSessionsUseCase | |
| 44 | + | restoreUC *usecase.RestoreSessionUseCase | |
| 45 | + | deleteSessionUC *usecase.DeleteSessionUseCase | |
| 46 | + | theme *styles.Theme | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // sessionsKeyMap defines keybindings for the sessions browser. | |
| 50 | + | type sessionsKeyMap struct { | |
| 51 | + | Up key.Binding | |
| 52 | + | Down key.Binding | |
| 53 | + | Expand key.Binding | |
| 54 | + | Restore key.Binding | |
| 55 | + | Delete key.Binding | |
| 56 | + | Refresh key.Binding | |
| 57 | + | Help key.Binding | |
| 58 | + | Quit key.Binding | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // ShortHelp returns keybindings for the short help view. | |
| 62 | + | func (k sessionsKeyMap) ShortHelp() []key.Binding { | |
| 63 | + | return []key.Binding{k.Up, k.Down, k.Expand, k.Restore, k.Delete, k.Quit} | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // FullHelp returns keybindings for the full help view. | |
| 67 | + | func (k sessionsKeyMap) FullHelp() [][]key.Binding { | |
| 68 | + | return [][]key.Binding{ | |
| 69 | + | {k.Up, k.Down, k.Expand}, | |
| 70 | + | {k.Restore, k.Delete, k.Refresh}, | |
| 71 | + | {k.Help, k.Quit}, | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func defaultSessionsKeyMap() sessionsKeyMap { | |
| 76 | + | return sessionsKeyMap{ | |
| 77 | + | Up: key.NewBinding( | |
| 78 | + | key.WithKeys("k", "up"), | |
| 79 | + | key.WithHelp("↑/k", "up"), | |
| 80 | + | ), | |
| 81 | + | Down: key.NewBinding( | |
| 82 | + | key.WithKeys("j", "down"), | |
| 83 | + | key.WithHelp("↓/j", "down"), | |
| 84 | + | ), | |
| 85 | + | Expand: key.NewBinding( | |
| 86 | + | key.WithKeys("enter", "tab"), | |
| 87 | + | key.WithHelp("enter", "expand/collapse"), | |
| 88 | + | ), | |
| 89 | + | Restore: key.NewBinding( | |
| 90 | + | key.WithKeys("r"), | |
| 91 | + | key.WithHelp("r", "restore"), | |
| 92 | + | ), | |
| 93 | + | Delete: key.NewBinding( | |
| 94 | + | key.WithKeys("x", "d"), | |
| 95 | + | key.WithHelp("x", "delete"), | |
| 96 | + | ), | |
| 97 | + | Refresh: key.NewBinding( | |
| 98 | + | key.WithKeys("R"), | |
| 99 | + | key.WithHelp("R", "refresh"), | |
| 100 | + | ), | |
| 101 | + | Help: key.NewBinding( | |
| 102 | + | key.WithKeys("?"), | |
| 103 | + | key.WithHelp("?", "help"), | |
| 104 | + | ), | |
| 105 | + | Quit: key.NewBinding( | |
| 106 | + | key.WithKeys("q", "esc", "ctrl+c"), | |
| 107 | + | key.WithHelp("q", "quit"), | |
| 108 | + | ), | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // SessionsModelConfig holds configuration for the sessions model. | |
| 113 | + | type SessionsModelConfig struct { | |
| 114 | + | ListSessionsUC *usecase.ListSessionsUseCase | |
| 115 | + | RestoreUC *usecase.RestoreSessionUseCase | |
| 116 | + | DeleteSessionUC *usecase.DeleteSessionUseCase | |
| 117 | + | CurrentSession entity.SessionID | |
| 118 | + | MaxExitedSessions int | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // NewSessionsModel creates a new sessions browser model. | |
| 122 | + | func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel { | |
| 123 | + | maxListed := cfg.MaxExitedSessions | |
| 124 | + | if maxListed <= 0 { | |
| 125 | + | maxListed = 50 // fallback default | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | return SessionsModel{ | |
| 129 | + | help: help.New(), | |
| 130 | + | keys: defaultSessionsKeyMap(), | |
| 131 | + | selectedIdx: 0, | |
| 132 | + | expandedIdx: -1, | |
| 133 | + | width: 80, | |
| 134 | + | height: 24, | |
| 135 | + | maxListedSessions: maxListed, | |
| 136 | + | ctx: ctx, | |
| 137 | + | listSessionsUC: cfg.ListSessionsUC, | |
| 138 | + | restoreUC: cfg.RestoreUC, | |
| 139 | + | deleteSessionUC: cfg.DeleteSessionUC, | |
| 140 | + | currentSession: cfg.CurrentSession, | |
| 141 | + | theme: theme, | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // Init implements tea.Model. | |
| 146 | + | func (m SessionsModel) Init() tea.Cmd { | |
| 147 | + | return m.loadSessions | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // sessionsLoadedMsg is sent when sessions are loaded. | |
| 151 | + | type sessionsLoadedMsg struct { | |
| 152 | + | sessions []entity.SessionInfo | |
| 153 | + | err error | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // sessionDeletedMsg is sent when a session is deleted. | |
| 157 | + | type sessionDeletedMsg struct { | |
| 158 | + | sessionID entity.SessionID | |
| 159 | + | err error | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // sessionRestoredMsg is sent when a session restoration is triggered. | |
| 163 | + | type sessionRestoredMsg struct { | |
| 164 | + | sessionID entity.SessionID | |
| 165 | + | err error | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (m SessionsModel) loadSessions() tea.Msg { | |
| 169 | + | log := logging.FromContext(m.ctx) | |
| 170 | + | log.Debug().Msg("loading sessions") | |
| 171 | + | ||
| 172 | + | if m.listSessionsUC == nil { | |
| 173 | + | return sessionsLoadedMsg{err: fmt.Errorf("session management not available")} | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | output, err := m.listSessionsUC.Execute(m.ctx, m.currentSession, m.maxListedSessions) | |
| 177 | + | if err != nil { | |
| 178 | + | log.Error().Err(err).Msg("failed to load sessions") | |
| 179 | + | return sessionsLoadedMsg{err: err} | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | log.Debug().Int("count", len(output.Sessions)).Msg("loaded sessions") | |
| 183 | + | return sessionsLoadedMsg{sessions: output.Sessions} | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // Update implements tea.Model. | |
| 187 | + | func (m SessionsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 188 | + | // Handle confirm modal | |
| 189 | + | if m.confirm != nil { | |
| 190 | + | return m.handleConfirmModal(msg) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | switch msg := msg.(type) { | |
| 194 | + | case tea.WindowSizeMsg: | |
| 195 | + | m.width = msg.Width | |
| 196 | + | m.height = msg.Height | |
| 197 | + | m.help.Width = msg.Width | |
| 198 | + | return m, nil | |
| 199 | + | ||
| 200 | + | case tea.KeyMsg: | |
| 201 | + | return m.handleKeyMsg(msg) | |
| 202 | + | ||
| 203 | + | case sessionsLoadedMsg: | |
| 204 | + | if msg.err != nil { | |
| 205 | + | m.err = msg.err | |
| 206 | + | } else { | |
| 207 | + | m.sessions = msg.sessions | |
| 208 | + | m.err = nil | |
| 209 | + | } | |
| 210 | + | return m, nil | |
| 211 | + | ||
| 212 | + | case sessionDeletedMsg: | |
| 213 | + | if msg.err != nil { | |
| 214 | + | m.statusMessage = fmt.Sprintf("Error: %v", msg.err) | |
| 215 | + | } else { | |
| 216 | + | m.statusMessage = fmt.Sprintf("Session %s deleted", msg.sessionID) | |
| 217 | + | } | |
| 218 | + | return m, m.loadSessions | |
| 219 | + | ||
| 220 | + | case sessionRestoredMsg: | |
| 221 | + | if msg.err != nil { | |
| 222 | + | m.statusMessage = fmt.Sprintf("Error: %v", msg.err) | |
| 223 | + | } else { | |
| 224 | + | m.statusMessage = fmt.Sprintf("Restoring session %s...", msg.sessionID) | |
| 225 | + | return m, tea.Quit | |
| 226 | + | } | |
| 227 | + | return m, nil | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | return m, nil | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func (m SessionsModel) handleConfirmModal(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 234 | + | confirm, cmd := m.confirm.Update(msg) | |
| 235 | + | m.confirm = &confirm | |
| 236 | + | if m.confirm.Done() { | |
| 237 | + | if m.confirm.Result() { | |
| 238 | + | // User confirmed deletion | |
| 239 | + | if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) { | |
| 240 | + | session := m.sessions[m.selectedIdx] | |
| 241 | + | cmd = m.deleteSession(session.Session.ID) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | m.confirm = nil | |
| 245 | + | return m, cmd | |
| 246 | + | } | |
| 247 | + | return m, cmd | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func (m SessionsModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |
| 251 | + | switch { | |
| 252 | + | case key.Matches(msg, m.keys.Quit): | |
| 253 | + | return m, tea.Quit | |
| 254 | + | ||
| 255 | + | case key.Matches(msg, m.keys.Up): | |
| 256 | + | if m.selectedIdx > 0 { | |
| 257 | + | m.selectedIdx-- | |
| 258 | + | } | |
| 259 | + | return m, nil | |
| 260 | + | ||
| 261 | + | case key.Matches(msg, m.keys.Down): | |
| 262 | + | if m.selectedIdx < len(m.sessions)-1 { | |
| 263 | + | m.selectedIdx++ | |
| 264 | + | } | |
| 265 | + | return m, nil | |
| 266 | + | ||
| 267 | + | case key.Matches(msg, m.keys.Expand): | |
| 268 | + | if m.expandedIdx == m.selectedIdx { | |
| 269 | + | m.expandedIdx = -1 // collapse | |
| 270 | + | } else { | |
| 271 | + | m.expandedIdx = m.selectedIdx // expand | |
| 272 | + | } | |
| 273 | + | return m, nil | |
| 274 | + | ||
| 275 | + | case key.Matches(msg, m.keys.Restore): | |
| 276 | + | if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) { | |
| 277 | + | session := m.sessions[m.selectedIdx] | |
| 278 | + | if session.IsCurrent { | |
| 279 | + | m.statusMessage = "Cannot restore current session" | |
| 280 | + | return m, nil | |
| 281 | + | } | |
| 282 | + | return m, m.restoreSession(session.Session.ID) | |
| 283 | + | } | |
| 284 | + | return m, nil | |
| 285 | + | ||
| 286 | + | case key.Matches(msg, m.keys.Delete): | |
| 287 | + | if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) { | |
| 288 | + | session := m.sessions[m.selectedIdx] | |
| 289 | + | if session.IsCurrent || session.IsActive { | |
| 290 | + | m.statusMessage = "Cannot delete active session" | |
| 291 | + | return m, nil | |
| 292 | + | } | |
| 293 | + | confirm := styles.NewConfirm(m.theme, fmt.Sprintf("Delete session %s?", session.Session.ID)) | |
| 294 | + | m.confirm = &confirm | |
| 295 | + | } | |
| 296 | + | return m, nil | |
| 297 | + | ||
| 298 | + | case key.Matches(msg, m.keys.Refresh): | |
| 299 | + | return m, m.loadSessions | |
| 300 | + | ||
| 301 | + | case key.Matches(msg, m.keys.Help): | |
| 302 | + | m.help.ShowAll = !m.help.ShowAll | |
| 303 | + | return m, nil | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | return m, nil | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func (m SessionsModel) deleteSession(sessionID entity.SessionID) tea.Cmd { | |
| 310 | + | return func() tea.Msg { | |
| 311 | + | if m.deleteSessionUC == nil { | |
| 312 | + | return sessionDeletedMsg{sessionID: sessionID, err: fmt.Errorf("delete session use case not available")} | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | err := m.deleteSessionUC.Execute(m.ctx, usecase.DeleteSessionInput{ | |
| 316 | + | SessionID: sessionID, | |
| 317 | + | CurrentSessionID: m.currentSession, | |
| 318 | + | }) | |
| 319 | + | return sessionDeletedMsg{sessionID: sessionID, err: err} | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func (m SessionsModel) restoreSession(sessionID entity.SessionID) tea.Cmd { | |
| 324 | + | return func() tea.Msg { | |
| 325 | + | log := logging.FromContext(m.ctx) | |
| 326 | + | log.Info().Str("session_id", string(sessionID)).Msg("restoring session") | |
| 327 | + | ||
| 328 | + | if m.restoreUC == nil { | |
| 329 | + | return sessionRestoredMsg{sessionID: sessionID, err: fmt.Errorf("restore use case not available")} | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // Validate the session has restorable state | |
| 333 | + | _, err := m.restoreUC.Execute(m.ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 334 | + | if err != nil { | |
| 335 | + | return sessionRestoredMsg{sessionID: sessionID, err: err} | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // Spawn a new dumber instance | |
| 339 | + | spawner := desktop.NewSessionSpawner(m.ctx) | |
| 340 | + | if err := spawner.SpawnWithSession(sessionID); err != nil { | |
| 341 | + | return sessionRestoredMsg{sessionID: sessionID, err: err} | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | return sessionRestoredMsg{sessionID: sessionID} | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // View implements tea.Model. | |
| 349 | + | func (m SessionsModel) View() string { | |
| 350 | + | // Handle confirm modal | |
| 351 | + | if m.confirm != nil { | |
| 352 | + | return m.confirm.View() | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | t := m.theme | |
| 356 | + | var b strings.Builder | |
| 357 | + | ||
| 358 | + | // Header | |
| 359 | + | header := m.renderHeader() | |
| 360 | + | b.WriteString(header) | |
| 361 | + | b.WriteString("\n\n") | |
| 362 | + | ||
| 363 | + | // Error display | |
| 364 | + | if m.err != nil { | |
| 365 | + | b.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err))) | |
| 366 | + | b.WriteString("\n\n") | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | // Status message | |
| 370 | + | if m.statusMessage != "" { | |
| 371 | + | b.WriteString(t.Subtle.Render(m.statusMessage)) | |
| 372 | + | b.WriteString("\n\n") | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // Sessions list | |
| 376 | + | if len(m.sessions) == 0 { | |
| 377 | + | b.WriteString(t.Subtle.Render(" No saved sessions found.")) | |
| 378 | + | b.WriteString("\n") | |
| 379 | + | } else { | |
| 380 | + | b.WriteString(m.renderSessionsList()) | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | b.WriteString("\n") | |
| 384 | + | ||
| 385 | + | // Help | |
| 386 | + | helpView := m.help.View(m.keys) | |
| 387 | + | b.WriteString(helpView) | |
| 388 | + | ||
| 389 | + | return b.String() | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func (m SessionsModel) renderHeader() string { | |
| 393 | + | t := m.theme | |
| 394 | + | ||
| 395 | + | iconStyle := lipgloss.NewStyle().Foreground(t.Accent) | |
| 396 | + | titleStyle := t.Title.MarginLeft(1) | |
| 397 | + | ||
| 398 | + | icon := iconStyle.Render(styles.IconSession) | |
| 399 | + | title := titleStyle.Render("Sessions") | |
| 400 | + | ||
| 401 | + | // Count stats | |
| 402 | + | var activeCount, exitedCount int | |
| 403 | + | for _, s := range m.sessions { | |
| 404 | + | if s.IsCurrent || s.IsActive { | |
| 405 | + | activeCount++ | |
| 406 | + | } else { | |
| 407 | + | exitedCount++ | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | stats := t.Subtle.Render(fmt.Sprintf(" %s %d active %s %d exited", | |
| 412 | + | styles.IconPlay, activeCount, | |
| 413 | + | styles.IconStop, exitedCount, | |
| 414 | + | )) | |
| 415 | + | ||
| 416 | + | return icon + title + stats | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | func (m SessionsModel) renderSessionsList() string { | |
| 420 | + | var b strings.Builder | |
| 421 | + | ||
| 422 | + | for i, info := range m.sessions { | |
| 423 | + | isSelected := i == m.selectedIdx | |
| 424 | + | isExpanded := i == m.expandedIdx | |
| 425 | + | ||
| 426 | + | b.WriteString(m.renderSessionRow(info, isSelected, isExpanded)) | |
| 427 | + | b.WriteString("\n") | |
| 428 | + | ||
| 429 | + | // Render expanded details | |
| 430 | + | if isExpanded && info.State != nil { | |
| 431 | + | b.WriteString(m.renderSessionDetails(info)) | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | return b.String() | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string { | |
| 439 | + | t := m.theme | |
| 440 | + | ||
| 441 | + | // Cursor | |
| 442 | + | cursor := " " | |
| 443 | + | if isSelected { | |
| 444 | + | cursor = t.Highlight.Render(styles.IconCursor + " ") | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | // Status icon | |
| 448 | + | var statusIcon string | |
| 449 | + | var statusStyle lipgloss.Style | |
| 450 | + | switch { | |
| 451 | + | case info.IsCurrent: | |
| 452 | + | statusIcon = styles.IconPlay | |
| 453 | + | statusStyle = lipgloss.NewStyle().Foreground(t.Accent) | |
| 454 | + | case info.IsActive: | |
| 455 | + | statusIcon = styles.IconPlay | |
| 456 | + | statusStyle = lipgloss.NewStyle().Foreground(t.Warning) | |
| 457 | + | default: | |
| 458 | + | statusIcon = styles.IconStop | |
| 459 | + | statusStyle = t.Subtle | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | // Session ID | |
| 463 | + | idStyle := t.Normal | |
| 464 | + | if isSelected { | |
| 465 | + | idStyle = t.Highlight | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | // Label for current/active | |
| 469 | + | label := "" | |
| 470 | + | if info.IsCurrent { | |
| 471 | + | label = t.Badge.Render("current") | |
| 472 | + | } else if info.IsActive { | |
| 473 | + | label = t.BadgeMuted.Render("active") | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | // Expand indicator | |
| 477 | + | expandIcon := styles.IconExpand | |
| 478 | + | if isExpanded { | |
| 479 | + | expandIcon = styles.IconCollapse | |
| 480 | + | } | |
| 481 | + | expandStyle := t.Subtle | |
| 482 | + | ||
| 483 | + | // Tab/pane counts | |
| 484 | + | counts := t.Subtle.Render(fmt.Sprintf("%s %d %s %d", | |
| 485 | + | styles.IconTab, info.TabCount, | |
| 486 | + | styles.IconPane, info.PaneCount, | |
| 487 | + | )) | |
| 488 | + | ||
| 489 | + | // Time | |
| 490 | + | relTime := usecase.GetRelativeTime(info.UpdatedAt) | |
| 491 | + | timeStr := t.Subtle.Render(fmt.Sprintf("%s %s", styles.IconClock, relTime)) | |
| 492 | + | ||
| 493 | + | // Build row | |
| 494 | + | row := fmt.Sprintf("%s%s %s %s %s %s %s", | |
| 495 | + | cursor, | |
| 496 | + | statusStyle.Render(statusIcon), | |
| 497 | + | idStyle.Render(string(info.Session.ID)), | |
| 498 | + | label, | |
| 499 | + | expandStyle.Render(expandIcon), | |
| 500 | + | counts, | |
| 501 | + | timeStr, | |
| 502 | + | ) | |
| 503 | + | ||
| 504 | + | return row | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string { | |
| 508 | + | t := m.theme | |
| 509 | + | var b strings.Builder | |
| 510 | + | ||
| 511 | + | if info.State == nil { | |
| 512 | + | b.WriteString(t.Subtle.Render(" No state data available\n")) | |
| 513 | + | return b.String() | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | // Tree prefix styles | |
| 517 | + | treeStyle := lipgloss.NewStyle().Foreground(t.Border) | |
| 518 | + | leafStyle := lipgloss.NewStyle().Foreground(t.Muted) | |
| 519 | + | ||
| 520 | + | // Render tabs as tree | |
| 521 | + | for tabIdx, tab := range info.State.Tabs { | |
| 522 | + | isLastTab := tabIdx == len(info.State.Tabs)-1 | |
| 523 | + | ||
| 524 | + | // Tab branch | |
| 525 | + | branch := "├── " | |
| 526 | + | if isLastTab { | |
| 527 | + | branch = "└── " | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | tabIcon := styles.IconTab | |
| 531 | + | if tab.IsPinned { | |
| 532 | + | tabIcon = styles.IconCheckboxChecked | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | tabName := tab.Name | |
| 536 | + | if tabName == "" { | |
| 537 | + | tabName = "Tab" | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | b.WriteString(fmt.Sprintf(" %s%s %s\n", | |
| 541 | + | treeStyle.Render(branch), | |
| 542 | + | leafStyle.Render(tabIcon), | |
| 543 | + | t.Normal.Render(tabName), | |
| 544 | + | )) | |
| 545 | + | ||
| 546 | + | // Render panes under this tab | |
| 547 | + | m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle) | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | b.WriteString("\n") | |
| 551 | + | return b.String() | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | func (m SessionsModel) renderPaneTree( | |
| 555 | + | b *strings.Builder, | |
| 556 | + | ws *entity.WorkspaceSnapshot, | |
| 557 | + | isLastTab bool, | |
| 558 | + | t *styles.Theme, | |
| 559 | + | treeStyle, leafStyle lipgloss.Style, | |
| 560 | + | ) { | |
| 561 | + | if ws == nil || ws.Root == nil { | |
| 562 | + | return | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | // Determine prefix for children | |
| 566 | + | childPrefix := " │ " | |
| 567 | + | if isLastTab { | |
| 568 | + | childPrefix = " " | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | m.renderPaneNode(b, ws.Root, childPrefix, true, t, treeStyle, leafStyle) | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | func (m SessionsModel) renderPaneNode( | |
| 575 | + | b *strings.Builder, | |
| 576 | + | node *entity.PaneNodeSnapshot, | |
| 577 | + | prefix string, | |
| 578 | + | isLast bool, | |
| 579 | + | t *styles.Theme, | |
| 580 | + | treeStyle, leafStyle lipgloss.Style, | |
| 581 | + | ) { | |
| 582 | + | if node == nil { | |
| 583 | + | return | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | branch := "├── " | |
| 587 | + | if isLast { | |
| 588 | + | branch = "└── " | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | if node.Pane != nil { | |
| 592 | + | // Leaf node (actual pane) | |
| 593 | + | pane := node.Pane | |
| 594 | + | title := pane.Title | |
| 595 | + | if title == "" { | |
| 596 | + | title = pane.URI | |
| 597 | + | } | |
| 598 | + | // Truncate long titles | |
| 599 | + | const maxTitleLen = 50 | |
| 600 | + | if len(title) > maxTitleLen { | |
| 601 | + | title = title[:maxTitleLen-3] + "..." | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | fmt.Fprintf(b, "%s%s%s %s\n", | |
| 605 | + | prefix, | |
| 606 | + | treeStyle.Render(branch), | |
| 607 | + | leafStyle.Render(styles.IconPane), | |
| 608 | + | t.Subtle.Render(title), | |
| 609 | + | ) | |
| 610 | + | } else if len(node.Children) > 0 { | |
| 611 | + | // Container node (split or stacked) | |
| 612 | + | splitIcon := styles.IconSessionStack | |
| 613 | + | splitType := "split" | |
| 614 | + | if node.IsStacked { | |
| 615 | + | splitType = "stacked" | |
| 616 | + | } else if node.SplitRatio > 0 { | |
| 617 | + | splitType = fmt.Sprintf("split %.0f%%", node.SplitRatio*100) | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | fmt.Fprintf(b, "%s%s%s %s\n", | |
| 621 | + | prefix, | |
| 622 | + | treeStyle.Render(branch), | |
| 623 | + | leafStyle.Render(splitIcon), | |
| 624 | + | t.Subtle.Render(splitType), | |
| 625 | + | ) | |
| 626 | + | ||
| 627 | + | // Child prefix | |
| 628 | + | childPrefix := prefix + "│ " | |
| 629 | + | if isLast { | |
| 630 | + | childPrefix = prefix + " " | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | // Render all children | |
| 634 | + | for i, child := range node.Children { | |
| 635 | + | isLastChild := i == len(node.Children)-1 | |
| 636 | + | m.renderPaneNode(b, child, childPrefix, isLastChild, t, treeStyle, leafStyle) | |
| 637 | + | } | |
| 638 | + | } | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | // Ensure interface compliance at compile time. | |
| 642 | + | var _ tea.Model = (*SessionsModel)(nil) | |
A internal/cli/styles/config.go +248 -0
| @@ -0,0 +1,248 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/charmbracelet/lipgloss" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ConfigRenderer renders config status messages with styled output. | |
| 14 | + | type ConfigRenderer struct { | |
| 15 | + | theme *Theme | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // NewConfigRenderer creates a new config renderer with the given theme. | |
| 19 | + | func NewConfigRenderer(theme *Theme) *ConfigRenderer { | |
| 20 | + | return &ConfigRenderer{theme: theme} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // RenderConfigInfo renders the config file info with status. | |
| 24 | + | func (r *ConfigRenderer) RenderConfigInfo(path string, changeCount int) string { | |
| 25 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 26 | + | pathStyle := r.theme.Subtle | |
| 27 | + | ||
| 28 | + | var status string | |
| 29 | + | if changeCount > 0 { | |
| 30 | + | countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |
| 31 | + | status = fmt.Sprintf("\n %s %s changes detected", | |
| 32 | + | iconStyle.Render(IconInfo), | |
| 33 | + | countStyle.Render(fmt.Sprintf("%d", changeCount)), | |
| 34 | + | ) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | return fmt.Sprintf( | |
| 38 | + | "\n %s Config %s%s\n", | |
| 39 | + | iconStyle.Render(IconConfig), | |
| 40 | + | pathStyle.Render(path), | |
| 41 | + | status, | |
| 42 | + | ) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // RenderMissingKeys renders the list of missing keys with their types and default values. | |
| 46 | + | func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string { | |
| 47 | + | if len(keys) == 0 { | |
| 48 | + | return "" | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 52 | + | keyStyle := r.theme.Highlight | |
| 53 | + | typeStyle := r.theme.Subtle | |
| 54 | + | valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text) | |
| 55 | + | ||
| 56 | + | var sb strings.Builder | |
| 57 | + | sb.WriteString(fmt.Sprintf("\n Missing settings (%d):\n", len(keys))) | |
| 58 | + | ||
| 59 | + | for _, key := range keys { | |
| 60 | + | sb.WriteString(fmt.Sprintf( | |
| 61 | + | " %s %s\n Type: %s | Default: %s\n", | |
| 62 | + | iconStyle.Render(IconCursor), | |
| 63 | + | keyStyle.Render(key.Key), | |
| 64 | + | typeStyle.Render(key.Type), | |
| 65 | + | valueStyle.Render(key.DefaultValue), | |
| 66 | + | )) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | return sb.String() | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // RenderChanges renders the list of detected changes (added, renamed, removed). | |
| 73 | + | func (r *ConfigRenderer) RenderChanges(changes []port.KeyChange) string { | |
| 74 | + | if len(changes) == 0 { | |
| 75 | + | return "" | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | addStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |
| 79 | + | renameStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |
| 80 | + | removeStyle := lipgloss.NewStyle().Foreground(r.theme.Error) | |
| 81 | + | keyStyle := r.theme.Highlight | |
| 82 | + | valueStyle := r.theme.Subtle | |
| 83 | + | ||
| 84 | + | // Group changes by type | |
| 85 | + | var added, renamed, removed []port.KeyChange | |
| 86 | + | for _, c := range changes { | |
| 87 | + | switch c.Type { | |
| 88 | + | case port.KeyChangeAdded: | |
| 89 | + | added = append(added, c) | |
| 90 | + | case port.KeyChangeRenamed: | |
| 91 | + | renamed = append(renamed, c) | |
| 92 | + | case port.KeyChangeRemoved: | |
| 93 | + | removed = append(removed, c) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | var sb strings.Builder | |
| 98 | + | ||
| 99 | + | // Show renamed keys first (most important for user to understand) | |
| 100 | + | if len(renamed) > 0 { | |
| 101 | + | sb.WriteString(fmt.Sprintf("\n Renamed settings (%d):\n", len(renamed))) | |
| 102 | + | for _, c := range renamed { | |
| 103 | + | sb.WriteString(fmt.Sprintf( | |
| 104 | + | " %s %s %s %s\n Value: %s\n", | |
| 105 | + | renameStyle.Render("~"), | |
| 106 | + | valueStyle.Render(c.OldKey), | |
| 107 | + | renameStyle.Render("→"), | |
| 108 | + | keyStyle.Render(c.NewKey), | |
| 109 | + | valueStyle.Render(c.OldValue), | |
| 110 | + | )) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Show new settings | |
| 115 | + | if len(added) > 0 { | |
| 116 | + | sb.WriteString(fmt.Sprintf("\n New settings (%d):\n", len(added))) | |
| 117 | + | for _, c := range added { | |
| 118 | + | sb.WriteString(fmt.Sprintf( | |
| 119 | + | " %s %s\n Default: %s\n", | |
| 120 | + | addStyle.Render("+"), | |
| 121 | + | keyStyle.Render(c.NewKey), | |
| 122 | + | valueStyle.Render(c.NewValue), | |
| 123 | + | )) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // Show deprecated settings (will be removed) | |
| 128 | + | if len(removed) > 0 { | |
| 129 | + | sb.WriteString(fmt.Sprintf("\n Deprecated settings (%d):\n", len(removed))) | |
| 130 | + | for _, c := range removed { | |
| 131 | + | sb.WriteString(fmt.Sprintf( | |
| 132 | + | " %s %s %s\n", | |
| 133 | + | removeStyle.Render("-"), | |
| 134 | + | valueStyle.Render(c.OldKey), | |
| 135 | + | removeStyle.Render("(will be removed)"), | |
| 136 | + | )) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | return sb.String() | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // RenderChangesSummary renders a summary of changes for the config info header. | |
| 144 | + | func (r *ConfigRenderer) RenderChangesSummary(changes []port.KeyChange) string { | |
| 145 | + | var added, renamed, removed int | |
| 146 | + | for _, c := range changes { | |
| 147 | + | switch c.Type { | |
| 148 | + | case port.KeyChangeAdded: | |
| 149 | + | added++ | |
| 150 | + | case port.KeyChangeRenamed: | |
| 151 | + | renamed++ | |
| 152 | + | case port.KeyChangeRemoved: | |
| 153 | + | removed++ | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 158 | + | addStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |
| 159 | + | renameStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |
| 160 | + | removeStyle := lipgloss.NewStyle().Foreground(r.theme.Error) | |
| 161 | + | ||
| 162 | + | var parts []string | |
| 163 | + | if renamed > 0 { | |
| 164 | + | parts = append(parts, renameStyle.Render(fmt.Sprintf("%d renamed", renamed))) | |
| 165 | + | } | |
| 166 | + | if added > 0 { | |
| 167 | + | parts = append(parts, addStyle.Render(fmt.Sprintf("%d new", added))) | |
| 168 | + | } | |
| 169 | + | if removed > 0 { | |
| 170 | + | parts = append(parts, removeStyle.Render(fmt.Sprintf("%d deprecated", removed))) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | if len(parts) == 0 { | |
| 174 | + | return "" | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | return fmt.Sprintf("\n %s %s", iconStyle.Render(IconInfo), strings.Join(parts, ", ")) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // RenderMigrationSuccess renders the success message after migration. | |
| 181 | + | func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string { | |
| 182 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |
| 183 | + | countStyle := r.theme.Highlight | |
| 184 | + | pathStyle := r.theme.Subtle | |
| 185 | + | ||
| 186 | + | // Get just the filename from the path | |
| 187 | + | filename := filepath.Base(path) | |
| 188 | + | ||
| 189 | + | return fmt.Sprintf( | |
| 190 | + | "\n %s Applied %s changes to %s\n", | |
| 191 | + | iconStyle.Render(IconCheck), | |
| 192 | + | countStyle.Render(fmt.Sprintf("%d", count)), | |
| 193 | + | pathStyle.Render(filename), | |
| 194 | + | ) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // RenderUpToDate renders the "config is up to date" message. | |
| 198 | + | func (r *ConfigRenderer) RenderUpToDate(path string) string { | |
| 199 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |
| 200 | + | pathStyle := r.theme.Subtle | |
| 201 | + | ||
| 202 | + | return fmt.Sprintf( | |
| 203 | + | "\n %s Config %s\n %s Config is up to date\n", | |
| 204 | + | iconStyle.Render(IconConfig), | |
| 205 | + | pathStyle.Render(path), | |
| 206 | + | iconStyle.Render(IconCheck), | |
| 207 | + | ) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // RenderError renders an error message. | |
| 211 | + | func (r *ConfigRenderer) RenderError(err error) string { | |
| 212 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error) | |
| 213 | + | ||
| 214 | + | return fmt.Sprintf( | |
| 215 | + | "\n %s Config error: %v\n", | |
| 216 | + | iconStyle.Render(IconX), | |
| 217 | + | err, | |
| 218 | + | ) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // RenderChecking renders the "checking config..." message with spinner. | |
| 222 | + | func (*ConfigRenderer) RenderChecking(spinner string) string { | |
| 223 | + | return fmt.Sprintf("\n %s Checking config...\n", spinner) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // RenderMigrateHint renders a hint to run the migrate command. | |
| 227 | + | func (r *ConfigRenderer) RenderMigrateHint() string { | |
| 228 | + | hintStyle := r.theme.Subtle | |
| 229 | + | ||
| 230 | + | return fmt.Sprintf( | |
| 231 | + | "\n %s\n", | |
| 232 | + | hintStyle.Render("Run 'dumber config migrate' to add missing defaults."), | |
| 233 | + | ) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // RenderNoConfigFile renders message when config file doesn't exist yet. | |
| 237 | + | func (r *ConfigRenderer) RenderNoConfigFile(path string) string { | |
| 238 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 239 | + | pathStyle := r.theme.Subtle | |
| 240 | + | hintStyle := r.theme.Subtle | |
| 241 | + | ||
| 242 | + | return fmt.Sprintf( | |
| 243 | + | "\n %s Config %s\n %s\n", | |
| 244 | + | iconStyle.Render(IconConfig), | |
| 245 | + | pathStyle.Render(path), | |
| 246 | + | hintStyle.Render("Config file will be created on first run with all defaults."), | |
| 247 | + | ) | |
| 248 | + | } | |
A internal/cli/styles/config_schema.go +144 -0
| @@ -0,0 +1,144 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/charmbracelet/lipgloss" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ConfigSchemaRenderer renders configuration schema information. | |
| 14 | + | type ConfigSchemaRenderer struct { | |
| 15 | + | theme *Theme | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // NewConfigSchemaRenderer creates a new ConfigSchemaRenderer. | |
| 19 | + | func NewConfigSchemaRenderer(theme *Theme) *ConfigSchemaRenderer { | |
| 20 | + | return &ConfigSchemaRenderer{theme: theme} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Render renders the configuration schema in styled format. | |
| 24 | + | func (r *ConfigSchemaRenderer) Render(keys []entity.ConfigKeyInfo) string { | |
| 25 | + | if len(keys) == 0 { | |
| 26 | + | return r.theme.Subtle.Render("No configuration keys found") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Group keys by section | |
| 30 | + | sections := groupBySection(keys) | |
| 31 | + | ||
| 32 | + | // Build output | |
| 33 | + | var parts []string | |
| 34 | + | ||
| 35 | + | // Header | |
| 36 | + | header := r.renderHeader() | |
| 37 | + | parts = append(parts, header, "") | |
| 38 | + | ||
| 39 | + | // Render each section | |
| 40 | + | sectionOrder := []string{ | |
| 41 | + | "Appearance", | |
| 42 | + | "Logging", | |
| 43 | + | "History", | |
| 44 | + | "Search", | |
| 45 | + | "Dmenu", | |
| 46 | + | "Workspace", | |
| 47 | + | "Session", | |
| 48 | + | "Omnibox", | |
| 49 | + | "Content Filtering", | |
| 50 | + | "Rendering", | |
| 51 | + | "Media", | |
| 52 | + | "Update", | |
| 53 | + | "Debug", | |
| 54 | + | "Performance", | |
| 55 | + | "Runtime", | |
| 56 | + | "Database", | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | for _, section := range sectionOrder { | |
| 60 | + | if sectionKeys, ok := sections[section]; ok { | |
| 61 | + | parts = append(parts, r.renderSection(section, sectionKeys), "") | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | return strings.Join(parts, "\n") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // RenderJSON renders the configuration schema as JSON. | |
| 69 | + | func (*ConfigSchemaRenderer) RenderJSON(keys []entity.ConfigKeyInfo) (string, error) { | |
| 70 | + | data, err := json.MarshalIndent(keys, "", " ") | |
| 71 | + | if err != nil { | |
| 72 | + | return "", fmt.Errorf("marshal schema: %w", err) | |
| 73 | + | } | |
| 74 | + | return string(data), nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (r *ConfigSchemaRenderer) renderHeader() string { | |
| 78 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 79 | + | title := fmt.Sprintf("%s %s", iconStyle.Render(IconConfig), r.theme.Title.Render("Config Schema Reference")) | |
| 80 | + | return title | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func groupBySection(keys []entity.ConfigKeyInfo) map[string][]entity.ConfigKeyInfo { | |
| 84 | + | sections := make(map[string][]entity.ConfigKeyInfo) | |
| 85 | + | for _, key := range keys { | |
| 86 | + | sections[key.Section] = append(sections[key.Section], key) | |
| 87 | + | } | |
| 88 | + | return sections | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (r *ConfigSchemaRenderer) renderSection(name string, keys []entity.ConfigKeyInfo) string { | |
| 92 | + | var lines []string | |
| 93 | + | ||
| 94 | + | for _, key := range keys { | |
| 95 | + | lines = append(lines, r.renderKey(key)) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | body := strings.Join(lines, "\n") | |
| 99 | + | ||
| 100 | + | // Create section header (no extra padding/margin) | |
| 101 | + | sectionHeader := r.theme.Highlight.Render(name) | |
| 102 | + | ||
| 103 | + | // Build box content: header directly followed by keys | |
| 104 | + | boxContent := sectionHeader + "\n" + body | |
| 105 | + | ||
| 106 | + | // Use box style without top padding | |
| 107 | + | boxStyle := r.theme.Box.PaddingTop(0) | |
| 108 | + | ||
| 109 | + | return boxStyle.Render(boxContent) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (r *ConfigSchemaRenderer) renderKey(key entity.ConfigKeyInfo) string { | |
| 113 | + | keyStyle := r.theme.Normal.Bold(true) | |
| 114 | + | typeStyle := r.theme.Subtle | |
| 115 | + | defaultStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 116 | + | descStyle := r.theme.Subtle | |
| 117 | + | valuesStyle := r.theme.Normal | |
| 118 | + | ||
| 119 | + | // Line 1: key name, type, default | |
| 120 | + | line1 := fmt.Sprintf( | |
| 121 | + | "%s %s %s", | |
| 122 | + | keyStyle.Render(key.Key), | |
| 123 | + | typeStyle.Render(key.Type), | |
| 124 | + | defaultStyle.Render(key.Default), | |
| 125 | + | ) | |
| 126 | + | ||
| 127 | + | // Line 2: description (indented) | |
| 128 | + | line2 := fmt.Sprintf(" %s", descStyle.Render(key.Description)) | |
| 129 | + | ||
| 130 | + | result := line1 + "\n" + line2 | |
| 131 | + | ||
| 132 | + | // Line 3 (optional): values or range | |
| 133 | + | if len(key.Values) > 0 { | |
| 134 | + | valuesText := "Values: " + strings.Join(key.Values, ", ") | |
| 135 | + | line3 := fmt.Sprintf(" %s", valuesStyle.Render(valuesText)) | |
| 136 | + | result += "\n" + line3 | |
| 137 | + | } else if key.Range != "" { | |
| 138 | + | rangeText := "Range: " + key.Range | |
| 139 | + | line3 := fmt.Sprintf(" %s", valuesStyle.Render(rangeText)) | |
| 140 | + | result += "\n" + line3 | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | return result | |
| 144 | + | } | |
M internal/cli/styles/icons.go +18 -0
| @@ -40,4 +40,22 @@ const ( | |||
| 40 | 40 | ||
| 41 | 41 | // UI | |
| 42 | 42 | IconCursor = "\uf054" // chevron-right | |
| 43 | + | ||
| 44 | + | // Sessions / browser | |
| 45 | + | IconSession = "\uf2d2" // window | |
| 46 | + | IconSessionStack = "\uf24d" // clone/stack | |
| 47 | + | IconTab = "\uf0ce" // table | |
| 48 | + | IconPane = "\uf0db" // columns | |
| 49 | + | IconTree = "\uf1bb" // tree | |
| 50 | + | IconClock = "\uf017" // clock | |
| 51 | + | IconPlay = "\uf04b" // play (running) | |
| 52 | + | IconStop = "\uf04d" // stop (exited) | |
| 53 | + | IconRestore = "\uf0e2" // rotate-left (restore) | |
| 54 | + | IconExpand = "\uf065" // expand | |
| 55 | + | IconCollapse = "\uf066" // compress | |
| 56 | + | ||
| 57 | + | // Update / download | |
| 58 | + | IconDownload = "\uf019" // download | |
| 59 | + | IconUpdate = "\uf021" // refresh/sync | |
| 60 | + | IconRocket = "\uf135" // rocket (upgrade) | |
| 43 | 61 | ) | |
M internal/cli/styles/purge.go +224 -34
| @@ -17,13 +17,19 @@ type PurgeItem struct { | |||
| 17 | 17 | Selected bool | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | - | // PurgeModel is the multi-select purge modal. | |
| 20 | + | // PurgeModel is the multi-select purge modal with simplified sessions support. | |
| 21 | + | // Sessions are handled as a single "purge all" option instead of individual selection. | |
| 21 | 22 | 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 | |
| 27 | 33 | } | |
| 28 | 34 | ||
| 29 | 35 | // PurgeKeyMap defines keybindings for purge modal. | |
| @@ -48,6 +54,34 @@ func DefaultPurgeKeyMap() PurgeKeyMap { | |||
| 48 | 54 | } | |
| 49 | 55 | } | |
| 50 | 56 | ||
| 57 | + | // rowType indicates what kind of row we're dealing with | |
| 58 | + | type rowType int | |
| 59 | + | ||
| 60 | + | const ( | |
| 61 | + | rowTypeItem rowType = iota | |
| 62 | + | rowTypeSessions | |
| 63 | + | ) | |
| 64 | + | ||
| 65 | + | // row represents a single row in the flat list | |
| 66 | + | type row struct { | |
| 67 | + | Type rowType | |
| 68 | + | ItemIndex int // index into Items array (only for rowTypeItem) | |
| 69 | + | Exists bool // whether this row is selectable | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // buildRows creates a flat list of rows in display order | |
| 73 | + | func (m PurgeModel) buildRows() []row { | |
| 74 | + | var rows []row | |
| 75 | + | for i, it := range m.Items { | |
| 76 | + | rows = append(rows, row{Type: rowTypeItem, ItemIndex: i, Exists: it.Exists}) | |
| 77 | + | // Insert sessions row right after Data | |
| 78 | + | if it.Type == entity.PurgeTargetData && m.hasSessionsItem() { | |
| 79 | + | rows = append(rows, row{Type: rowTypeSessions, Exists: m.SessionsEnabled()}) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | return rows | |
| 83 | + | } | |
| 84 | + | ||
| 51 | 85 | // NewPurge creates a new purge modal with the given targets. | |
| 52 | 86 | func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel { | |
| 53 | 87 | items := make([]PurgeItem, 0, len(targets)) | |
| @@ -60,6 +94,14 @@ func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel { | |||
| 60 | 94 | return m | |
| 61 | 95 | } | |
| 62 | 96 | ||
| 97 | + | // NewPurgeWithSessions creates a new purge modal with targets and sessions. | |
| 98 | + | func NewPurgeWithSessions(theme *Theme, targets []entity.PurgeTarget, sessions []entity.SessionPurgeItem, sessionsSize int64) PurgeModel { | |
| 99 | + | m := NewPurge(theme, targets) | |
| 100 | + | m.Sessions = sessions | |
| 101 | + | m.SessionsSize = sessionsSize | |
| 102 | + | return m | |
| 103 | + | } | |
| 104 | + | ||
| 63 | 105 | // Init implements tea.Model. | |
| 64 | 106 | func (m PurgeModel) Init() tea.Cmd { | |
| 65 | 107 | _ = m | |
| @@ -91,24 +133,63 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) { | |||
| 91 | 133 | return m, nil | |
| 92 | 134 | } | |
| 93 | 135 | ||
| 136 | + | // isDataSelected returns true if the Data target is selected. | |
| 137 | + | func (m PurgeModel) isDataSelected() bool { | |
| 138 | + | for _, it := range m.Items { | |
| 139 | + | if it.Type == entity.PurgeTargetData && it.Selected { | |
| 140 | + | return true | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | return false | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // SessionsEnabled returns true if sessions can be selected. | |
| 147 | + | // Sessions are disabled when Data is selected (database will be removed). | |
| 148 | + | func (m PurgeModel) SessionsEnabled() bool { | |
| 149 | + | return !m.isDataSelected() | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // hasSessionsItem returns true if there are sessions available to purge. | |
| 153 | + | func (m PurgeModel) hasSessionsItem() bool { | |
| 154 | + | return len(m.Sessions) > 0 | |
| 155 | + | } | |
| 156 | + | ||
| 94 | 157 | func (m *PurgeModel) moveCursor(delta int) { | |
| 95 | - | if len(m.Items) == 0 { | |
| 158 | + | rows := m.buildRows() | |
| 159 | + | if len(rows) == 0 { | |
| 160 | + | return | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // Build list of selectable row indices | |
| 164 | + | var selectableIndices []int | |
| 165 | + | for i, r := range rows { | |
| 166 | + | if r.Exists { | |
| 167 | + | selectableIndices = append(selectableIndices, i) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | if len(selectableIndices) == 0 { | |
| 96 | 172 | return | |
| 97 | 173 | } | |
| 98 | 174 | ||
| 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 | |
| 105 | 181 | } | |
| 106 | 182 | } | |
| 183 | + | ||
| 184 | + | // Move | |
| 185 | + | newPos := (currentPos + delta + len(selectableIndices)) % len(selectableIndices) | |
| 186 | + | m.Cursor = selectableIndices[newPos] | |
| 107 | 187 | } | |
| 108 | 188 | ||
| 109 | 189 | 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 { | |
| 112 | 193 | return i | |
| 113 | 194 | } | |
| 114 | 195 | } | |
| @@ -116,16 +197,26 @@ func (m PurgeModel) firstSelectableIndex() int { | |||
| 116 | 197 | } | |
| 117 | 198 | ||
| 118 | 199 | func (m *PurgeModel) toggleCurrent() { | |
| 119 | - | if m.Cursor < 0 || m.Cursor >= len(m.Items) { | |
| 200 | + | rows := m.buildRows() | |
| 201 | + | if m.Cursor < 0 || m.Cursor >= len(rows) { | |
| 120 | 202 | return | |
| 121 | 203 | } | |
| 122 | - | if !m.Items[m.Cursor].Exists { | |
| 204 | + | ||
| 205 | + | r := rows[m.Cursor] | |
| 206 | + | if !r.Exists { | |
| 123 | 207 | return | |
| 124 | 208 | } | |
| 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 | + | } | |
| 126 | 216 | } | |
| 127 | 217 | ||
| 128 | 218 | func (m *PurgeModel) toggleAll() { | |
| 219 | + | // Check if any selectable item is unselected | |
| 129 | 220 | anyUnselected := false | |
| 130 | 221 | for _, it := range m.Items { | |
| 131 | 222 | if it.Exists && !it.Selected { | |
| @@ -133,13 +224,27 @@ func (m *PurgeModel) toggleAll() { | |||
| 133 | 224 | break | |
| 134 | 225 | } | |
| 135 | 226 | } | |
| 227 | + | if m.hasSessionsItem() && m.SessionsEnabled() && !m.allSessionsSelected { | |
| 228 | + | anyUnselected = true | |
| 229 | + | } | |
| 136 | 230 | ||
| 231 | + | // Set all to the opposite state | |
| 137 | 232 | 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 | |
| 140 | 235 | } | |
| 141 | - | m.Items[i].Selected = anyUnselected | |
| 142 | 236 | } | |
| 237 | + | if m.hasSessionsItem() && m.SessionsEnabled() { | |
| 238 | + | m.allSessionsSelected = anyUnselected | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // ToggleAllSessions toggles the selection state of all sessions. | |
| 243 | + | func (m *PurgeModel) ToggleAllSessions() { | |
| 244 | + | if !m.hasSessionsItem() || !m.SessionsEnabled() { | |
| 245 | + | return | |
| 246 | + | } | |
| 247 | + | m.allSessionsSelected = !m.allSessionsSelected | |
| 143 | 248 | } | |
| 144 | 249 | ||
| 145 | 250 | // View implements tea.Model. | |
| @@ -149,12 +254,20 @@ func (m PurgeModel) View() string { | |||
| 149 | 254 | header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash)) | |
| 150 | 255 | subtitle := t.Subtle.Render("Select items to remove") | |
| 151 | 256 | ||
| 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 | + | } | |
| 155 | 268 | } | |
| 156 | 269 | ||
| 157 | - | list := lipgloss.JoinVertical(lipgloss.Left, rows...) | |
| 270 | + | itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...) | |
| 158 | 271 | ||
| 159 | 272 | selectedCount := m.SelectedCount() | |
| 160 | 273 | size := m.SelectedSize() | |
| @@ -172,28 +285,30 @@ func (m PurgeModel) View() string { | |||
| 172 | 285 | summary = t.Subtle.Render("0 selected") | |
| 173 | 286 | } | |
| 174 | 287 | ||
| 175 | - | help := t.Subtle.Render("↑/↓ or j/k to move • space to toggle • a toggle all • enter confirm • esc cancel") | |
| 288 | + | helpText := "↑/↓ j/k move • space toggle • a all • enter • esc" | |
| 289 | + | help := t.Subtle.Render(helpText) | |
| 176 | 290 | ||
| 177 | - | content := lipgloss.JoinVertical( | |
| 178 | - | lipgloss.Left, | |
| 291 | + | contentParts := []string{ | |
| 179 | 292 | header, | |
| 180 | 293 | subtitle, | |
| 181 | 294 | "", | |
| 182 | - | list, | |
| 295 | + | itemsList, | |
| 183 | 296 | "", | |
| 184 | 297 | summary, | |
| 185 | 298 | "", | |
| 186 | 299 | help, | |
| 187 | - | ) | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | content := lipgloss.JoinVertical(lipgloss.Left, contentParts...) | |
| 188 | 303 | ||
| 189 | 304 | return t.Box.Render(content) | |
| 190 | 305 | } | |
| 191 | 306 | ||
| 192 | - | func (m PurgeModel) renderRow(i int, it PurgeItem) string { | |
| 307 | + | func (m PurgeModel) renderItemRow(_ int, it PurgeItem, isCursor bool) string { | |
| 193 | 308 | t := m.theme | |
| 194 | 309 | ||
| 195 | 310 | cursor := " " | |
| 196 | - | if i == m.Cursor { | |
| 311 | + | if isCursor && it.Exists { | |
| 197 | 312 | cursor = IconCursor + " " | |
| 198 | 313 | } | |
| 199 | 314 | ||
| @@ -242,6 +357,51 @@ func (m PurgeModel) renderRow(i int, it PurgeItem) string { | |||
| 242 | 357 | return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing) | |
| 243 | 358 | } | |
| 244 | 359 | ||
| 360 | + | // renderSessionsItem renders the single "Sessions" item. | |
| 361 | + | func (m PurgeModel) renderSessionsItem(isCursor bool) string { | |
| 362 | + | t := m.theme | |
| 363 | + | enabled := m.SessionsEnabled() | |
| 364 | + | ||
| 365 | + | cursor := " " | |
| 366 | + | if isCursor && enabled { | |
| 367 | + | cursor = IconCursor + " " | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | checkbox := IconCheckboxEmpty | |
| 371 | + | checkboxStyle := t.Subtle | |
| 372 | + | cursorStyle := t.Subtle | |
| 373 | + | labelStyle := t.Subtle | |
| 374 | + | detailStyle := t.Subtle | |
| 375 | + | ||
| 376 | + | if enabled { | |
| 377 | + | if m.allSessionsSelected { | |
| 378 | + | checkbox = IconCheckboxChecked | |
| 379 | + | } | |
| 380 | + | checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent) | |
| 381 | + | cursorStyle = lipgloss.NewStyle().Foreground(t.Accent) | |
| 382 | + | labelStyle = t.Normal | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // Format: Sessions (5 inactive, 12.3 MB) or (included with Data) | |
| 386 | + | label := fmt.Sprintf("%s Sessions", IconSession) | |
| 387 | + | var detail string | |
| 388 | + | if enabled { | |
| 389 | + | detail = fmt.Sprintf("(%d inactive, %s)", len(m.Sessions), formatSize(m.SessionsSize)) | |
| 390 | + | } else { | |
| 391 | + | detail = fmt.Sprintf("(%d inactive, included with Data)", len(m.Sessions)) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | const labelPadWidth = 12 | |
| 395 | + | return lipgloss.JoinHorizontal( | |
| 396 | + | lipgloss.Left, | |
| 397 | + | cursorStyle.Render(cursor), | |
| 398 | + | checkboxStyle.Render(checkbox), | |
| 399 | + | " ", | |
| 400 | + | labelStyle.Render(padRight(label, labelPadWidth)), | |
| 401 | + | detailStyle.Render(detail), | |
| 402 | + | ) | |
| 403 | + | } | |
| 404 | + | ||
| 245 | 405 | func purgeLabel(t entity.PurgeTargetType) string { | |
| 246 | 406 | switch t { | |
| 247 | 407 | case entity.PurgeTargetConfig: | |
| @@ -309,7 +469,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType { | |||
| 309 | 469 | return out | |
| 310 | 470 | } | |
| 311 | 471 | ||
| 312 | - | // SelectedCount returns the number of selected, existing items. | |
| 472 | + | // SelectedCount returns the number of selected items (targets + sessions as 1 if selected). | |
| 313 | 473 | func (m PurgeModel) SelectedCount() int { | |
| 314 | 474 | count := 0 | |
| 315 | 475 | for _, it := range m.Items { | |
| @@ -317,10 +477,15 @@ func (m PurgeModel) SelectedCount() int { | |||
| 317 | 477 | count++ | |
| 318 | 478 | } | |
| 319 | 479 | } | |
| 480 | + | // Count sessions as 1 item if selected (and enabled) | |
| 481 | + | if m.hasSessionsItem() && m.SessionsEnabled() && m.allSessionsSelected { | |
| 482 | + | count++ | |
| 483 | + | } | |
| 320 | 484 | return count | |
| 321 | 485 | } | |
| 322 | 486 | ||
| 323 | - | // SelectedSize returns the total size of selected, existing items. | |
| 487 | + | // SelectedSize returns the total size of selected items. | |
| 488 | + | // When Data is selected, includes SessionsSize since DB will be removed. | |
| 324 | 489 | func (m PurgeModel) SelectedSize() int64 { | |
| 325 | 490 | var total int64 | |
| 326 | 491 | for _, it := range m.Items { | |
| @@ -328,5 +493,30 @@ func (m PurgeModel) SelectedSize() int64 { | |||
| 328 | 493 | total += it.Size | |
| 329 | 494 | } | |
| 330 | 495 | } | |
| 496 | + | // If Data is selected, sessions are implicitly included | |
| 497 | + | if m.isDataSelected() { | |
| 498 | + | total += m.SessionsSize | |
| 499 | + | } else if m.allSessionsSelected { | |
| 500 | + | // Sessions are explicitly selected | |
| 501 | + | total += m.SessionsSize | |
| 502 | + | } | |
| 331 | 503 | return total | |
| 332 | 504 | } | |
| 505 | + | ||
| 506 | + | // SelectedSessionIDs returns the IDs of all sessions if sessions are selected. | |
| 507 | + | // Returns nil if Data is selected (sessions are deleted with the database) | |
| 508 | + | // or if sessions are not selected. | |
| 509 | + | func (m PurgeModel) SelectedSessionIDs() []entity.SessionID { | |
| 510 | + | if m.isDataSelected() { | |
| 511 | + | return nil | |
| 512 | + | } | |
| 513 | + | if !m.allSessionsSelected { | |
| 514 | + | return nil | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | ids := make([]entity.SessionID, 0, len(m.Sessions)) | |
| 518 | + | for _, s := range m.Sessions { | |
| 519 | + | ids = append(ids, s.Info.Session.ID) | |
| 520 | + | } | |
| 521 | + | return ids | |
| 522 | + | } | |
A internal/cli/styles/purge_test.go +247 -0
| @@ -0,0 +1,247 @@ | |||
| 1 | + | package styles_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func testTheme() *styles.Theme { | |
| 15 | + | return styles.NewTheme(config.DefaultConfig()) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestPurgeModel_SessionsDisabledWhenDataSelected(t *testing.T) { | |
| 19 | + | theme := testTheme() | |
| 20 | + | ||
| 21 | + | targets := []entity.PurgeTarget{ | |
| 22 | + | {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100}, | |
| 23 | + | {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 1000}, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | endedAt := time.Now().Add(-time.Hour) | |
| 27 | + | sessions := []entity.SessionPurgeItem{ | |
| 28 | + | { | |
| 29 | + | Info: entity.SessionInfo{ | |
| 30 | + | Session: &entity.Session{ | |
| 31 | + | ID: "session1", | |
| 32 | + | Type: entity.SessionTypeBrowser, | |
| 33 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 34 | + | EndedAt: &endedAt, | |
| 35 | + | }, | |
| 36 | + | TabCount: 2, | |
| 37 | + | PaneCount: 3, | |
| 38 | + | }, | |
| 39 | + | Selected: true, | |
| 40 | + | }, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | |
| 44 | + | ||
| 45 | + | // Initially Data is selected (default for existing items) | |
| 46 | + | assert.False(t, m.SessionsEnabled(), "sessions should be disabled when Data is selected") | |
| 47 | + | ||
| 48 | + | // Deselect Data | |
| 49 | + | m.Items[1].Selected = false | |
| 50 | + | assert.True(t, m.SessionsEnabled(), "sessions should be enabled when Data is not selected") | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T) { | |
| 54 | + | theme := testTheme() | |
| 55 | + | ||
| 56 | + | targets := []entity.PurgeTarget{ | |
| 57 | + | {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 1000}, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | endedAt := time.Now().Add(-time.Hour) | |
| 61 | + | sessions := []entity.SessionPurgeItem{ | |
| 62 | + | { | |
| 63 | + | Info: entity.SessionInfo{ | |
| 64 | + | Session: &entity.Session{ | |
| 65 | + | ID: "session1", | |
| 66 | + | Type: entity.SessionTypeBrowser, | |
| 67 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 68 | + | EndedAt: &endedAt, | |
| 69 | + | }, | |
| 70 | + | }, | |
| 71 | + | }, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | |
| 75 | + | ||
| 76 | + | // Data is selected, so sessions should return nil | |
| 77 | + | ids := m.SelectedSessionIDs() | |
| 78 | + | assert.Nil(t, ids, "should return nil when Data is selected") | |
| 79 | + | ||
| 80 | + | // Deselect Data and select all sessions | |
| 81 | + | m.Items[0].Selected = false | |
| 82 | + | m.ToggleAllSessions() // Now allSessionsSelected = true | |
| 83 | + | ids = m.SelectedSessionIDs() | |
| 84 | + | require.Len(t, ids, 1) | |
| 85 | + | assert.Equal(t, entity.SessionID("session1"), ids[0]) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) { | |
| 89 | + | theme := testTheme() | |
| 90 | + | ||
| 91 | + | targets := []entity.PurgeTarget{ | |
| 92 | + | {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100}, | |
| 93 | + | {Type: entity.PurgeTargetCache, Path: "/cache", Exists: true, Size: 200}, | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | endedAt := time.Now().Add(-time.Hour) | |
| 97 | + | sessions := []entity.SessionPurgeItem{ | |
| 98 | + | { | |
| 99 | + | Info: entity.SessionInfo{ | |
| 100 | + | Session: &entity.Session{ | |
| 101 | + | ID: "session1", | |
| 102 | + | Type: entity.SessionTypeBrowser, | |
| 103 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 104 | + | EndedAt: &endedAt, | |
| 105 | + | }, | |
| 106 | + | }, | |
| 107 | + | }, | |
| 108 | + | { | |
| 109 | + | Info: entity.SessionInfo{ | |
| 110 | + | Session: &entity.Session{ | |
| 111 | + | ID: "session2", | |
| 112 | + | Type: entity.SessionTypeBrowser, | |
| 113 | + | StartedAt: time.Now().Add(-3 * time.Hour), | |
| 114 | + | EndedAt: &endedAt, | |
| 115 | + | }, | |
| 116 | + | }, | |
| 117 | + | }, | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | |
| 121 | + | ||
| 122 | + | // 2 targets selected, sessions not selected = 2 | |
| 123 | + | assert.Equal(t, 2, m.SelectedCount()) | |
| 124 | + | ||
| 125 | + | // Select all sessions (as a single item) | |
| 126 | + | m.ToggleAllSessions() | |
| 127 | + | // 2 targets + 1 sessions item = 3 | |
| 128 | + | assert.Equal(t, 3, m.SelectedCount()) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestPurgeModel_ToggleAllSessions(t *testing.T) { | |
| 132 | + | theme := testTheme() | |
| 133 | + | ||
| 134 | + | targets := []entity.PurgeTarget{ | |
| 135 | + | {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100}, | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | endedAt := time.Now().Add(-time.Hour) | |
| 139 | + | sessions := []entity.SessionPurgeItem{ | |
| 140 | + | { | |
| 141 | + | Info: entity.SessionInfo{ | |
| 142 | + | Session: &entity.Session{ | |
| 143 | + | ID: "session1", | |
| 144 | + | Type: entity.SessionTypeBrowser, | |
| 145 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 146 | + | EndedAt: &endedAt, | |
| 147 | + | }, | |
| 148 | + | }, | |
| 149 | + | }, | |
| 150 | + | { | |
| 151 | + | Info: entity.SessionInfo{ | |
| 152 | + | Session: &entity.Session{ | |
| 153 | + | ID: "session2", | |
| 154 | + | Type: entity.SessionTypeBrowser, | |
| 155 | + | StartedAt: time.Now().Add(-3 * time.Hour), | |
| 156 | + | EndedAt: &endedAt, | |
| 157 | + | }, | |
| 158 | + | }, | |
| 159 | + | }, | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | |
| 163 | + | ||
| 164 | + | // Initially sessions are not selected | |
| 165 | + | ids := m.SelectedSessionIDs() | |
| 166 | + | assert.Nil(t, ids, "sessions should not be selected initially") | |
| 167 | + | ||
| 168 | + | // Toggle to select all | |
| 169 | + | m.ToggleAllSessions() | |
| 170 | + | ids = m.SelectedSessionIDs() | |
| 171 | + | require.Len(t, ids, 2, "all sessions should be selected after toggle") | |
| 172 | + | ||
| 173 | + | // Toggle again to deselect all | |
| 174 | + | m.ToggleAllSessions() | |
| 175 | + | ids = m.SelectedSessionIDs() | |
| 176 | + | assert.Nil(t, ids, "sessions should be deselected after second toggle") | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func TestPurgeModel_SelectedSize_IncludesAllSessionsWhenSelected(t *testing.T) { | |
| 180 | + | theme := testTheme() | |
| 181 | + | ||
| 182 | + | targets := []entity.PurgeTarget{ | |
| 183 | + | {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100}, | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | endedAt := time.Now().Add(-time.Hour) | |
| 187 | + | sessions := []entity.SessionPurgeItem{ | |
| 188 | + | { | |
| 189 | + | Info: entity.SessionInfo{ | |
| 190 | + | Session: &entity.Session{ | |
| 191 | + | ID: "session1", | |
| 192 | + | Type: entity.SessionTypeBrowser, | |
| 193 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 194 | + | EndedAt: &endedAt, | |
| 195 | + | }, | |
| 196 | + | }, | |
| 197 | + | }, | |
| 198 | + | { | |
| 199 | + | Info: entity.SessionInfo{ | |
| 200 | + | Session: &entity.Session{ | |
| 201 | + | ID: "session2", | |
| 202 | + | Type: entity.SessionTypeBrowser, | |
| 203 | + | StartedAt: time.Now().Add(-3 * time.Hour), | |
| 204 | + | EndedAt: &endedAt, | |
| 205 | + | }, | |
| 206 | + | }, | |
| 207 | + | }, | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // Total sessions size is 1000 | |
| 211 | + | m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000) | |
| 212 | + | ||
| 213 | + | // Only config selected = 100 | |
| 214 | + | assert.Equal(t, int64(100), m.SelectedSize()) | |
| 215 | + | ||
| 216 | + | // Select all sessions | |
| 217 | + | m.ToggleAllSessions() | |
| 218 | + | // 100 (config) + 1000 (all sessions) = 1100 | |
| 219 | + | assert.Equal(t, int64(1100), m.SelectedSize()) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *testing.T) { | |
| 223 | + | theme := testTheme() | |
| 224 | + | ||
| 225 | + | targets := []entity.PurgeTarget{ | |
| 226 | + | {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 100}, | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | endedAt := time.Now().Add(-time.Hour) | |
| 230 | + | sessions := []entity.SessionPurgeItem{ | |
| 231 | + | { | |
| 232 | + | Info: entity.SessionInfo{ | |
| 233 | + | Session: &entity.Session{ | |
| 234 | + | ID: "session1", | |
| 235 | + | Type: entity.SessionTypeBrowser, | |
| 236 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 237 | + | EndedAt: &endedAt, | |
| 238 | + | }, | |
| 239 | + | }, | |
| 240 | + | }, | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000) | |
| 244 | + | ||
| 245 | + | // 100 (data) + 1000 (all sessions because DB is being deleted) = 1100 | |
| 246 | + | assert.Equal(t, int64(1100), m.SelectedSize()) | |
| 247 | + | } | |
A internal/cli/styles/update.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/charmbracelet/lipgloss" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // UpdateRenderer renders update status messages with styled output. | |
| 10 | + | type UpdateRenderer struct { | |
| 11 | + | theme *Theme | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // NewUpdateRenderer creates a new update renderer with the given theme. | |
| 15 | + | func NewUpdateRenderer(theme *Theme) *UpdateRenderer { | |
| 16 | + | return &UpdateRenderer{theme: theme} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // RenderChecking renders the "checking for updates" message. | |
| 20 | + | func (*UpdateRenderer) RenderChecking(spinner string) string { | |
| 21 | + | return fmt.Sprintf("\n %s Checking for updates...\n", spinner) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // RenderUpToDate renders the "already up to date" message. | |
| 25 | + | func (r *UpdateRenderer) RenderUpToDate(version string) string { | |
| 26 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |
| 27 | + | versionStyle := r.theme.Highlight | |
| 28 | + | ||
| 29 | + | return fmt.Sprintf( | |
| 30 | + | "\n %s Already up to date (%s)\n", | |
| 31 | + | iconStyle.Render(IconCheck), | |
| 32 | + | versionStyle.Render(version), | |
| 33 | + | ) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // RenderAvailable renders the "update available" message. | |
| 37 | + | func (r *UpdateRenderer) RenderAvailable(current, latest, releaseURL string) string { | |
| 38 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 39 | + | versionStyle := r.theme.Highlight | |
| 40 | + | urlStyle := r.theme.Subtle | |
| 41 | + | ||
| 42 | + | return fmt.Sprintf( | |
| 43 | + | "\n %s Update available: %s %s %s\n %s\n", | |
| 44 | + | iconStyle.Render(IconRocket), | |
| 45 | + | versionStyle.Render(current), | |
| 46 | + | iconStyle.Render(IconArrow), | |
| 47 | + | versionStyle.Render(latest), | |
| 48 | + | urlStyle.Render(releaseURL), | |
| 49 | + | ) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // RenderDownloading renders the "downloading" message with spinner. | |
| 53 | + | func (r *UpdateRenderer) RenderDownloading(spinner, version string) string { | |
| 54 | + | versionStyle := r.theme.Highlight | |
| 55 | + | ||
| 56 | + | return fmt.Sprintf( | |
| 57 | + | "\n %s Downloading %s...\n", | |
| 58 | + | spinner, | |
| 59 | + | versionStyle.Render(version), | |
| 60 | + | ) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // RenderStaged renders the "update staged" success message. | |
| 64 | + | func (r *UpdateRenderer) RenderStaged(version string) string { | |
| 65 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |
| 66 | + | versionStyle := r.theme.Highlight | |
| 67 | + | ||
| 68 | + | return fmt.Sprintf( | |
| 69 | + | "\n %s Update %s downloaded and staged\n %s Will be applied when this command exits\n", | |
| 70 | + | iconStyle.Render(IconCheck), | |
| 71 | + | versionStyle.Render(version), | |
| 72 | + | iconStyle.Render(IconCheck), | |
| 73 | + | ) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // RenderError renders an error message. | |
| 77 | + | func (r *UpdateRenderer) RenderError(err error) string { | |
| 78 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error) | |
| 79 | + | ||
| 80 | + | return fmt.Sprintf( | |
| 81 | + | "\n %s Update failed: %v\n", | |
| 82 | + | iconStyle.Render(IconX), | |
| 83 | + | err, | |
| 84 | + | ) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // RenderCannotAutoUpdate renders the "cannot auto-update" message. | |
| 88 | + | func (r *UpdateRenderer) RenderCannotAutoUpdate(current, latest, releaseURL string) string { | |
| 89 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |
| 90 | + | versionStyle := r.theme.Highlight | |
| 91 | + | urlStyle := r.theme.Subtle | |
| 92 | + | ||
| 93 | + | return fmt.Sprintf( | |
| 94 | + | "\n %s Update available: %s %s %s\n"+ | |
| 95 | + | " %s Cannot auto-update: binary is not writable\n"+ | |
| 96 | + | " Download manually: %s\n", | |
| 97 | + | iconStyle.Render(IconRocket), | |
| 98 | + | versionStyle.Render(current), | |
| 99 | + | iconStyle.Render(IconArrow), | |
| 100 | + | versionStyle.Render(latest), | |
| 101 | + | iconStyle.Render(IconWarning), | |
| 102 | + | urlStyle.Render(releaseURL), | |
| 103 | + | ) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // RenderDevBuild renders the "dev build" skip message. | |
| 107 | + | func (r *UpdateRenderer) RenderDevBuild() string { | |
| 108 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |
| 109 | + | return fmt.Sprintf( | |
| 110 | + | "\n %s Development build - update check skipped\n", | |
| 111 | + | iconStyle.Render(IconInfo), | |
| 112 | + | ) | |
| 113 | + | } | |
A internal/domain/entity/config_key.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | // ConfigKeyInfo describes a single configuration key for schema documentation. | |
| 4 | + | type ConfigKeyInfo struct { | |
| 5 | + | // Key is the full dotted path to the config key (e.g., "appearance.color_scheme") | |
| 6 | + | Key string `json:"key"` | |
| 7 | + | ||
| 8 | + | // Type is the Go type name (e.g., "string", "int", "bool", "float64") | |
| 9 | + | Type string `json:"type"` | |
| 10 | + | ||
| 11 | + | // Default is the default value as a string representation | |
| 12 | + | Default string `json:"default"` | |
| 13 | + | ||
| 14 | + | // Description explains the purpose of this config key | |
| 15 | + | Description string `json:"description"` | |
| 16 | + | ||
| 17 | + | // Values contains valid enum values (for string enums) | |
| 18 | + | // Empty if not an enum type | |
| 19 | + | Values []string `json:"values,omitempty"` | |
| 20 | + | ||
| 21 | + | // Range describes numeric constraints (e.g., "1-72", "0.1-5.0") | |
| 22 | + | // Empty if no range constraint | |
| 23 | + | Range string `json:"range,omitempty"` | |
| 24 | + | ||
| 25 | + | // Section groups related keys (e.g., "Appearance", "Logging") | |
| 26 | + | Section string `json:"section"` | |
| 27 | + | } | |
M internal/domain/entity/purge.go +6 -0
| @@ -30,3 +30,9 @@ type PurgeResult struct { | |||
| 30 | 30 | Success bool | |
| 31 | 31 | Error error | |
| 32 | 32 | } | |
| 33 | + | ||
| 34 | + | // SessionPurgeItem represents an inactive session in the purge selection UI. | |
| 35 | + | type SessionPurgeItem struct { | |
| 36 | + | Info SessionInfo | |
| 37 | + | Selected bool | |
| 38 | + | } | |
A internal/domain/entity/session_state.go +302 -0
| @@ -0,0 +1,302 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // SessionStateVersion is the current schema version for session state. | |
| 6 | + | // Increment when making breaking changes to the serialization format. | |
| 7 | + | const SessionStateVersion = 1 | |
| 8 | + | ||
| 9 | + | // SessionState represents a complete snapshot of a browser session. | |
| 10 | + | // This is serialized to JSON and stored in the database. | |
| 11 | + | type SessionState struct { | |
| 12 | + | Version int `json:"version"` | |
| 13 | + | SessionID SessionID `json:"session_id"` | |
| 14 | + | Tabs []TabSnapshot `json:"tabs"` | |
| 15 | + | ActiveTabIndex int `json:"active_tab_index"` | |
| 16 | + | SavedAt time.Time `json:"saved_at"` | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // TabSnapshot captures the state of a single tab. | |
| 20 | + | type TabSnapshot struct { | |
| 21 | + | ID TabID `json:"id"` | |
| 22 | + | Name string `json:"name"` | |
| 23 | + | Position int `json:"position"` | |
| 24 | + | IsPinned bool `json:"is_pinned"` | |
| 25 | + | Workspace WorkspaceSnapshot `json:"workspace"` | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // WorkspaceSnapshot captures the pane tree layout. | |
| 29 | + | type WorkspaceSnapshot struct { | |
| 30 | + | ID WorkspaceID `json:"id"` | |
| 31 | + | Root *PaneNodeSnapshot `json:"root"` | |
| 32 | + | ActivePaneID PaneID `json:"active_pane_id"` | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // PaneNodeSnapshot captures a node in the pane tree. | |
| 36 | + | type PaneNodeSnapshot struct { | |
| 37 | + | ID string `json:"id"` | |
| 38 | + | Pane *PaneSnapshot `json:"pane,omitempty"` // Non-nil for leaf nodes | |
| 39 | + | Children []*PaneNodeSnapshot `json:"children,omitempty"` // Non-nil for containers | |
| 40 | + | SplitDir SplitDirection `json:"split_dir"` | |
| 41 | + | SplitRatio float64 `json:"split_ratio"` | |
| 42 | + | IsStacked bool `json:"is_stacked"` | |
| 43 | + | ActiveStackIndex int `json:"active_stack_index"` | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // PaneSnapshot captures the essential state of a pane. | |
| 47 | + | type PaneSnapshot struct { | |
| 48 | + | ID PaneID `json:"id"` | |
| 49 | + | URI string `json:"uri"` | |
| 50 | + | Title string `json:"title"` | |
| 51 | + | ZoomFactor float64 `json:"zoom_factor"` | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // SnapshotFromTabList creates a SessionState from a live TabList. | |
| 55 | + | func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState { | |
| 56 | + | if tabs == nil { | |
| 57 | + | return &SessionState{ | |
| 58 | + | Version: SessionStateVersion, | |
| 59 | + | SessionID: sessionID, | |
| 60 | + | Tabs: []TabSnapshot{}, | |
| 61 | + | SavedAt: time.Now(), | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | snapTabs := make([]TabSnapshot, 0, len(tabs.Tabs)) | |
| 66 | + | activeTabIndex := 0 | |
| 67 | + | ||
| 68 | + | for i, tab := range tabs.Tabs { | |
| 69 | + | if tab.ID == tabs.ActiveTabID { | |
| 70 | + | activeTabIndex = i | |
| 71 | + | } | |
| 72 | + | snapTabs = append(snapTabs, snapshotTab(tab)) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | return &SessionState{ | |
| 76 | + | Version: SessionStateVersion, | |
| 77 | + | SessionID: sessionID, | |
| 78 | + | Tabs: snapTabs, | |
| 79 | + | ActiveTabIndex: activeTabIndex, | |
| 80 | + | SavedAt: time.Now(), | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func snapshotTab(tab *Tab) TabSnapshot { | |
| 85 | + | return TabSnapshot{ | |
| 86 | + | ID: tab.ID, | |
| 87 | + | Name: tab.Name, | |
| 88 | + | Position: tab.Position, | |
| 89 | + | IsPinned: tab.IsPinned, | |
| 90 | + | Workspace: snapshotWorkspace(tab.Workspace), | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func snapshotWorkspace(ws *Workspace) WorkspaceSnapshot { | |
| 95 | + | if ws == nil { | |
| 96 | + | return WorkspaceSnapshot{} | |
| 97 | + | } | |
| 98 | + | return WorkspaceSnapshot{ | |
| 99 | + | ID: ws.ID, | |
| 100 | + | Root: snapshotPaneNode(ws.Root), | |
| 101 | + | ActivePaneID: ws.ActivePaneID, | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func snapshotPaneNode(node *PaneNode) *PaneNodeSnapshot { | |
| 106 | + | if node == nil { | |
| 107 | + | return nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | snapshot := &PaneNodeSnapshot{ | |
| 111 | + | ID: node.ID, | |
| 112 | + | SplitDir: node.SplitDir, | |
| 113 | + | SplitRatio: node.SplitRatio, | |
| 114 | + | IsStacked: node.IsStacked, | |
| 115 | + | ActiveStackIndex: node.ActiveStackIndex, | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | if node.Pane != nil { | |
| 119 | + | snapshot.Pane = &PaneSnapshot{ | |
| 120 | + | ID: node.Pane.ID, | |
| 121 | + | URI: node.Pane.URI, | |
| 122 | + | Title: node.Pane.Title, | |
| 123 | + | ZoomFactor: node.Pane.ZoomFactor, | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | if len(node.Children) > 0 { | |
| 128 | + | snapshot.Children = make([]*PaneNodeSnapshot, 0, len(node.Children)) | |
| 129 | + | for _, child := range node.Children { | |
| 130 | + | snapshot.Children = append(snapshot.Children, snapshotPaneNode(child)) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | return snapshot | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // SessionInfo provides summary information for the session manager UI. | |
| 138 | + | type SessionInfo struct { | |
| 139 | + | Session *Session | |
| 140 | + | State *SessionState | |
| 141 | + | TabCount int | |
| 142 | + | PaneCount int | |
| 143 | + | IsActive bool // Has active lock file | |
| 144 | + | IsCurrent bool // Is the current session | |
| 145 | + | UpdatedAt time.Time // When the state was last saved | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // CountPanes returns the total number of panes in the session state. | |
| 149 | + | func (s *SessionState) CountPanes() int { | |
| 150 | + | count := 0 | |
| 151 | + | for _, tab := range s.Tabs { | |
| 152 | + | count += countPanesInNode(tab.Workspace.Root) | |
| 153 | + | } | |
| 154 | + | return count | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func countPanesInNode(node *PaneNodeSnapshot) int { | |
| 158 | + | if node == nil { | |
| 159 | + | return 0 | |
| 160 | + | } | |
| 161 | + | if node.Pane != nil { | |
| 162 | + | return 1 | |
| 163 | + | } | |
| 164 | + | count := 0 | |
| 165 | + | for _, child := range node.Children { | |
| 166 | + | count += countPanesInNode(child) | |
| 167 | + | } | |
| 168 | + | return count | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // IDGenerator is a function that generates unique IDs. | |
| 172 | + | type IDGenerator func() string | |
| 173 | + | ||
| 174 | + | // TabListFromSnapshot reconstructs a TabList from a SessionState snapshot. | |
| 175 | + | // Generates new IDs for all entities using the provided generator. | |
| 176 | + | // This is the inverse of SnapshotFromTabList. | |
| 177 | + | func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList { | |
| 178 | + | if state == nil { | |
| 179 | + | return NewTabList() | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | tabs := NewTabList() | |
| 183 | + | ||
| 184 | + | for i, tabSnap := range state.Tabs { | |
| 185 | + | tab := tabFromSnapshot(&tabSnap, idGen) | |
| 186 | + | if tab == nil { | |
| 187 | + | continue | |
| 188 | + | } | |
| 189 | + | tabs.Tabs = append(tabs.Tabs, tab) | |
| 190 | + | tab.Position = len(tabs.Tabs) - 1 | |
| 191 | + | ||
| 192 | + | if i == state.ActiveTabIndex { | |
| 193 | + | tabs.ActiveTabID = tab.ID | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // Ensure we have an active tab | |
| 198 | + | if tabs.ActiveTabID == "" && len(tabs.Tabs) > 0 { | |
| 199 | + | tabs.ActiveTabID = tabs.Tabs[0].ID | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | return tabs | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func tabFromSnapshot(snap *TabSnapshot, idGen IDGenerator) *Tab { | |
| 206 | + | if snap == nil { | |
| 207 | + | return nil | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | ws := workspaceFromSnapshot(&snap.Workspace, idGen) | |
| 211 | + | if ws == nil { | |
| 212 | + | return nil | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | return &Tab{ | |
| 216 | + | ID: TabID(idGen()), | |
| 217 | + | Name: snap.Name, | |
| 218 | + | Workspace: ws, | |
| 219 | + | Position: snap.Position, | |
| 220 | + | IsPinned: snap.IsPinned, | |
| 221 | + | CreatedAt: time.Now(), | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func workspaceFromSnapshot(snap *WorkspaceSnapshot, idGen IDGenerator) *Workspace { | |
| 226 | + | if snap == nil { | |
| 227 | + | return nil | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | root := paneNodeFromSnapshot(snap.Root, nil, idGen) | |
| 231 | + | if root == nil { | |
| 232 | + | return nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // Find the active pane ID in the restored tree | |
| 236 | + | // We need to map the snapshot's ActivePaneID to the new pane ID | |
| 237 | + | var activePaneID PaneID | |
| 238 | + | root.Walk(func(node *PaneNode) bool { | |
| 239 | + | if node.Pane != nil { | |
| 240 | + | // Use the first pane as active if we can't find the original | |
| 241 | + | if activePaneID == "" { | |
| 242 | + | activePaneID = node.Pane.ID | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | return true | |
| 246 | + | }) | |
| 247 | + | ||
| 248 | + | return &Workspace{ | |
| 249 | + | ID: WorkspaceID(idGen()), | |
| 250 | + | Root: root, | |
| 251 | + | ActivePaneID: activePaneID, | |
| 252 | + | CreatedAt: time.Now(), | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func paneNodeFromSnapshot(snap *PaneNodeSnapshot, parent *PaneNode, idGen IDGenerator) *PaneNode { | |
| 257 | + | if snap == nil { | |
| 258 | + | return nil | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | node := &PaneNode{ | |
| 262 | + | ID: idGen(), | |
| 263 | + | Parent: parent, | |
| 264 | + | SplitDir: snap.SplitDir, | |
| 265 | + | SplitRatio: snap.SplitRatio, | |
| 266 | + | IsStacked: snap.IsStacked, | |
| 267 | + | ActiveStackIndex: snap.ActiveStackIndex, | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // Restore pane if this is a leaf node | |
| 271 | + | if snap.Pane != nil { | |
| 272 | + | node.Pane = paneFromSnapshot(snap.Pane, idGen) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // Restore children recursively | |
| 276 | + | if len(snap.Children) > 0 { | |
| 277 | + | node.Children = make([]*PaneNode, 0, len(snap.Children)) | |
| 278 | + | for _, childSnap := range snap.Children { | |
| 279 | + | child := paneNodeFromSnapshot(childSnap, node, idGen) | |
| 280 | + | if child != nil { | |
| 281 | + | node.Children = append(node.Children, child) | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | return node | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func paneFromSnapshot(snap *PaneSnapshot, idGen IDGenerator) *Pane { | |
| 290 | + | if snap == nil { | |
| 291 | + | return nil | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | return &Pane{ | |
| 295 | + | ID: PaneID(idGen()), | |
| 296 | + | URI: snap.URI, | |
| 297 | + | Title: snap.Title, | |
| 298 | + | ZoomFactor: snap.ZoomFactor, | |
| 299 | + | WindowType: WindowMain, | |
| 300 | + | CreatedAt: time.Now(), | |
| 301 | + | } | |
| 302 | + | } | |
A internal/domain/entity/session_state_test.go +430 -0
| @@ -0,0 +1,430 @@ | |||
| 1 | + | package entity_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync/atomic" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // mockIDGenerator creates a simple ID generator for testing. | |
| 14 | + | func mockIDGenerator() entity.IDGenerator { | |
| 15 | + | var counter uint64 | |
| 16 | + | return func() string { | |
| 17 | + | id := atomic.AddUint64(&counter, 1) | |
| 18 | + | return string(rune('a'-1+id)) + "_new" | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestTabListFromSnapshot_EmptyState(t *testing.T) { | |
| 23 | + | idGen := mockIDGenerator() | |
| 24 | + | ||
| 25 | + | // Nil state | |
| 26 | + | tabs := entity.TabListFromSnapshot(nil, idGen) | |
| 27 | + | require.NotNil(t, tabs) | |
| 28 | + | assert.Empty(t, tabs.Tabs) | |
| 29 | + | ||
| 30 | + | // Empty state | |
| 31 | + | state := &entity.SessionState{ | |
| 32 | + | Version: 1, | |
| 33 | + | Tabs: []entity.TabSnapshot{}, | |
| 34 | + | } | |
| 35 | + | tabs = entity.TabListFromSnapshot(state, idGen) | |
| 36 | + | require.NotNil(t, tabs) | |
| 37 | + | assert.Empty(t, tabs.Tabs) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestTabListFromSnapshot_SingleTab(t *testing.T) { | |
| 41 | + | idGen := mockIDGenerator() | |
| 42 | + | ||
| 43 | + | state := &entity.SessionState{ | |
| 44 | + | Version: 1, | |
| 45 | + | SessionID: "test_session", | |
| 46 | + | ActiveTabIndex: 0, | |
| 47 | + | Tabs: []entity.TabSnapshot{ | |
| 48 | + | { | |
| 49 | + | ID: "old_tab_1", | |
| 50 | + | Name: "Test Tab", | |
| 51 | + | Position: 0, | |
| 52 | + | IsPinned: true, | |
| 53 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 54 | + | ID: "old_ws_1", | |
| 55 | + | ActivePaneID: "old_pane_1", | |
| 56 | + | Root: &entity.PaneNodeSnapshot{ | |
| 57 | + | ID: "old_node_1", | |
| 58 | + | Pane: &entity.PaneSnapshot{ | |
| 59 | + | ID: "old_pane_1", | |
| 60 | + | URI: "https://example.com", | |
| 61 | + | Title: "Example", | |
| 62 | + | ZoomFactor: 1.25, | |
| 63 | + | }, | |
| 64 | + | }, | |
| 65 | + | }, | |
| 66 | + | }, | |
| 67 | + | }, | |
| 68 | + | SavedAt: time.Now(), | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | tabs := entity.TabListFromSnapshot(state, idGen) | |
| 72 | + | ||
| 73 | + | require.NotNil(t, tabs) | |
| 74 | + | require.Len(t, tabs.Tabs, 1) | |
| 75 | + | ||
| 76 | + | tab := tabs.Tabs[0] | |
| 77 | + | assert.NotEqual(t, entity.TabID("old_tab_1"), tab.ID, "should generate new ID") | |
| 78 | + | assert.Equal(t, "Test Tab", tab.Name) | |
| 79 | + | assert.True(t, tab.IsPinned) | |
| 80 | + | ||
| 81 | + | require.NotNil(t, tab.Workspace) | |
| 82 | + | require.NotNil(t, tab.Workspace.Root) | |
| 83 | + | require.NotNil(t, tab.Workspace.Root.Pane) | |
| 84 | + | ||
| 85 | + | pane := tab.Workspace.Root.Pane | |
| 86 | + | assert.NotEqual(t, entity.PaneID("old_pane_1"), pane.ID, "should generate new pane ID") | |
| 87 | + | assert.Equal(t, "https://example.com", pane.URI) | |
| 88 | + | assert.Equal(t, "Example", pane.Title) | |
| 89 | + | assert.InDelta(t, 1.25, pane.ZoomFactor, 0.001) | |
| 90 | + | ||
| 91 | + | // Active tab should be set | |
| 92 | + | assert.Equal(t, tab.ID, tabs.ActiveTabID) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestTabListFromSnapshot_MultipleTabs(t *testing.T) { | |
| 96 | + | idGen := mockIDGenerator() | |
| 97 | + | ||
| 98 | + | state := &entity.SessionState{ | |
| 99 | + | Version: 1, | |
| 100 | + | ActiveTabIndex: 1, // Second tab is active | |
| 101 | + | Tabs: []entity.TabSnapshot{ | |
| 102 | + | { | |
| 103 | + | ID: "tab_1", | |
| 104 | + | Name: "First", | |
| 105 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 106 | + | Root: &entity.PaneNodeSnapshot{ | |
| 107 | + | Pane: &entity.PaneSnapshot{ | |
| 108 | + | ID: "pane_1", | |
| 109 | + | URI: "https://first.com", | |
| 110 | + | }, | |
| 111 | + | }, | |
| 112 | + | }, | |
| 113 | + | }, | |
| 114 | + | { | |
| 115 | + | ID: "tab_2", | |
| 116 | + | Name: "Second", | |
| 117 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 118 | + | Root: &entity.PaneNodeSnapshot{ | |
| 119 | + | Pane: &entity.PaneSnapshot{ | |
| 120 | + | ID: "pane_2", | |
| 121 | + | URI: "https://second.com", | |
| 122 | + | }, | |
| 123 | + | }, | |
| 124 | + | }, | |
| 125 | + | }, | |
| 126 | + | }, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | tabs := entity.TabListFromSnapshot(state, idGen) | |
| 130 | + | ||
| 131 | + | require.Len(t, tabs.Tabs, 2) | |
| 132 | + | assert.Equal(t, "First", tabs.Tabs[0].Name) | |
| 133 | + | assert.Equal(t, "Second", tabs.Tabs[1].Name) | |
| 134 | + | ||
| 135 | + | // Active tab should be the second one | |
| 136 | + | assert.Equal(t, tabs.Tabs[1].ID, tabs.ActiveTabID) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestTabListFromSnapshot_SplitPanes(t *testing.T) { | |
| 140 | + | idGen := mockIDGenerator() | |
| 141 | + | ||
| 142 | + | // Horizontal split with two panes | |
| 143 | + | state := &entity.SessionState{ | |
| 144 | + | Version: 1, | |
| 145 | + | Tabs: []entity.TabSnapshot{ | |
| 146 | + | { | |
| 147 | + | ID: "tab_1", | |
| 148 | + | Name: "Split Tab", | |
| 149 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 150 | + | Root: &entity.PaneNodeSnapshot{ | |
| 151 | + | ID: "container", | |
| 152 | + | SplitDir: entity.SplitHorizontal, | |
| 153 | + | SplitRatio: 0.5, | |
| 154 | + | Children: []*entity.PaneNodeSnapshot{ | |
| 155 | + | { | |
| 156 | + | ID: "left_node", | |
| 157 | + | Pane: &entity.PaneSnapshot{ | |
| 158 | + | ID: "left_pane", | |
| 159 | + | URI: "https://left.com", | |
| 160 | + | }, | |
| 161 | + | }, | |
| 162 | + | { | |
| 163 | + | ID: "right_node", | |
| 164 | + | Pane: &entity.PaneSnapshot{ | |
| 165 | + | ID: "right_pane", | |
| 166 | + | URI: "https://right.com", | |
| 167 | + | }, | |
| 168 | + | }, | |
| 169 | + | }, | |
| 170 | + | }, | |
| 171 | + | }, | |
| 172 | + | }, | |
| 173 | + | }, | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | tabs := entity.TabListFromSnapshot(state, idGen) | |
| 177 | + | ||
| 178 | + | require.Len(t, tabs.Tabs, 1) | |
| 179 | + | ws := tabs.Tabs[0].Workspace | |
| 180 | + | require.NotNil(t, ws.Root) | |
| 181 | + | ||
| 182 | + | // Root should be a container with children | |
| 183 | + | assert.Nil(t, ws.Root.Pane) | |
| 184 | + | assert.Equal(t, entity.SplitHorizontal, ws.Root.SplitDir) | |
| 185 | + | assert.InDelta(t, 0.5, ws.Root.SplitRatio, 0.001) | |
| 186 | + | require.Len(t, ws.Root.Children, 2) | |
| 187 | + | ||
| 188 | + | // Check children | |
| 189 | + | leftPane := ws.Root.Children[0].Pane | |
| 190 | + | rightPane := ws.Root.Children[1].Pane | |
| 191 | + | require.NotNil(t, leftPane) | |
| 192 | + | require.NotNil(t, rightPane) | |
| 193 | + | assert.Equal(t, "https://left.com", leftPane.URI) | |
| 194 | + | assert.Equal(t, "https://right.com", rightPane.URI) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestTabListFromSnapshot_StackedPanes(t *testing.T) { | |
| 198 | + | idGen := mockIDGenerator() | |
| 199 | + | ||
| 200 | + | state := &entity.SessionState{ | |
| 201 | + | Version: 1, | |
| 202 | + | Tabs: []entity.TabSnapshot{ | |
| 203 | + | { | |
| 204 | + | ID: "tab_1", | |
| 205 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 206 | + | Root: &entity.PaneNodeSnapshot{ | |
| 207 | + | ID: "stack", | |
| 208 | + | IsStacked: true, | |
| 209 | + | ActiveStackIndex: 1, | |
| 210 | + | Children: []*entity.PaneNodeSnapshot{ | |
| 211 | + | {Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://a.com"}}, | |
| 212 | + | {Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://b.com"}}, | |
| 213 | + | {Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://c.com"}}, | |
| 214 | + | }, | |
| 215 | + | }, | |
| 216 | + | }, | |
| 217 | + | }, | |
| 218 | + | }, | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | tabs := entity.TabListFromSnapshot(state, idGen) | |
| 222 | + | ||
| 223 | + | ws := tabs.Tabs[0].Workspace | |
| 224 | + | require.NotNil(t, ws.Root) | |
| 225 | + | assert.True(t, ws.Root.IsStacked) | |
| 226 | + | assert.Equal(t, 1, ws.Root.ActiveStackIndex) | |
| 227 | + | require.Len(t, ws.Root.Children, 3) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestTabList_ReplaceFrom(t *testing.T) { | |
| 231 | + | // Create original list | |
| 232 | + | original := entity.NewTabList() | |
| 233 | + | pane1 := entity.NewPane("pane1") | |
| 234 | + | pane1.URI = "https://old.com" | |
| 235 | + | tab1 := entity.NewTab("tab1", "ws1", pane1) | |
| 236 | + | original.Add(tab1) | |
| 237 | + | original.SetActive("tab1") | |
| 238 | + | ||
| 239 | + | // Create new list | |
| 240 | + | newList := entity.NewTabList() | |
| 241 | + | pane2 := entity.NewPane("pane2") | |
| 242 | + | pane2.URI = "https://new1.com" | |
| 243 | + | pane3 := entity.NewPane("pane3") | |
| 244 | + | pane3.URI = "https://new2.com" | |
| 245 | + | tab2 := entity.NewTab("tab2", "ws2", pane2) | |
| 246 | + | tab3 := entity.NewTab("tab3", "ws3", pane3) | |
| 247 | + | newList.Add(tab2) | |
| 248 | + | newList.Add(tab3) | |
| 249 | + | newList.SetActive("tab3") | |
| 250 | + | ||
| 251 | + | // Keep reference to original | |
| 252 | + | ref := original | |
| 253 | + | ||
| 254 | + | // Replace contents | |
| 255 | + | original.ReplaceFrom(newList) | |
| 256 | + | ||
| 257 | + | // Verify the reference still points to the modified list | |
| 258 | + | assert.Same(t, original, ref) | |
| 259 | + | assert.Len(t, ref.Tabs, 2) | |
| 260 | + | assert.Equal(t, entity.TabID("tab3"), ref.ActiveTabID) | |
| 261 | + | assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestTabList_ReplaceFrom_Nil(t *testing.T) { | |
| 265 | + | original := entity.NewTabList() | |
| 266 | + | pane := entity.NewPane("pane1") | |
| 267 | + | tab := entity.NewTab("tab1", "ws1", pane) | |
| 268 | + | original.Add(tab) | |
| 269 | + | ||
| 270 | + | original.ReplaceFrom(nil) | |
| 271 | + | ||
| 272 | + | assert.Empty(t, original.Tabs) | |
| 273 | + | assert.Empty(t, original.ActiveTabID) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func TestSnapshotRoundTrip(t *testing.T) { | |
| 277 | + | // Create a complex tab structure | |
| 278 | + | idGen := mockIDGenerator() | |
| 279 | + | ||
| 280 | + | pane1 := entity.NewPane("p1") | |
| 281 | + | pane1.URI = "https://google.com" | |
| 282 | + | pane1.Title = "Google" | |
| 283 | + | pane1.ZoomFactor = 1.5 | |
| 284 | + | ||
| 285 | + | pane2 := entity.NewPane("p2") | |
| 286 | + | pane2.URI = "https://github.com" | |
| 287 | + | pane2.Title = "GitHub" | |
| 288 | + | ||
| 289 | + | tab1 := entity.NewTab("t1", "ws1", pane1) | |
| 290 | + | tab1.Name = "Search" | |
| 291 | + | tab1.IsPinned = true | |
| 292 | + | ||
| 293 | + | tab2 := entity.NewTab("t2", "ws2", pane2) | |
| 294 | + | tab2.Name = "Code" | |
| 295 | + | ||
| 296 | + | original := entity.NewTabList() | |
| 297 | + | original.Add(tab1) | |
| 298 | + | original.Add(tab2) | |
| 299 | + | original.SetActive("t2") | |
| 300 | + | ||
| 301 | + | // Snapshot | |
| 302 | + | sessionID := entity.SessionID("test_session") | |
| 303 | + | state := entity.SnapshotFromTabList(sessionID, original) | |
| 304 | + | ||
| 305 | + | require.NotNil(t, state) | |
| 306 | + | assert.Equal(t, sessionID, state.SessionID) | |
| 307 | + | assert.Len(t, state.Tabs, 2) | |
| 308 | + | assert.Equal(t, 1, state.ActiveTabIndex) // Second tab active | |
| 309 | + | ||
| 310 | + | // Restore | |
| 311 | + | restored := entity.TabListFromSnapshot(state, idGen) | |
| 312 | + | ||
| 313 | + | require.NotNil(t, restored) | |
| 314 | + | require.Len(t, restored.Tabs, 2) | |
| 315 | + | ||
| 316 | + | // Check first tab | |
| 317 | + | restoredTab1 := restored.Tabs[0] | |
| 318 | + | assert.Equal(t, "Search", restoredTab1.Name) | |
| 319 | + | assert.True(t, restoredTab1.IsPinned) | |
| 320 | + | require.NotNil(t, restoredTab1.Workspace.Root.Pane) | |
| 321 | + | assert.Equal(t, "https://google.com", restoredTab1.Workspace.Root.Pane.URI) | |
| 322 | + | assert.Equal(t, "Google", restoredTab1.Workspace.Root.Pane.Title) | |
| 323 | + | assert.InDelta(t, 1.5, restoredTab1.Workspace.Root.Pane.ZoomFactor, 0.001) | |
| 324 | + | ||
| 325 | + | // Check second tab | |
| 326 | + | restoredTab2 := restored.Tabs[1] | |
| 327 | + | assert.Equal(t, "Code", restoredTab2.Name) | |
| 328 | + | assert.False(t, restoredTab2.IsPinned) | |
| 329 | + | assert.Equal(t, "https://github.com", restoredTab2.Workspace.Root.Pane.URI) | |
| 330 | + | ||
| 331 | + | // Active tab should be second | |
| 332 | + | assert.Equal(t, restoredTab2.ID, restored.ActiveTabID) | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func TestFindPaneAcrossTabs(t *testing.T) { | |
| 336 | + | // Create tabs with panes | |
| 337 | + | pane1 := entity.NewPane("pane_in_tab1") | |
| 338 | + | pane1.URI = "https://tab1.com" | |
| 339 | + | tab1 := entity.NewTab("t1", "ws1", pane1) | |
| 340 | + | ||
| 341 | + | pane2 := entity.NewPane("pane_in_tab2") | |
| 342 | + | pane2.URI = "https://tab2.com" | |
| 343 | + | tab2 := entity.NewTab("t2", "ws2", pane2) | |
| 344 | + | ||
| 345 | + | tabs := entity.NewTabList() | |
| 346 | + | tabs.Add(tab1) | |
| 347 | + | tabs.Add(tab2) | |
| 348 | + | ||
| 349 | + | // Helper function to find pane across all tabs (mirrors App.updatePaneURIInAllTabs logic) | |
| 350 | + | findPaneInTabs := func(tabs *entity.TabList, paneID entity.PaneID) *entity.Pane { | |
| 351 | + | for _, tab := range tabs.Tabs { | |
| 352 | + | if tab.Workspace == nil { | |
| 353 | + | continue | |
| 354 | + | } | |
| 355 | + | paneNode := tab.Workspace.FindPane(paneID) | |
| 356 | + | if paneNode != nil && paneNode.Pane != nil { | |
| 357 | + | return paneNode.Pane | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | return nil | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // Find pane in first tab | |
| 364 | + | found := findPaneInTabs(tabs, "pane_in_tab1") | |
| 365 | + | require.NotNil(t, found) | |
| 366 | + | assert.Equal(t, "https://tab1.com", found.URI) | |
| 367 | + | ||
| 368 | + | // Find pane in second tab | |
| 369 | + | found = findPaneInTabs(tabs, "pane_in_tab2") | |
| 370 | + | require.NotNil(t, found) | |
| 371 | + | assert.Equal(t, "https://tab2.com", found.URI) | |
| 372 | + | ||
| 373 | + | // Update URI and verify | |
| 374 | + | found.URI = "https://updated.com" | |
| 375 | + | foundAgain := findPaneInTabs(tabs, "pane_in_tab2") | |
| 376 | + | assert.Equal(t, "https://updated.com", foundAgain.URI) | |
| 377 | + | ||
| 378 | + | // Non-existent pane | |
| 379 | + | notFound := findPaneInTabs(tabs, "non_existent") | |
| 380 | + | assert.Nil(t, notFound) | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func TestFindPaneInNestedStructure(t *testing.T) { | |
| 384 | + | // Create a tab with split panes | |
| 385 | + | pane1 := entity.NewPane("left_pane") | |
| 386 | + | pane1.URI = "https://left.com" | |
| 387 | + | ||
| 388 | + | pane2 := entity.NewPane("right_pane") | |
| 389 | + | pane2.URI = "https://right.com" | |
| 390 | + | ||
| 391 | + | // Build a split structure manually | |
| 392 | + | tab := entity.NewTab("t1", "ws1", pane1) | |
| 393 | + | ||
| 394 | + | // Add second pane as sibling (simulating a horizontal split) | |
| 395 | + | rightNode := &entity.PaneNode{ | |
| 396 | + | ID: "right_node", | |
| 397 | + | Pane: pane2, | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // Create container node | |
| 401 | + | containerNode := &entity.PaneNode{ | |
| 402 | + | ID: "container", | |
| 403 | + | SplitDir: entity.SplitHorizontal, | |
| 404 | + | Children: []*entity.PaneNode{ | |
| 405 | + | tab.Workspace.Root, // left pane | |
| 406 | + | rightNode, // right pane | |
| 407 | + | }, | |
| 408 | + | } | |
| 409 | + | tab.Workspace.Root = containerNode | |
| 410 | + | ||
| 411 | + | tabs := entity.NewTabList() | |
| 412 | + | tabs.Add(tab) | |
| 413 | + | ||
| 414 | + | // Find left pane | |
| 415 | + | leftNode := tab.Workspace.FindPane("left_pane") | |
| 416 | + | require.NotNil(t, leftNode) | |
| 417 | + | assert.Equal(t, "https://left.com", leftNode.Pane.URI) | |
| 418 | + | ||
| 419 | + | // Find right pane | |
| 420 | + | rightNodeFound := tab.Workspace.FindPane("right_pane") | |
| 421 | + | require.NotNil(t, rightNodeFound) | |
| 422 | + | assert.Equal(t, "https://right.com", rightNodeFound.Pane.URI) | |
| 423 | + | ||
| 424 | + | // Update URI on nested pane | |
| 425 | + | rightNodeFound.Pane.URI = "https://right-updated.com" | |
| 426 | + | ||
| 427 | + | // Verify update persisted | |
| 428 | + | rightNodeAgain := tab.Workspace.FindPane("right_pane") | |
| 429 | + | assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI) | |
| 430 | + | } | |
M internal/domain/entity/tab.go +23 -4
| @@ -79,11 +79,16 @@ func (tl *TabList) Remove(id TabID) bool { | |||
| 79 | 79 | tl.Tabs[j].Position = j | |
| 80 | 80 | } | |
| 81 | 81 | // Update active tab if needed | |
| 82 | - | if tl.ActiveTabID == id && len(tl.Tabs) > 0 { | |
| 83 | - | if i < len(tl.Tabs) { | |
| 84 | - | tl.ActiveTabID = tl.Tabs[i].ID | |
| 82 | + | if tl.ActiveTabID == id { | |
| 83 | + | if len(tl.Tabs) > 0 { | |
| 84 | + | if i < len(tl.Tabs) { | |
| 85 | + | tl.ActiveTabID = tl.Tabs[i].ID | |
| 86 | + | } else { | |
| 87 | + | tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID | |
| 88 | + | } | |
| 85 | 89 | } else { | |
| 86 | - | tl.ActiveTabID = tl.Tabs[len(tl.Tabs)-1].ID | |
| 90 | + | tl.ActiveTabID = "" | |
| 91 | + | tl.PreviousActiveTabID = "" | |
| 87 | 92 | } | |
| 88 | 93 | } | |
| 89 | 94 | return true | |
| @@ -155,3 +160,17 @@ func (tl *TabList) Move(id TabID, newPos int) bool { | |||
| 155 | 160 | } | |
| 156 | 161 | return true | |
| 157 | 162 | } | |
| 163 | + | ||
| 164 | + | // ReplaceFrom replaces this TabList's contents with those from another TabList. | |
| 165 | + | // This modifies in-place so existing references to this TabList remain valid. | |
| 166 | + | func (tl *TabList) ReplaceFrom(other *TabList) { | |
| 167 | + | if other == nil { | |
| 168 | + | tl.Tabs = make([]*Tab, 0) | |
| 169 | + | tl.ActiveTabID = "" | |
| 170 | + | tl.PreviousActiveTabID = "" | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | tl.Tabs = other.Tabs | |
| 174 | + | tl.ActiveTabID = other.ActiveTabID | |
| 175 | + | tl.PreviousActiveTabID = other.PreviousActiveTabID | |
| 176 | + | } | |
A internal/domain/entity/update.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | // Package entity defines domain entities for dumber. | |
| 2 | + | package entity | |
| 3 | + | ||
| 4 | + | import "time" | |
| 5 | + | ||
| 6 | + | // UpdateInfo holds information about an available update. | |
| 7 | + | type UpdateInfo struct { | |
| 8 | + | // CurrentVersion is the version of the running binary. | |
| 9 | + | CurrentVersion string | |
| 10 | + | // LatestVersion is the latest available version from GitHub. | |
| 11 | + | LatestVersion string | |
| 12 | + | // IsNewer is true if LatestVersion is newer than CurrentVersion. | |
| 13 | + | IsNewer bool | |
| 14 | + | // ReleaseURL is the URL to the GitHub release page. | |
| 15 | + | ReleaseURL string | |
| 16 | + | // DownloadURL is the direct download URL for the binary archive. | |
| 17 | + | DownloadURL string | |
| 18 | + | // PublishedAt is when the release was published. | |
| 19 | + | PublishedAt time.Time | |
| 20 | + | // ReleaseNotes contains the release changelog (optional). | |
| 21 | + | ReleaseNotes string | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // UpdateStatus represents the current state of the update process. | |
| 25 | + | type UpdateStatus int | |
| 26 | + | ||
| 27 | + | const ( | |
| 28 | + | // UpdateStatusUnknown means update status hasn't been checked yet. | |
| 29 | + | UpdateStatusUnknown UpdateStatus = iota | |
| 30 | + | // UpdateStatusUpToDate means the current version is the latest. | |
| 31 | + | UpdateStatusUpToDate | |
| 32 | + | // UpdateStatusAvailable means a newer version is available. | |
| 33 | + | UpdateStatusAvailable | |
| 34 | + | // UpdateStatusDownloading means the update is being downloaded. | |
| 35 | + | UpdateStatusDownloading | |
| 36 | + | // UpdateStatusReady means the update is downloaded and staged for exit. | |
| 37 | + | UpdateStatusReady | |
| 38 | + | // UpdateStatusFailed means the update check or download failed. | |
| 39 | + | UpdateStatusFailed | |
| 40 | + | ) | |
| 41 | + | ||
| 42 | + | // String returns a human-readable string for the update status. | |
| 43 | + | func (s UpdateStatus) String() string { | |
| 44 | + | switch s { | |
| 45 | + | case UpdateStatusUnknown: | |
| 46 | + | return "unknown" | |
| 47 | + | case UpdateStatusUpToDate: | |
| 48 | + | return "up-to-date" | |
| 49 | + | case UpdateStatusAvailable: | |
| 50 | + | return "available" | |
| 51 | + | case UpdateStatusDownloading: | |
| 52 | + | return "downloading" | |
| 53 | + | case UpdateStatusReady: | |
| 54 | + | return "ready" | |
| 55 | + | case UpdateStatusFailed: | |
| 56 | + | return "failed" | |
| 57 | + | default: | |
| 58 | + | return "unknown" | |
| 59 | + | } | |
| 60 | + | } | |
M internal/domain/repository/history.go +14 -0
| @@ -21,6 +21,20 @@ type HistoryRepository interface { | |||
| 21 | 21 | // GetRecent retrieves recent history entries with pagination. | |
| 22 | 22 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 23 | 23 | ||
| 24 | + | // GetRecentSince retrieves history entries visited within the last N days. | |
| 25 | + | // days must be > 0. | |
| 26 | + | GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | |
| 27 | + | ||
| 28 | + | // GetMostVisited retrieves history entries sorted by visit count within the last N days. | |
| 29 | + | // days must be > 0. | |
| 30 | + | GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | |
| 31 | + | ||
| 32 | + | // GetAllRecentHistory retrieves all history entries sorted by recency. | |
| 33 | + | GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) | |
| 34 | + | ||
| 35 | + | // GetAllMostVisited retrieves all history entries sorted by visit count. | |
| 36 | + | GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) | |
| 37 | + | ||
| 24 | 38 | // IncrementVisitCount increments the visit count for a URL. | |
| 25 | 39 | IncrementVisitCount(ctx context.Context, url string) error | |
| 26 | 40 | ||
M internal/domain/repository/mocks/mocks.go +827 -47
| @@ -2197,6 +2197,130 @@ func (_c *MockHistoryRepository_FindByURL_Call) RunAndReturn(run func(ctx contex | |||
| 2197 | 2197 | return _c | |
| 2198 | 2198 | } | |
| 2199 | 2199 | ||
| 2200 | + | // GetAllMostVisited provides a mock function for the type MockHistoryRepository | |
| 2201 | + | func (_mock *MockHistoryRepository) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) { | |
| 2202 | + | ret := _mock.Called(ctx) | |
| 2203 | + | ||
| 2204 | + | if len(ret) == 0 { | |
| 2205 | + | panic("no return value specified for GetAllMostVisited") | |
| 2206 | + | } | |
| 2207 | + | ||
| 2208 | + | var r0 []*entity.HistoryEntry | |
| 2209 | + | var r1 error | |
| 2210 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.HistoryEntry, error)); ok { | |
| 2211 | + | return returnFunc(ctx) | |
| 2212 | + | } | |
| 2213 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.HistoryEntry); ok { | |
| 2214 | + | r0 = returnFunc(ctx) | |
| 2215 | + | } else { | |
| 2216 | + | if ret.Get(0) != nil { | |
| 2217 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |
| 2218 | + | } | |
| 2219 | + | } | |
| 2220 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 2221 | + | r1 = returnFunc(ctx) | |
| 2222 | + | } else { | |
| 2223 | + | r1 = ret.Error(1) | |
| 2224 | + | } | |
| 2225 | + | return r0, r1 | |
| 2226 | + | } | |
| 2227 | + | ||
| 2228 | + | // MockHistoryRepository_GetAllMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllMostVisited' | |
| 2229 | + | type MockHistoryRepository_GetAllMostVisited_Call struct { | |
| 2230 | + | *mock.Call | |
| 2231 | + | } | |
| 2232 | + | ||
| 2233 | + | // GetAllMostVisited is a helper method to define mock.On call | |
| 2234 | + | // - ctx context.Context | |
| 2235 | + | func (_e *MockHistoryRepository_Expecter) GetAllMostVisited(ctx interface{}) *MockHistoryRepository_GetAllMostVisited_Call { | |
| 2236 | + | return &MockHistoryRepository_GetAllMostVisited_Call{Call: _e.mock.On("GetAllMostVisited", ctx)} | |
| 2237 | + | } | |
| 2238 | + | ||
| 2239 | + | func (_c *MockHistoryRepository_GetAllMostVisited_Call) Run(run func(ctx context.Context)) *MockHistoryRepository_GetAllMostVisited_Call { | |
| 2240 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2241 | + | var arg0 context.Context | |
| 2242 | + | if args[0] != nil { | |
| 2243 | + | arg0 = args[0].(context.Context) | |
| 2244 | + | } | |
| 2245 | + | run( | |
| 2246 | + | arg0, | |
| 2247 | + | ) | |
| 2248 | + | }) | |
| 2249 | + | return _c | |
| 2250 | + | } | |
| 2251 | + | ||
| 2252 | + | func (_c *MockHistoryRepository_GetAllMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetAllMostVisited_Call { | |
| 2253 | + | _c.Call.Return(historyEntrys, err) | |
| 2254 | + | return _c | |
| 2255 | + | } | |
| 2256 | + | ||
| 2257 | + | func (_c *MockHistoryRepository_GetAllMostVisited_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetAllMostVisited_Call { | |
| 2258 | + | _c.Call.Return(run) | |
| 2259 | + | return _c | |
| 2260 | + | } | |
| 2261 | + | ||
| 2262 | + | // GetAllRecentHistory provides a mock function for the type MockHistoryRepository | |
| 2263 | + | func (_mock *MockHistoryRepository) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) { | |
| 2264 | + | ret := _mock.Called(ctx) | |
| 2265 | + | ||
| 2266 | + | if len(ret) == 0 { | |
| 2267 | + | panic("no return value specified for GetAllRecentHistory") | |
| 2268 | + | } | |
| 2269 | + | ||
| 2270 | + | var r0 []*entity.HistoryEntry | |
| 2271 | + | var r1 error | |
| 2272 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.HistoryEntry, error)); ok { | |
| 2273 | + | return returnFunc(ctx) | |
| 2274 | + | } | |
| 2275 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.HistoryEntry); ok { | |
| 2276 | + | r0 = returnFunc(ctx) | |
| 2277 | + | } else { | |
| 2278 | + | if ret.Get(0) != nil { | |
| 2279 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |
| 2280 | + | } | |
| 2281 | + | } | |
| 2282 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 2283 | + | r1 = returnFunc(ctx) | |
| 2284 | + | } else { | |
| 2285 | + | r1 = ret.Error(1) | |
| 2286 | + | } | |
| 2287 | + | return r0, r1 | |
| 2288 | + | } | |
| 2289 | + | ||
| 2290 | + | // MockHistoryRepository_GetAllRecentHistory_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllRecentHistory' | |
| 2291 | + | type MockHistoryRepository_GetAllRecentHistory_Call struct { | |
| 2292 | + | *mock.Call | |
| 2293 | + | } | |
| 2294 | + | ||
| 2295 | + | // GetAllRecentHistory is a helper method to define mock.On call | |
| 2296 | + | // - ctx context.Context | |
| 2297 | + | func (_e *MockHistoryRepository_Expecter) GetAllRecentHistory(ctx interface{}) *MockHistoryRepository_GetAllRecentHistory_Call { | |
| 2298 | + | return &MockHistoryRepository_GetAllRecentHistory_Call{Call: _e.mock.On("GetAllRecentHistory", ctx)} | |
| 2299 | + | } | |
| 2300 | + | ||
| 2301 | + | func (_c *MockHistoryRepository_GetAllRecentHistory_Call) Run(run func(ctx context.Context)) *MockHistoryRepository_GetAllRecentHistory_Call { | |
| 2302 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2303 | + | var arg0 context.Context | |
| 2304 | + | if args[0] != nil { | |
| 2305 | + | arg0 = args[0].(context.Context) | |
| 2306 | + | } | |
| 2307 | + | run( | |
| 2308 | + | arg0, | |
| 2309 | + | ) | |
| 2310 | + | }) | |
| 2311 | + | return _c | |
| 2312 | + | } | |
| 2313 | + | ||
| 2314 | + | func (_c *MockHistoryRepository_GetAllRecentHistory_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetAllRecentHistory_Call { | |
| 2315 | + | _c.Call.Return(historyEntrys, err) | |
| 2316 | + | return _c | |
| 2317 | + | } | |
| 2318 | + | ||
| 2319 | + | func (_c *MockHistoryRepository_GetAllRecentHistory_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetAllRecentHistory_Call { | |
| 2320 | + | _c.Call.Return(run) | |
| 2321 | + | return _c | |
| 2322 | + | } | |
| 2323 | + | ||
| 2200 | 2324 | // GetDailyVisitCount provides a mock function for the type MockHistoryRepository | |
| 2201 | 2325 | func (_mock *MockHistoryRepository) GetDailyVisitCount(ctx context.Context, daysAgo string) ([]*entity.DailyVisitCount, error) { | |
| 2202 | 2326 | ret := _mock.Called(ctx, daysAgo) | |
| @@ -2395,6 +2519,74 @@ func (_c *MockHistoryRepository_GetHourlyDistribution_Call) RunAndReturn(run fun | |||
| 2395 | 2519 | return _c | |
| 2396 | 2520 | } | |
| 2397 | 2521 | ||
| 2522 | + | // GetMostVisited provides a mock function for the type MockHistoryRepository | |
| 2523 | + | func (_mock *MockHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 2524 | + | ret := _mock.Called(ctx, days) | |
| 2525 | + | ||
| 2526 | + | if len(ret) == 0 { | |
| 2527 | + | panic("no return value specified for GetMostVisited") | |
| 2528 | + | } | |
| 2529 | + | ||
| 2530 | + | var r0 []*entity.HistoryEntry | |
| 2531 | + | var r1 error | |
| 2532 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok { | |
| 2533 | + | return returnFunc(ctx, days) | |
| 2534 | + | } | |
| 2535 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok { | |
| 2536 | + | r0 = returnFunc(ctx, days) | |
| 2537 | + | } else { | |
| 2538 | + | if ret.Get(0) != nil { | |
| 2539 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |
| 2540 | + | } | |
| 2541 | + | } | |
| 2542 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |
| 2543 | + | r1 = returnFunc(ctx, days) | |
| 2544 | + | } else { | |
| 2545 | + | r1 = ret.Error(1) | |
| 2546 | + | } | |
| 2547 | + | return r0, r1 | |
| 2548 | + | } | |
| 2549 | + | ||
| 2550 | + | // MockHistoryRepository_GetMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMostVisited' | |
| 2551 | + | type MockHistoryRepository_GetMostVisited_Call struct { | |
| 2552 | + | *mock.Call | |
| 2553 | + | } | |
| 2554 | + | ||
| 2555 | + | // GetMostVisited is a helper method to define mock.On call | |
| 2556 | + | // - ctx context.Context | |
| 2557 | + | // - days int | |
| 2558 | + | func (_e *MockHistoryRepository_Expecter) GetMostVisited(ctx interface{}, days interface{}) *MockHistoryRepository_GetMostVisited_Call { | |
| 2559 | + | return &MockHistoryRepository_GetMostVisited_Call{Call: _e.mock.On("GetMostVisited", ctx, days)} | |
| 2560 | + | } | |
| 2561 | + | ||
| 2562 | + | func (_c *MockHistoryRepository_GetMostVisited_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetMostVisited_Call { | |
| 2563 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2564 | + | var arg0 context.Context | |
| 2565 | + | if args[0] != nil { | |
| 2566 | + | arg0 = args[0].(context.Context) | |
| 2567 | + | } | |
| 2568 | + | var arg1 int | |
| 2569 | + | if args[1] != nil { | |
| 2570 | + | arg1 = args[1].(int) | |
| 2571 | + | } | |
| 2572 | + | run( | |
| 2573 | + | arg0, | |
| 2574 | + | arg1, | |
| 2575 | + | ) | |
| 2576 | + | }) | |
| 2577 | + | return _c | |
| 2578 | + | } | |
| 2579 | + | ||
| 2580 | + | func (_c *MockHistoryRepository_GetMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetMostVisited_Call { | |
| 2581 | + | _c.Call.Return(historyEntrys, err) | |
| 2582 | + | return _c | |
| 2583 | + | } | |
| 2584 | + | ||
| 2585 | + | func (_c *MockHistoryRepository_GetMostVisited_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetMostVisited_Call { | |
| 2586 | + | _c.Call.Return(run) | |
| 2587 | + | return _c | |
| 2588 | + | } | |
| 2589 | + | ||
| 2398 | 2590 | // GetRecent provides a mock function for the type MockHistoryRepository | |
| 2399 | 2591 | func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) { | |
| 2400 | 2592 | ret := _mock.Called(ctx, limit, offset) | |
| @@ -2469,6 +2661,74 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex | |||
| 2469 | 2661 | return _c | |
| 2470 | 2662 | } | |
| 2471 | 2663 | ||
| 2664 | + | // GetRecentSince provides a mock function for the type MockHistoryRepository | |
| 2665 | + | func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 2666 | + | ret := _mock.Called(ctx, days) | |
| 2667 | + | ||
| 2668 | + | if len(ret) == 0 { | |
| 2669 | + | panic("no return value specified for GetRecentSince") | |
| 2670 | + | } | |
| 2671 | + | ||
| 2672 | + | var r0 []*entity.HistoryEntry | |
| 2673 | + | var r1 error | |
| 2674 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok { | |
| 2675 | + | return returnFunc(ctx, days) | |
| 2676 | + | } | |
| 2677 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok { | |
| 2678 | + | r0 = returnFunc(ctx, days) | |
| 2679 | + | } else { | |
| 2680 | + | if ret.Get(0) != nil { | |
| 2681 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |
| 2682 | + | } | |
| 2683 | + | } | |
| 2684 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |
| 2685 | + | r1 = returnFunc(ctx, days) | |
| 2686 | + | } else { | |
| 2687 | + | r1 = ret.Error(1) | |
| 2688 | + | } | |
| 2689 | + | return r0, r1 | |
| 2690 | + | } | |
| 2691 | + | ||
| 2692 | + | // MockHistoryRepository_GetRecentSince_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentSince' | |
| 2693 | + | type MockHistoryRepository_GetRecentSince_Call struct { | |
| 2694 | + | *mock.Call | |
| 2695 | + | } | |
| 2696 | + | ||
| 2697 | + | // GetRecentSince is a helper method to define mock.On call | |
| 2698 | + | // - ctx context.Context | |
| 2699 | + | // - days int | |
| 2700 | + | func (_e *MockHistoryRepository_Expecter) GetRecentSince(ctx interface{}, days interface{}) *MockHistoryRepository_GetRecentSince_Call { | |
| 2701 | + | return &MockHistoryRepository_GetRecentSince_Call{Call: _e.mock.On("GetRecentSince", ctx, days)} | |
| 2702 | + | } | |
| 2703 | + | ||
| 2704 | + | func (_c *MockHistoryRepository_GetRecentSince_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetRecentSince_Call { | |
| 2705 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2706 | + | var arg0 context.Context | |
| 2707 | + | if args[0] != nil { | |
| 2708 | + | arg0 = args[0].(context.Context) | |
| 2709 | + | } | |
| 2710 | + | var arg1 int | |
| 2711 | + | if args[1] != nil { | |
| 2712 | + | arg1 = args[1].(int) | |
| 2713 | + | } | |
| 2714 | + | run( | |
| 2715 | + | arg0, | |
| 2716 | + | arg1, | |
| 2717 | + | ) | |
| 2718 | + | }) | |
| 2719 | + | return _c | |
| 2720 | + | } | |
| 2721 | + | ||
| 2722 | + | func (_c *MockHistoryRepository_GetRecentSince_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentSince_Call { | |
| 2723 | + | _c.Call.Return(historyEntrys, err) | |
| 2724 | + | return _c | |
| 2725 | + | } | |
| 2726 | + | ||
| 2727 | + | func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentSince_Call { | |
| 2728 | + | _c.Call.Return(run) | |
| 2729 | + | return _c | |
| 2730 | + | } | |
| 2731 | + | ||
| 2472 | 2732 | // GetStats provides a mock function for the type MockHistoryRepository | |
| 2473 | 2733 | func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | |
| 2474 | 2734 | ret := _mock.Called(ctx) | |
| @@ -2746,47 +3006,36 @@ func (_m *MockSessionRepository) EXPECT() *MockSessionRepository_Expecter { | |||
| 2746 | 3006 | return &MockSessionRepository_Expecter{mock: &_m.Mock} | |
| 2747 | 3007 | } | |
| 2748 | 3008 | ||
| 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 { | |
| 2751 | 3011 | ret := _mock.Called(ctx, id) | |
| 2752 | 3012 | ||
| 2753 | 3013 | if len(ret) == 0 { | |
| 2754 | - | panic("no return value specified for FindByID") | |
| 3014 | + | panic("no return value specified for Delete") | |
| 2755 | 3015 | } | |
| 2756 | 3016 | ||
| 2757 | - | var r0 *entity.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 { | |
| 2763 | 3019 | r0 = returnFunc(ctx, id) | |
| 2764 | 3020 | } 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) | |
| 2773 | 3022 | } | |
| 2774 | - | return r0, r1 | |
| 3023 | + | return r0 | |
| 2775 | 3024 | } | |
| 2776 | 3025 | ||
| 2777 | - | // MockSessionRepository_FindByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByID' | |
| 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 { | |
| 2779 | 3028 | *mock.Call | |
| 2780 | 3029 | } | |
| 2781 | 3030 | ||
| 2782 | - | // FindByID is a helper method to define mock.On call | |
| 3031 | + | // Delete is a helper method to define mock.On call | |
| 2783 | 3032 | // - ctx context.Context | |
| 2784 | 3033 | // - 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)} | |
| 2787 | 3036 | } | |
| 2788 | 3037 | ||
| 2789 | - | func (_c *MockSessionRepository_FindByID_Call) Run(run func(ctx context.Context, id entity.SessionID)) *MockSessionRepository_FindByID_Call { | |
| 3038 | + | func (_c *MockSessionRepository_Delete_Call) Run(run func(ctx context.Context, id entity.SessionID)) *MockSessionRepository_Delete_Call { | |
| 2790 | 3039 | _c.Call.Run(func(args mock.Arguments) { | |
| 2791 | 3040 | var arg0 context.Context | |
| 2792 | 3041 | if args[0] != nil { | |
| @@ -2804,56 +3053,256 @@ func (_c *MockSessionRepository_FindByID_Call) Run(run func(ctx context.Context, | |||
| 2804 | 3053 | return _c | |
| 2805 | 3054 | } | |
| 2806 | 3055 | ||
| 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) | |
| 2809 | 3058 | return _c | |
| 2810 | 3059 | } | |
| 2811 | 3060 | ||
| 2812 | - | func (_c *MockSessionRepository_FindByID_Call) RunAndReturn(run func(ctx context.Context, id entity.SessionID) (*entity.Session, error)) *MockSessionRepository_FindByID_Call { | |
| 3061 | + | func (_c *MockSessionRepository_Delete_Call) RunAndReturn(run func(ctx context.Context, id entity.SessionID) error) *MockSessionRepository_Delete_Call { | |
| 2813 | 3062 | _c.Call.Return(run) | |
| 2814 | 3063 | return _c | |
| 2815 | 3064 | } | |
| 2816 | 3065 | ||
| 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) | |
| 2820 | 3069 | ||
| 2821 | 3070 | if len(ret) == 0 { | |
| 2822 | - | panic("no return value specified for GetActive") | |
| 3071 | + | panic("no return value specified for DeleteExitedBefore") | |
| 2823 | 3072 | } | |
| 2824 | 3073 | ||
| 2825 | - | var r0 *entity.Session | |
| 3074 | + | var r0 int64 | |
| 2826 | 3075 | 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) | |
| 2829 | 3078 | } | |
| 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) | |
| 2832 | 3081 | } else { | |
| 2833 | - | if ret.Get(0) != nil { | |
| 2834 | - | r0 = ret.Get(0).(*entity.Session) | |
| 2835 | - | } | |
| 3082 | + | r0 = ret.Get(0).(int64) | |
| 2836 | 3083 | } | |
| 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) | |
| 2839 | 3086 | } else { | |
| 2840 | 3087 | r1 = ret.Error(1) | |
| 2841 | 3088 | } | |
| 2842 | 3089 | return r0, r1 | |
| 2843 | 3090 | } | |
| 2844 | 3091 | ||
| 2845 | - | // MockSessionRepository_GetActive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActive' | |
| 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 { | |
| 2847 | 3094 | *mock.Call | |
| 2848 | 3095 | } | |
| 2849 | 3096 | ||
| 2850 | - | // GetActive is a helper method to define mock.On call | |
| 3097 | + | // DeleteExitedBefore is a helper method to define mock.On call | |
| 2851 | 3098 | // - 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)} | |
| 2854 | 3102 | } | |
| 2855 | 3103 | ||
| 2856 | - | func (_c *MockSessionRepository_GetActive_Call) Run(run func(ctx context.Context)) *MockSessionRepository_GetActive_Call { | |
| 3104 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) Run(run func(ctx context.Context, cutoff time.Time)) *MockSessionRepository_DeleteExitedBefore_Call { | |
| 3105 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3106 | + | var arg0 context.Context | |
| 3107 | + | if args[0] != nil { | |
| 3108 | + | arg0 = args[0].(context.Context) | |
| 3109 | + | } | |
| 3110 | + | var arg1 time.Time | |
| 3111 | + | if args[1] != nil { | |
| 3112 | + | arg1 = args[1].(time.Time) | |
| 3113 | + | } | |
| 3114 | + | run( | |
| 3115 | + | arg0, | |
| 3116 | + | arg1, | |
| 3117 | + | ) | |
| 3118 | + | }) | |
| 3119 | + | return _c | |
| 3120 | + | } | |
| 3121 | + | ||
| 3122 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) Return(n int64, err error) *MockSessionRepository_DeleteExitedBefore_Call { | |
| 3123 | + | _c.Call.Return(n, err) | |
| 3124 | + | return _c | |
| 3125 | + | } | |
| 3126 | + | ||
| 3127 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) RunAndReturn(run func(ctx context.Context, cutoff time.Time) (int64, error)) *MockSessionRepository_DeleteExitedBefore_Call { | |
| 3128 | + | _c.Call.Return(run) | |
| 3129 | + | return _c | |
| 3130 | + | } | |
| 3131 | + | ||
| 3132 | + | // DeleteOldestExited provides a mock function for the type MockSessionRepository | |
| 3133 | + | func (_mock *MockSessionRepository) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) { | |
| 3134 | + | ret := _mock.Called(ctx, keepCount) | |
| 3135 | + | ||
| 3136 | + | if len(ret) == 0 { | |
| 3137 | + | panic("no return value specified for DeleteOldestExited") | |
| 3138 | + | } | |
| 3139 | + | ||
| 3140 | + | var r0 int64 | |
| 3141 | + | var r1 error | |
| 3142 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) (int64, error)); ok { | |
| 3143 | + | return returnFunc(ctx, keepCount) | |
| 3144 | + | } | |
| 3145 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) int64); ok { | |
| 3146 | + | r0 = returnFunc(ctx, keepCount) | |
| 3147 | + | } else { | |
| 3148 | + | r0 = ret.Get(0).(int64) | |
| 3149 | + | } | |
| 3150 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |
| 3151 | + | r1 = returnFunc(ctx, keepCount) | |
| 3152 | + | } else { | |
| 3153 | + | r1 = ret.Error(1) | |
| 3154 | + | } | |
| 3155 | + | return r0, r1 | |
| 3156 | + | } | |
| 3157 | + | ||
| 3158 | + | // MockSessionRepository_DeleteOldestExited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteOldestExited' | |
| 3159 | + | type MockSessionRepository_DeleteOldestExited_Call struct { | |
| 3160 | + | *mock.Call | |
| 3161 | + | } | |
| 3162 | + | ||
| 3163 | + | // DeleteOldestExited is a helper method to define mock.On call | |
| 3164 | + | // - ctx context.Context | |
| 3165 | + | // - keepCount int | |
| 3166 | + | func (_e *MockSessionRepository_Expecter) DeleteOldestExited(ctx interface{}, keepCount interface{}) *MockSessionRepository_DeleteOldestExited_Call { | |
| 3167 | + | return &MockSessionRepository_DeleteOldestExited_Call{Call: _e.mock.On("DeleteOldestExited", ctx, keepCount)} | |
| 3168 | + | } | |
| 3169 | + | ||
| 3170 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) Run(run func(ctx context.Context, keepCount int)) *MockSessionRepository_DeleteOldestExited_Call { | |
| 3171 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3172 | + | var arg0 context.Context | |
| 3173 | + | if args[0] != nil { | |
| 3174 | + | arg0 = args[0].(context.Context) | |
| 3175 | + | } | |
| 3176 | + | var arg1 int | |
| 3177 | + | if args[1] != nil { | |
| 3178 | + | arg1 = args[1].(int) | |
| 3179 | + | } | |
| 3180 | + | run( | |
| 3181 | + | arg0, | |
| 3182 | + | arg1, | |
| 3183 | + | ) | |
| 3184 | + | }) | |
| 3185 | + | return _c | |
| 3186 | + | } | |
| 3187 | + | ||
| 3188 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) Return(n int64, err error) *MockSessionRepository_DeleteOldestExited_Call { | |
| 3189 | + | _c.Call.Return(n, err) | |
| 3190 | + | return _c | |
| 3191 | + | } | |
| 3192 | + | ||
| 3193 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) RunAndReturn(run func(ctx context.Context, keepCount int) (int64, error)) *MockSessionRepository_DeleteOldestExited_Call { | |
| 3194 | + | _c.Call.Return(run) | |
| 3195 | + | return _c | |
| 3196 | + | } | |
| 3197 | + | ||
| 3198 | + | // FindByID provides a mock function for the type MockSessionRepository | |
| 3199 | + | func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) { | |
| 3200 | + | ret := _mock.Called(ctx, id) | |
| 3201 | + | ||
| 3202 | + | if len(ret) == 0 { | |
| 3203 | + | panic("no return value specified for FindByID") | |
| 3204 | + | } | |
| 3205 | + | ||
| 3206 | + | var r0 *entity.Session | |
| 3207 | + | var r1 error | |
| 3208 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) (*entity.Session, error)); ok { | |
| 3209 | + | return returnFunc(ctx, id) | |
| 3210 | + | } | |
| 3211 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) *entity.Session); ok { | |
| 3212 | + | r0 = returnFunc(ctx, id) | |
| 3213 | + | } else { | |
| 3214 | + | if ret.Get(0) != nil { | |
| 3215 | + | r0 = ret.Get(0).(*entity.Session) | |
| 3216 | + | } | |
| 3217 | + | } | |
| 3218 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID) error); ok { | |
| 3219 | + | r1 = returnFunc(ctx, id) | |
| 3220 | + | } else { | |
| 3221 | + | r1 = ret.Error(1) | |
| 3222 | + | } | |
| 3223 | + | return r0, r1 | |
| 3224 | + | } | |
| 3225 | + | ||
| 3226 | + | // MockSessionRepository_FindByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindByID' | |
| 3227 | + | type MockSessionRepository_FindByID_Call struct { | |
| 3228 | + | *mock.Call | |
| 3229 | + | } | |
| 3230 | + | ||
| 3231 | + | // FindByID is a helper method to define mock.On call | |
| 3232 | + | // - ctx context.Context | |
| 3233 | + | // - id entity.SessionID | |
| 3234 | + | func (_e *MockSessionRepository_Expecter) FindByID(ctx interface{}, id interface{}) *MockSessionRepository_FindByID_Call { | |
| 3235 | + | return &MockSessionRepository_FindByID_Call{Call: _e.mock.On("FindByID", ctx, id)} | |
| 3236 | + | } | |
| 3237 | + | ||
| 3238 | + | func (_c *MockSessionRepository_FindByID_Call) Run(run func(ctx context.Context, id entity.SessionID)) *MockSessionRepository_FindByID_Call { | |
| 3239 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3240 | + | var arg0 context.Context | |
| 3241 | + | if args[0] != nil { | |
| 3242 | + | arg0 = args[0].(context.Context) | |
| 3243 | + | } | |
| 3244 | + | var arg1 entity.SessionID | |
| 3245 | + | if args[1] != nil { | |
| 3246 | + | arg1 = args[1].(entity.SessionID) | |
| 3247 | + | } | |
| 3248 | + | run( | |
| 3249 | + | arg0, | |
| 3250 | + | arg1, | |
| 3251 | + | ) | |
| 3252 | + | }) | |
| 3253 | + | return _c | |
| 3254 | + | } | |
| 3255 | + | ||
| 3256 | + | func (_c *MockSessionRepository_FindByID_Call) Return(session *entity.Session, err error) *MockSessionRepository_FindByID_Call { | |
| 3257 | + | _c.Call.Return(session, err) | |
| 3258 | + | return _c | |
| 3259 | + | } | |
| 3260 | + | ||
| 3261 | + | func (_c *MockSessionRepository_FindByID_Call) RunAndReturn(run func(ctx context.Context, id entity.SessionID) (*entity.Session, error)) *MockSessionRepository_FindByID_Call { | |
| 3262 | + | _c.Call.Return(run) | |
| 3263 | + | return _c | |
| 3264 | + | } | |
| 3265 | + | ||
| 3266 | + | // GetActive provides a mock function for the type MockSessionRepository | |
| 3267 | + | func (_mock *MockSessionRepository) GetActive(ctx context.Context) (*entity.Session, error) { | |
| 3268 | + | ret := _mock.Called(ctx) | |
| 3269 | + | ||
| 3270 | + | if len(ret) == 0 { | |
| 3271 | + | panic("no return value specified for GetActive") | |
| 3272 | + | } | |
| 3273 | + | ||
| 3274 | + | var r0 *entity.Session | |
| 3275 | + | var r1 error | |
| 3276 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (*entity.Session, error)); ok { | |
| 3277 | + | return returnFunc(ctx) | |
| 3278 | + | } | |
| 3279 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) *entity.Session); ok { | |
| 3280 | + | r0 = returnFunc(ctx) | |
| 3281 | + | } else { | |
| 3282 | + | if ret.Get(0) != nil { | |
| 3283 | + | r0 = ret.Get(0).(*entity.Session) | |
| 3284 | + | } | |
| 3285 | + | } | |
| 3286 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 3287 | + | r1 = returnFunc(ctx) | |
| 3288 | + | } else { | |
| 3289 | + | r1 = ret.Error(1) | |
| 3290 | + | } | |
| 3291 | + | return r0, r1 | |
| 3292 | + | } | |
| 3293 | + | ||
| 3294 | + | // MockSessionRepository_GetActive_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActive' | |
| 3295 | + | type MockSessionRepository_GetActive_Call struct { | |
| 3296 | + | *mock.Call | |
| 3297 | + | } | |
| 3298 | + | ||
| 3299 | + | // GetActive is a helper method to define mock.On call | |
| 3300 | + | // - ctx context.Context | |
| 3301 | + | func (_e *MockSessionRepository_Expecter) GetActive(ctx interface{}) *MockSessionRepository_GetActive_Call { | |
| 3302 | + | return &MockSessionRepository_GetActive_Call{Call: _e.mock.On("GetActive", ctx)} | |
| 3303 | + | } | |
| 3304 | + | ||
| 3305 | + | func (_c *MockSessionRepository_GetActive_Call) Run(run func(ctx context.Context)) *MockSessionRepository_GetActive_Call { | |
| 2857 | 3306 | _c.Call.Run(func(args mock.Arguments) { | |
| 2858 | 3307 | var arg0 context.Context | |
| 2859 | 3308 | if args[0] != nil { | |
| @@ -3064,6 +3513,337 @@ func (_c *MockSessionRepository_Save_Call) RunAndReturn(run func(ctx context.Con | |||
| 3064 | 3513 | return _c | |
| 3065 | 3514 | } | |
| 3066 | 3515 | ||
| 3516 | + | // NewMockSessionStateRepository creates a new instance of MockSessionStateRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 3517 | + | // The first argument is typically a *testing.T value. | |
| 3518 | + | func NewMockSessionStateRepository(t interface { | |
| 3519 | + | mock.TestingT | |
| 3520 | + | Cleanup(func()) | |
| 3521 | + | }) *MockSessionStateRepository { | |
| 3522 | + | mock := &MockSessionStateRepository{} | |
| 3523 | + | mock.Mock.Test(t) | |
| 3524 | + | ||
| 3525 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 3526 | + | ||
| 3527 | + | return mock | |
| 3528 | + | } | |
| 3529 | + | ||
| 3530 | + | // MockSessionStateRepository is an autogenerated mock type for the SessionStateRepository type | |
| 3531 | + | type MockSessionStateRepository struct { | |
| 3532 | + | mock.Mock | |
| 3533 | + | } | |
| 3534 | + | ||
| 3535 | + | type MockSessionStateRepository_Expecter struct { | |
| 3536 | + | mock *mock.Mock | |
| 3537 | + | } | |
| 3538 | + | ||
| 3539 | + | func (_m *MockSessionStateRepository) EXPECT() *MockSessionStateRepository_Expecter { | |
| 3540 | + | return &MockSessionStateRepository_Expecter{mock: &_m.Mock} | |
| 3541 | + | } | |
| 3542 | + | ||
| 3543 | + | // DeleteSnapshot provides a mock function for the type MockSessionStateRepository | |
| 3544 | + | func (_mock *MockSessionStateRepository) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error { | |
| 3545 | + | ret := _mock.Called(ctx, sessionID) | |
| 3546 | + | ||
| 3547 | + | if len(ret) == 0 { | |
| 3548 | + | panic("no return value specified for DeleteSnapshot") | |
| 3549 | + | } | |
| 3550 | + | ||
| 3551 | + | var r0 error | |
| 3552 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) error); ok { | |
| 3553 | + | r0 = returnFunc(ctx, sessionID) | |
| 3554 | + | } else { | |
| 3555 | + | r0 = ret.Error(0) | |
| 3556 | + | } | |
| 3557 | + | return r0 | |
| 3558 | + | } | |
| 3559 | + | ||
| 3560 | + | // MockSessionStateRepository_DeleteSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSnapshot' | |
| 3561 | + | type MockSessionStateRepository_DeleteSnapshot_Call struct { | |
| 3562 | + | *mock.Call | |
| 3563 | + | } | |
| 3564 | + | ||
| 3565 | + | // DeleteSnapshot is a helper method to define mock.On call | |
| 3566 | + | // - ctx context.Context | |
| 3567 | + | // - sessionID entity.SessionID | |
| 3568 | + | func (_e *MockSessionStateRepository_Expecter) DeleteSnapshot(ctx interface{}, sessionID interface{}) *MockSessionStateRepository_DeleteSnapshot_Call { | |
| 3569 | + | return &MockSessionStateRepository_DeleteSnapshot_Call{Call: _e.mock.On("DeleteSnapshot", ctx, sessionID)} | |
| 3570 | + | } | |
| 3571 | + | ||
| 3572 | + | func (_c *MockSessionStateRepository_DeleteSnapshot_Call) Run(run func(ctx context.Context, sessionID entity.SessionID)) *MockSessionStateRepository_DeleteSnapshot_Call { | |
| 3573 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3574 | + | var arg0 context.Context | |
| 3575 | + | if args[0] != nil { | |
| 3576 | + | arg0 = args[0].(context.Context) | |
| 3577 | + | } | |
| 3578 | + | var arg1 entity.SessionID | |
| 3579 | + | if args[1] != nil { | |
| 3580 | + | arg1 = args[1].(entity.SessionID) | |
| 3581 | + | } | |
| 3582 | + | run( | |
| 3583 | + | arg0, | |
| 3584 | + | arg1, | |
| 3585 | + | ) | |
| 3586 | + | }) | |
| 3587 | + | return _c | |
| 3588 | + | } | |
| 3589 | + | ||
| 3590 | + | func (_c *MockSessionStateRepository_DeleteSnapshot_Call) Return(err error) *MockSessionStateRepository_DeleteSnapshot_Call { | |
| 3591 | + | _c.Call.Return(err) | |
| 3592 | + | return _c | |
| 3593 | + | } | |
| 3594 | + | ||
| 3595 | + | func (_c *MockSessionStateRepository_DeleteSnapshot_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID) error) *MockSessionStateRepository_DeleteSnapshot_Call { | |
| 3596 | + | _c.Call.Return(run) | |
| 3597 | + | return _c | |
| 3598 | + | } | |
| 3599 | + | ||
| 3600 | + | // GetAllSnapshots provides a mock function for the type MockSessionStateRepository | |
| 3601 | + | func (_mock *MockSessionStateRepository) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) { | |
| 3602 | + | ret := _mock.Called(ctx) | |
| 3603 | + | ||
| 3604 | + | if len(ret) == 0 { | |
| 3605 | + | panic("no return value specified for GetAllSnapshots") | |
| 3606 | + | } | |
| 3607 | + | ||
| 3608 | + | var r0 []*entity.SessionState | |
| 3609 | + | var r1 error | |
| 3610 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.SessionState, error)); ok { | |
| 3611 | + | return returnFunc(ctx) | |
| 3612 | + | } | |
| 3613 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.SessionState); ok { | |
| 3614 | + | r0 = returnFunc(ctx) | |
| 3615 | + | } else { | |
| 3616 | + | if ret.Get(0) != nil { | |
| 3617 | + | r0 = ret.Get(0).([]*entity.SessionState) | |
| 3618 | + | } | |
| 3619 | + | } | |
| 3620 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 3621 | + | r1 = returnFunc(ctx) | |
| 3622 | + | } else { | |
| 3623 | + | r1 = ret.Error(1) | |
| 3624 | + | } | |
| 3625 | + | return r0, r1 | |
| 3626 | + | } | |
| 3627 | + | ||
| 3628 | + | // MockSessionStateRepository_GetAllSnapshots_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllSnapshots' | |
| 3629 | + | type MockSessionStateRepository_GetAllSnapshots_Call struct { | |
| 3630 | + | *mock.Call | |
| 3631 | + | } | |
| 3632 | + | ||
| 3633 | + | // GetAllSnapshots is a helper method to define mock.On call | |
| 3634 | + | // - ctx context.Context | |
| 3635 | + | func (_e *MockSessionStateRepository_Expecter) GetAllSnapshots(ctx interface{}) *MockSessionStateRepository_GetAllSnapshots_Call { | |
| 3636 | + | return &MockSessionStateRepository_GetAllSnapshots_Call{Call: _e.mock.On("GetAllSnapshots", ctx)} | |
| 3637 | + | } | |
| 3638 | + | ||
| 3639 | + | func (_c *MockSessionStateRepository_GetAllSnapshots_Call) Run(run func(ctx context.Context)) *MockSessionStateRepository_GetAllSnapshots_Call { | |
| 3640 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3641 | + | var arg0 context.Context | |
| 3642 | + | if args[0] != nil { | |
| 3643 | + | arg0 = args[0].(context.Context) | |
| 3644 | + | } | |
| 3645 | + | run( | |
| 3646 | + | arg0, | |
| 3647 | + | ) | |
| 3648 | + | }) | |
| 3649 | + | return _c | |
| 3650 | + | } | |
| 3651 | + | ||
| 3652 | + | func (_c *MockSessionStateRepository_GetAllSnapshots_Call) Return(sessionStates []*entity.SessionState, err error) *MockSessionStateRepository_GetAllSnapshots_Call { | |
| 3653 | + | _c.Call.Return(sessionStates, err) | |
| 3654 | + | return _c | |
| 3655 | + | } | |
| 3656 | + | ||
| 3657 | + | func (_c *MockSessionStateRepository_GetAllSnapshots_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.SessionState, error)) *MockSessionStateRepository_GetAllSnapshots_Call { | |
| 3658 | + | _c.Call.Return(run) | |
| 3659 | + | return _c | |
| 3660 | + | } | |
| 3661 | + | ||
| 3662 | + | // GetSnapshot provides a mock function for the type MockSessionStateRepository | |
| 3663 | + | func (_mock *MockSessionStateRepository) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) { | |
| 3664 | + | ret := _mock.Called(ctx, sessionID) | |
| 3665 | + | ||
| 3666 | + | if len(ret) == 0 { | |
| 3667 | + | panic("no return value specified for GetSnapshot") | |
| 3668 | + | } | |
| 3669 | + | ||
| 3670 | + | var r0 *entity.SessionState | |
| 3671 | + | var r1 error | |
| 3672 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) (*entity.SessionState, error)); ok { | |
| 3673 | + | return returnFunc(ctx, sessionID) | |
| 3674 | + | } | |
| 3675 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) *entity.SessionState); ok { | |
| 3676 | + | r0 = returnFunc(ctx, sessionID) | |
| 3677 | + | } else { | |
| 3678 | + | if ret.Get(0) != nil { | |
| 3679 | + | r0 = ret.Get(0).(*entity.SessionState) | |
| 3680 | + | } | |
| 3681 | + | } | |
| 3682 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID) error); ok { | |
| 3683 | + | r1 = returnFunc(ctx, sessionID) | |
| 3684 | + | } else { | |
| 3685 | + | r1 = ret.Error(1) | |
| 3686 | + | } | |
| 3687 | + | return r0, r1 | |
| 3688 | + | } | |
| 3689 | + | ||
| 3690 | + | // MockSessionStateRepository_GetSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSnapshot' | |
| 3691 | + | type MockSessionStateRepository_GetSnapshot_Call struct { | |
| 3692 | + | *mock.Call | |
| 3693 | + | } | |
| 3694 | + | ||
| 3695 | + | // GetSnapshot is a helper method to define mock.On call | |
| 3696 | + | // - ctx context.Context | |
| 3697 | + | // - sessionID entity.SessionID | |
| 3698 | + | func (_e *MockSessionStateRepository_Expecter) GetSnapshot(ctx interface{}, sessionID interface{}) *MockSessionStateRepository_GetSnapshot_Call { | |
| 3699 | + | return &MockSessionStateRepository_GetSnapshot_Call{Call: _e.mock.On("GetSnapshot", ctx, sessionID)} | |
| 3700 | + | } | |
| 3701 | + | ||
| 3702 | + | func (_c *MockSessionStateRepository_GetSnapshot_Call) Run(run func(ctx context.Context, sessionID entity.SessionID)) *MockSessionStateRepository_GetSnapshot_Call { | |
| 3703 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3704 | + | var arg0 context.Context | |
| 3705 | + | if args[0] != nil { | |
| 3706 | + | arg0 = args[0].(context.Context) | |
| 3707 | + | } | |
| 3708 | + | var arg1 entity.SessionID | |
| 3709 | + | if args[1] != nil { | |
| 3710 | + | arg1 = args[1].(entity.SessionID) | |
| 3711 | + | } | |
| 3712 | + | run( | |
| 3713 | + | arg0, | |
| 3714 | + | arg1, | |
| 3715 | + | ) | |
| 3716 | + | }) | |
| 3717 | + | return _c | |
| 3718 | + | } | |
| 3719 | + | ||
| 3720 | + | func (_c *MockSessionStateRepository_GetSnapshot_Call) Return(sessionState *entity.SessionState, err error) *MockSessionStateRepository_GetSnapshot_Call { | |
| 3721 | + | _c.Call.Return(sessionState, err) | |
| 3722 | + | return _c | |
| 3723 | + | } | |
| 3724 | + | ||
| 3725 | + | func (_c *MockSessionStateRepository_GetSnapshot_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error)) *MockSessionStateRepository_GetSnapshot_Call { | |
| 3726 | + | _c.Call.Return(run) | |
| 3727 | + | return _c | |
| 3728 | + | } | |
| 3729 | + | ||
| 3730 | + | // GetTotalSnapshotsSize provides a mock function for the type MockSessionStateRepository | |
| 3731 | + | func (_mock *MockSessionStateRepository) GetTotalSnapshotsSize(ctx context.Context) (int64, error) { | |
| 3732 | + | ret := _mock.Called(ctx) | |
| 3733 | + | ||
| 3734 | + | if len(ret) == 0 { | |
| 3735 | + | panic("no return value specified for GetTotalSnapshotsSize") | |
| 3736 | + | } | |
| 3737 | + | ||
| 3738 | + | var r0 int64 | |
| 3739 | + | var r1 error | |
| 3740 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (int64, error)); ok { | |
| 3741 | + | return returnFunc(ctx) | |
| 3742 | + | } | |
| 3743 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) int64); ok { | |
| 3744 | + | r0 = returnFunc(ctx) | |
| 3745 | + | } else { | |
| 3746 | + | r0 = ret.Get(0).(int64) | |
| 3747 | + | } | |
| 3748 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 3749 | + | r1 = returnFunc(ctx) | |
| 3750 | + | } else { | |
| 3751 | + | r1 = ret.Error(1) | |
| 3752 | + | } | |
| 3753 | + | return r0, r1 | |
| 3754 | + | } | |
| 3755 | + | ||
| 3756 | + | // MockSessionStateRepository_GetTotalSnapshotsSize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTotalSnapshotsSize' | |
| 3757 | + | type MockSessionStateRepository_GetTotalSnapshotsSize_Call struct { | |
| 3758 | + | *mock.Call | |
| 3759 | + | } | |
| 3760 | + | ||
| 3761 | + | // GetTotalSnapshotsSize is a helper method to define mock.On call | |
| 3762 | + | // - ctx context.Context | |
| 3763 | + | func (_e *MockSessionStateRepository_Expecter) GetTotalSnapshotsSize(ctx interface{}) *MockSessionStateRepository_GetTotalSnapshotsSize_Call { | |
| 3764 | + | return &MockSessionStateRepository_GetTotalSnapshotsSize_Call{Call: _e.mock.On("GetTotalSnapshotsSize", ctx)} | |
| 3765 | + | } | |
| 3766 | + | ||
| 3767 | + | func (_c *MockSessionStateRepository_GetTotalSnapshotsSize_Call) Run(run func(ctx context.Context)) *MockSessionStateRepository_GetTotalSnapshotsSize_Call { | |
| 3768 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3769 | + | var arg0 context.Context | |
| 3770 | + | if args[0] != nil { | |
| 3771 | + | arg0 = args[0].(context.Context) | |
| 3772 | + | } | |
| 3773 | + | run( | |
| 3774 | + | arg0, | |
| 3775 | + | ) | |
| 3776 | + | }) | |
| 3777 | + | return _c | |
| 3778 | + | } | |
| 3779 | + | ||
| 3780 | + | func (_c *MockSessionStateRepository_GetTotalSnapshotsSize_Call) Return(n int64, err error) *MockSessionStateRepository_GetTotalSnapshotsSize_Call { | |
| 3781 | + | _c.Call.Return(n, err) | |
| 3782 | + | return _c | |
| 3783 | + | } | |
| 3784 | + | ||
| 3785 | + | func (_c *MockSessionStateRepository_GetTotalSnapshotsSize_Call) RunAndReturn(run func(ctx context.Context) (int64, error)) *MockSessionStateRepository_GetTotalSnapshotsSize_Call { | |
| 3786 | + | _c.Call.Return(run) | |
| 3787 | + | return _c | |
| 3788 | + | } | |
| 3789 | + | ||
| 3790 | + | // SaveSnapshot provides a mock function for the type MockSessionStateRepository | |
| 3791 | + | func (_mock *MockSessionStateRepository) SaveSnapshot(ctx context.Context, state *entity.SessionState) error { | |
| 3792 | + | ret := _mock.Called(ctx, state) | |
| 3793 | + | ||
| 3794 | + | if len(ret) == 0 { | |
| 3795 | + | panic("no return value specified for SaveSnapshot") | |
| 3796 | + | } | |
| 3797 | + | ||
| 3798 | + | var r0 error | |
| 3799 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.SessionState) error); ok { | |
| 3800 | + | r0 = returnFunc(ctx, state) | |
| 3801 | + | } else { | |
| 3802 | + | r0 = ret.Error(0) | |
| 3803 | + | } | |
| 3804 | + | return r0 | |
| 3805 | + | } | |
| 3806 | + | ||
| 3807 | + | // MockSessionStateRepository_SaveSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveSnapshot' | |
| 3808 | + | type MockSessionStateRepository_SaveSnapshot_Call struct { | |
| 3809 | + | *mock.Call | |
| 3810 | + | } | |
| 3811 | + | ||
| 3812 | + | // SaveSnapshot is a helper method to define mock.On call | |
| 3813 | + | // - ctx context.Context | |
| 3814 | + | // - state *entity.SessionState | |
| 3815 | + | func (_e *MockSessionStateRepository_Expecter) SaveSnapshot(ctx interface{}, state interface{}) *MockSessionStateRepository_SaveSnapshot_Call { | |
| 3816 | + | return &MockSessionStateRepository_SaveSnapshot_Call{Call: _e.mock.On("SaveSnapshot", ctx, state)} | |
| 3817 | + | } | |
| 3818 | + | ||
| 3819 | + | func (_c *MockSessionStateRepository_SaveSnapshot_Call) Run(run func(ctx context.Context, state *entity.SessionState)) *MockSessionStateRepository_SaveSnapshot_Call { | |
| 3820 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3821 | + | var arg0 context.Context | |
| 3822 | + | if args[0] != nil { | |
| 3823 | + | arg0 = args[0].(context.Context) | |
| 3824 | + | } | |
| 3825 | + | var arg1 *entity.SessionState | |
| 3826 | + | if args[1] != nil { | |
| 3827 | + | arg1 = args[1].(*entity.SessionState) | |
| 3828 | + | } | |
| 3829 | + | run( | |
| 3830 | + | arg0, | |
| 3831 | + | arg1, | |
| 3832 | + | ) | |
| 3833 | + | }) | |
| 3834 | + | return _c | |
| 3835 | + | } | |
| 3836 | + | ||
| 3837 | + | func (_c *MockSessionStateRepository_SaveSnapshot_Call) Return(err error) *MockSessionStateRepository_SaveSnapshot_Call { | |
| 3838 | + | _c.Call.Return(err) | |
| 3839 | + | return _c | |
| 3840 | + | } | |
| 3841 | + | ||
| 3842 | + | func (_c *MockSessionStateRepository_SaveSnapshot_Call) RunAndReturn(run func(ctx context.Context, state *entity.SessionState) error) *MockSessionStateRepository_SaveSnapshot_Call { | |
| 3843 | + | _c.Call.Return(run) | |
| 3844 | + | return _c | |
| 3845 | + | } | |
| 3846 | + | ||
| 3067 | 3847 | // NewMockZoomRepository creates a new instance of MockZoomRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 3068 | 3848 | // The first argument is typically a *testing.T value. | |
| 3069 | 3849 | func NewMockZoomRepository(t interface { | |
M internal/domain/repository/session.go +11 -0
| @@ -16,4 +16,15 @@ type SessionRepository interface { | |||
| 16 | 16 | GetActive(ctx context.Context) (*entity.Session, error) | |
| 17 | 17 | GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) | |
| 18 | 18 | MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error | |
| 19 | + | ||
| 20 | + | // Delete removes a session record. | |
| 21 | + | Delete(ctx context.Context, id entity.SessionID) error | |
| 22 | + | ||
| 23 | + | // DeleteOldestExited deletes exited sessions beyond the keep limit. | |
| 24 | + | // Returns number of deleted sessions. | |
| 25 | + | DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) | |
| 26 | + | ||
| 27 | + | // DeleteExitedBefore deletes exited sessions older than the cutoff time. | |
| 28 | + | // Returns number of deleted sessions. | |
| 29 | + | DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) | |
| 19 | 30 | } | |
A internal/domain/repository/session_state.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package repository | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // SessionStateRepository persists session state snapshots. | |
| 10 | + | type SessionStateRepository interface { | |
| 11 | + | // SaveSnapshot saves or updates a session state snapshot. | |
| 12 | + | SaveSnapshot(ctx context.Context, state *entity.SessionState) error | |
| 13 | + | ||
| 14 | + | // GetSnapshot returns the latest snapshot for a session. | |
| 15 | + | GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) | |
| 16 | + | ||
| 17 | + | // DeleteSnapshot removes a session's snapshot. | |
| 18 | + | DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error | |
| 19 | + | ||
| 20 | + | // GetAllSnapshots returns all snapshots with summary info. | |
| 21 | + | // Used by ListSessionsUseCase. | |
| 22 | + | GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) | |
| 23 | + | ||
| 24 | + | // GetTotalSnapshotsSize returns the total size of all session snapshots in bytes. | |
| 25 | + | GetTotalSnapshotsSize(ctx context.Context) (int64, error) | |
| 26 | + | } | |
M internal/domain/url/normalize.go +53 -0
| @@ -4,11 +4,41 @@ package url | |||
| 4 | 4 | import ( | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "net/url" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 7 | 9 | "strings" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| 12 | + | // expandHome expands ~ prefix to user's home directory. | |
| 13 | + | func expandHome(path string) string { | |
| 14 | + | if strings.HasPrefix(path, "~/") { | |
| 15 | + | if home, err := os.UserHomeDir(); err == nil { | |
| 16 | + | return filepath.Join(home, path[2:]) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | return path | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // looksLikeFilePath returns true if the input looks like a filesystem path. | |
| 23 | + | // This includes absolute paths (/path), relative paths (./path, ../path), | |
| 24 | + | // and home-relative paths (~/path). | |
| 25 | + | func looksLikeFilePath(input string) bool { | |
| 26 | + | switch { | |
| 27 | + | case strings.HasPrefix(input, "/"): | |
| 28 | + | return true | |
| 29 | + | case strings.HasPrefix(input, "./"): | |
| 30 | + | return true | |
| 31 | + | case strings.HasPrefix(input, "../"): | |
| 32 | + | return true | |
| 33 | + | case strings.HasPrefix(input, "~/"): | |
| 34 | + | return true | |
| 35 | + | } | |
| 36 | + | return false | |
| 37 | + | } | |
| 38 | + | ||
| 10 | 39 | // Normalize adds https:// prefix if missing for URL-like inputs. | |
| 11 | 40 | // Returns the input unchanged if it already has a scheme or doesn't look like a URL. | |
| 41 | + | // If the input is an existing local file, returns a file:// URL. | |
| 12 | 42 | func Normalize(input string) string { | |
| 13 | 43 | if input == "" { | |
| 14 | 44 | return "" | |
| @@ -28,6 +58,29 @@ func Normalize(input string) string { | |||
| 28 | 58 | return input | |
| 29 | 59 | } | |
| 30 | 60 | ||
| 61 | + | // Check if input looks like a file path | |
| 62 | + | if looksLikeFilePath(input) { | |
| 63 | + | expanded := expandHome(input) | |
| 64 | + | absPath, err := filepath.Abs(expanded) | |
| 65 | + | if err == nil { | |
| 66 | + | if _, statErr := os.Stat(absPath); statErr == nil { | |
| 67 | + | // File exists, convert to file:// URL | |
| 68 | + | return "file://" + absPath | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | // Path-like input but file doesn't exist - return unchanged | |
| 72 | + | // Don't treat as URL to avoid malformed URLs like https:///path | |
| 73 | + | return input | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Check if input is an existing file in current directory (relative without ./ prefix) | |
| 77 | + | absPath, err := filepath.Abs(input) | |
| 78 | + | if err == nil { | |
| 79 | + | if _, statErr := os.Stat(absPath); statErr == nil { | |
| 80 | + | return "file://" + absPath | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 31 | 84 | // Looks like a URL (contains . and no spaces) | |
| 32 | 85 | if strings.Contains(input, ".") && !strings.Contains(input, " ") { | |
| 33 | 86 | return "https://" + input | |
A internal/domain/url/normalize_test.go +270 -0
| @@ -0,0 +1,270 @@ | |||
| 1 | + | package url | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestNormalize(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | input string | |
| 13 | + | want string | |
| 14 | + | }{ | |
| 15 | + | { | |
| 16 | + | name: "empty string", | |
| 17 | + | input: "", | |
| 18 | + | want: "", | |
| 19 | + | }, | |
| 20 | + | { | |
| 21 | + | name: "http scheme unchanged", | |
| 22 | + | input: "http://example.com", | |
| 23 | + | want: "http://example.com", | |
| 24 | + | }, | |
| 25 | + | { | |
| 26 | + | name: "https scheme unchanged", | |
| 27 | + | input: "https://example.com", | |
| 28 | + | want: "https://example.com", | |
| 29 | + | }, | |
| 30 | + | { | |
| 31 | + | name: "file scheme unchanged", | |
| 32 | + | input: "file:///path/to/file.html", | |
| 33 | + | want: "file:///path/to/file.html", | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "dumb scheme unchanged", | |
| 37 | + | input: "dumb://home", | |
| 38 | + | want: "dumb://home", | |
| 39 | + | }, | |
| 40 | + | { | |
| 41 | + | name: "about scheme unchanged", | |
| 42 | + | input: "about:blank", | |
| 43 | + | want: "about:blank", | |
| 44 | + | }, | |
| 45 | + | { | |
| 46 | + | name: "domain gets https", | |
| 47 | + | input: "example.com", | |
| 48 | + | want: "https://example.com", | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | name: "domain with path gets https", | |
| 52 | + | input: "example.com/path", | |
| 53 | + | want: "https://example.com/path", | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | name: "search query unchanged", | |
| 57 | + | input: "hello world", | |
| 58 | + | want: "hello world", | |
| 59 | + | }, | |
| 60 | + | { | |
| 61 | + | name: "single word unchanged", | |
| 62 | + | input: "hello", | |
| 63 | + | want: "hello", | |
| 64 | + | }, | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | for _, tt := range tests { | |
| 68 | + | t.Run(tt.name, func(t *testing.T) { | |
| 69 | + | got := Normalize(tt.input) | |
| 70 | + | if got != tt.want { | |
| 71 | + | t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want) | |
| 72 | + | } | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestNormalize_LocalFiles(t *testing.T) { | |
| 78 | + | // Create a temporary file for testing | |
| 79 | + | tmpDir := t.TempDir() | |
| 80 | + | tmpFile := filepath.Join(tmpDir, "test.html") | |
| 81 | + | if err := os.WriteFile(tmpFile, []byte("<html></html>"), 0644); err != nil { | |
| 82 | + | t.Fatalf("failed to create temp file: %v", err) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Create a subdirectory with a file | |
| 86 | + | subDir := filepath.Join(tmpDir, "subdir") | |
| 87 | + | if err := os.Mkdir(subDir, 0755); err != nil { | |
| 88 | + | t.Fatalf("failed to create subdir: %v", err) | |
| 89 | + | } | |
| 90 | + | subFile := filepath.Join(subDir, "page.html") | |
| 91 | + | if err := os.WriteFile(subFile, []byte("<html></html>"), 0644); err != nil { | |
| 92 | + | t.Fatalf("failed to create subdir file: %v", err) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | tests := []struct { | |
| 96 | + | name string | |
| 97 | + | input string | |
| 98 | + | want string | |
| 99 | + | }{ | |
| 100 | + | { | |
| 101 | + | name: "absolute path to existing file", | |
| 102 | + | input: tmpFile, | |
| 103 | + | want: "file://" + tmpFile, | |
| 104 | + | }, | |
| 105 | + | { | |
| 106 | + | name: "absolute path to existing directory", | |
| 107 | + | input: tmpDir, | |
| 108 | + | want: "file://" + tmpDir, | |
| 109 | + | }, | |
| 110 | + | { | |
| 111 | + | name: "non-existent absolute path returned unchanged", | |
| 112 | + | input: "/nonexistent/file.html", | |
| 113 | + | want: "/nonexistent/file.html", | |
| 114 | + | }, | |
| 115 | + | { | |
| 116 | + | name: "non-existent relative path with dot prefix returned unchanged", | |
| 117 | + | input: "./nonexistent/file.html", | |
| 118 | + | want: "./nonexistent/file.html", | |
| 119 | + | }, | |
| 120 | + | { | |
| 121 | + | name: "non-existent home path returned unchanged", | |
| 122 | + | input: "~/nonexistent/file.html", | |
| 123 | + | want: "~/nonexistent/file.html", | |
| 124 | + | }, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | for _, tt := range tests { | |
| 128 | + | t.Run(tt.name, func(t *testing.T) { | |
| 129 | + | got := Normalize(tt.input) | |
| 130 | + | if got != tt.want { | |
| 131 | + | t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want) | |
| 132 | + | } | |
| 133 | + | }) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestNormalize_RelativePaths(t *testing.T) { | |
| 138 | + | // Create a temp file in current directory | |
| 139 | + | tmpFile, err := os.CreateTemp(".", "test-*.html") | |
| 140 | + | if err != nil { | |
| 141 | + | t.Fatalf("failed to create temp file: %v", err) | |
| 142 | + | } | |
| 143 | + | tmpFile.Close() | |
| 144 | + | defer os.Remove(tmpFile.Name()) | |
| 145 | + | ||
| 146 | + | // Get absolute path for comparison | |
| 147 | + | absPath, err := filepath.Abs(tmpFile.Name()) | |
| 148 | + | if err != nil { | |
| 149 | + | t.Fatalf("failed to get abs path: %v", err) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | t.Run("relative path to existing file", func(t *testing.T) { | |
| 153 | + | got := Normalize(tmpFile.Name()) | |
| 154 | + | want := "file://" + absPath | |
| 155 | + | if got != want { | |
| 156 | + | t.Errorf("Normalize(%q) = %q, want %q", tmpFile.Name(), got, want) | |
| 157 | + | } | |
| 158 | + | }) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestExpandHome(t *testing.T) { | |
| 162 | + | home, err := os.UserHomeDir() | |
| 163 | + | if err != nil { | |
| 164 | + | t.Skip("cannot get home directory") | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | tests := []struct { | |
| 168 | + | name string | |
| 169 | + | input string | |
| 170 | + | want string | |
| 171 | + | }{ | |
| 172 | + | { | |
| 173 | + | name: "tilde expansion", | |
| 174 | + | input: "~/Documents", | |
| 175 | + | want: filepath.Join(home, "Documents"), | |
| 176 | + | }, | |
| 177 | + | { | |
| 178 | + | name: "no tilde unchanged", | |
| 179 | + | input: "/absolute/path", | |
| 180 | + | want: "/absolute/path", | |
| 181 | + | }, | |
| 182 | + | { | |
| 183 | + | name: "relative path unchanged", | |
| 184 | + | input: "./relative/path", | |
| 185 | + | want: "./relative/path", | |
| 186 | + | }, | |
| 187 | + | { | |
| 188 | + | name: "tilde in middle unchanged", | |
| 189 | + | input: "/path/~/file", | |
| 190 | + | want: "/path/~/file", | |
| 191 | + | }, | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | for _, tt := range tests { | |
| 195 | + | t.Run(tt.name, func(t *testing.T) { | |
| 196 | + | got := expandHome(tt.input) | |
| 197 | + | if got != tt.want { | |
| 198 | + | t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want) | |
| 199 | + | } | |
| 200 | + | }) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func TestLooksLikeURL(t *testing.T) { | |
| 205 | + | tests := []struct { | |
| 206 | + | name string | |
| 207 | + | input string | |
| 208 | + | want bool | |
| 209 | + | }{ | |
| 210 | + | { | |
| 211 | + | name: "empty string", | |
| 212 | + | input: "", | |
| 213 | + | want: false, | |
| 214 | + | }, | |
| 215 | + | { | |
| 216 | + | name: "http URL", | |
| 217 | + | input: "http://example.com", | |
| 218 | + | want: true, | |
| 219 | + | }, | |
| 220 | + | { | |
| 221 | + | name: "https URL", | |
| 222 | + | input: "https://example.com", | |
| 223 | + | want: true, | |
| 224 | + | }, | |
| 225 | + | { | |
| 226 | + | name: "file URL", | |
| 227 | + | input: "file:///path/to/file", | |
| 228 | + | want: true, | |
| 229 | + | }, | |
| 230 | + | { | |
| 231 | + | name: "dumb URL", | |
| 232 | + | input: "dumb://home", | |
| 233 | + | want: true, | |
| 234 | + | }, | |
| 235 | + | { | |
| 236 | + | name: "about URL", | |
| 237 | + | input: "about:blank", | |
| 238 | + | want: true, | |
| 239 | + | }, | |
| 240 | + | { | |
| 241 | + | name: "domain", | |
| 242 | + | input: "example.com", | |
| 243 | + | want: true, | |
| 244 | + | }, | |
| 245 | + | { | |
| 246 | + | name: "domain with path", | |
| 247 | + | input: "example.com/path", | |
| 248 | + | want: true, | |
| 249 | + | }, | |
| 250 | + | { | |
| 251 | + | name: "search query", | |
| 252 | + | input: "hello world", | |
| 253 | + | want: false, | |
| 254 | + | }, | |
| 255 | + | { | |
| 256 | + | name: "single word", | |
| 257 | + | input: "hello", | |
| 258 | + | want: false, | |
| 259 | + | }, | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | for _, tt := range tests { | |
| 263 | + | t.Run(tt.name, func(t *testing.T) { | |
| 264 | + | got := LooksLikeURL(tt.input) | |
| 265 | + | if got != tt.want { | |
| 266 | + | t.Errorf("LooksLikeURL(%q) = %v, want %v", tt.input, got, tt.want) | |
| 267 | + | } | |
| 268 | + | }) | |
| 269 | + | } | |
| 270 | + | } | |
A internal/infrastructure/cache/lru.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | // Package cache provides cache implementations for the application layer. | |
| 2 | + | package cache | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "container/list" | |
| 6 | + | "sync" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // LRU is a thread-safe LRU (Least Recently Used) cache with a fixed capacity. | |
| 10 | + | // It implements port.Cache[K, V]. | |
| 11 | + | // | |
| 12 | + | // When the cache reaches capacity, the least recently accessed entry is evicted | |
| 13 | + | // to make room for new entries. Both Get and Set operations mark an entry as | |
| 14 | + | // recently used. | |
| 15 | + | type LRU[K comparable, V any] struct { | |
| 16 | + | capacity int | |
| 17 | + | mu sync.RWMutex | |
| 18 | + | items map[K]*list.Element | |
| 19 | + | order *list.List // Front = most recent, Back = least recent | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // entry holds a key-value pair in the LRU cache. | |
| 23 | + | type entry[K comparable, V any] struct { | |
| 24 | + | key K | |
| 25 | + | value V | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // NewLRU creates a new LRU cache with the given capacity. | |
| 29 | + | // Capacity must be positive; if zero or negative, a capacity of 1 is used. | |
| 30 | + | func NewLRU[K comparable, V any](capacity int) *LRU[K, V] { | |
| 31 | + | if capacity <= 0 { | |
| 32 | + | capacity = 1 | |
| 33 | + | } | |
| 34 | + | return &LRU[K, V]{ | |
| 35 | + | capacity: capacity, | |
| 36 | + | items: make(map[K]*list.Element), | |
| 37 | + | order: list.New(), | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Get retrieves a value by key and marks it as recently used. | |
| 42 | + | // Returns the value and true if found, or the zero value and false if not found. | |
| 43 | + | func (c *LRU[K, V]) Get(key K) (V, bool) { | |
| 44 | + | c.mu.Lock() | |
| 45 | + | defer c.mu.Unlock() | |
| 46 | + | ||
| 47 | + | if elem, ok := c.items[key]; ok { | |
| 48 | + | c.order.MoveToFront(elem) | |
| 49 | + | return elem.Value.(*entry[K, V]).value, true | |
| 50 | + | } | |
| 51 | + | var zero V | |
| 52 | + | return zero, false | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Set adds or updates a value in the cache. | |
| 56 | + | // If the key already exists, its value is updated and it's marked as recently used. | |
| 57 | + | // If the cache is at capacity, the least recently used entry is evicted. | |
| 58 | + | func (c *LRU[K, V]) Set(key K, value V) { | |
| 59 | + | c.mu.Lock() | |
| 60 | + | defer c.mu.Unlock() | |
| 61 | + | ||
| 62 | + | // Update existing entry | |
| 63 | + | if elem, ok := c.items[key]; ok { | |
| 64 | + | c.order.MoveToFront(elem) | |
| 65 | + | elem.Value.(*entry[K, V]).value = value | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Evict LRU entry if at capacity | |
| 70 | + | if c.order.Len() >= c.capacity { | |
| 71 | + | oldest := c.order.Back() | |
| 72 | + | if oldest != nil { | |
| 73 | + | c.order.Remove(oldest) | |
| 74 | + | delete(c.items, oldest.Value.(*entry[K, V]).key) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Add new entry at front (most recent) | |
| 79 | + | elem := c.order.PushFront(&entry[K, V]{key: key, value: value}) | |
| 80 | + | c.items[key] = elem | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Remove deletes a key from the cache. | |
| 84 | + | // If the key doesn't exist, this is a no-op. | |
| 85 | + | func (c *LRU[K, V]) Remove(key K) { | |
| 86 | + | c.mu.Lock() | |
| 87 | + | defer c.mu.Unlock() | |
| 88 | + | ||
| 89 | + | if elem, ok := c.items[key]; ok { | |
| 90 | + | c.order.Remove(elem) | |
| 91 | + | delete(c.items, key) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Len returns the number of items currently in the cache. | |
| 96 | + | func (c *LRU[K, V]) Len() int { | |
| 97 | + | c.mu.RLock() | |
| 98 | + | defer c.mu.RUnlock() | |
| 99 | + | return c.order.Len() | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // Clear removes all items from the cache. | |
| 103 | + | func (c *LRU[K, V]) Clear() { | |
| 104 | + | c.mu.Lock() | |
| 105 | + | defer c.mu.Unlock() | |
| 106 | + | ||
| 107 | + | c.items = make(map[K]*list.Element) | |
| 108 | + | c.order.Init() | |
| 109 | + | } | |
A internal/infrastructure/cache/lru_test.go +212 -0
| @@ -0,0 +1,212 @@ | |||
| 1 | + | package cache | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestLRU_BasicOperations(t *testing.T) { | |
| 12 | + | cache := NewLRU[string, int](3) | |
| 13 | + | ||
| 14 | + | // Test Set and Get | |
| 15 | + | cache.Set("a", 1) | |
| 16 | + | cache.Set("b", 2) | |
| 17 | + | cache.Set("c", 3) | |
| 18 | + | ||
| 19 | + | val, ok := cache.Get("a") | |
| 20 | + | assert.True(t, ok) | |
| 21 | + | assert.Equal(t, 1, val) | |
| 22 | + | ||
| 23 | + | val, ok = cache.Get("b") | |
| 24 | + | assert.True(t, ok) | |
| 25 | + | assert.Equal(t, 2, val) | |
| 26 | + | ||
| 27 | + | // Test not found | |
| 28 | + | val, ok = cache.Get("notfound") | |
| 29 | + | assert.False(t, ok) | |
| 30 | + | assert.Equal(t, 0, val) | |
| 31 | + | ||
| 32 | + | assert.Equal(t, 3, cache.Len()) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestLRU_Eviction(t *testing.T) { | |
| 36 | + | cache := NewLRU[string, int](2) | |
| 37 | + | ||
| 38 | + | cache.Set("a", 1) | |
| 39 | + | cache.Set("b", 2) | |
| 40 | + | // Cache is now at capacity: [b, a] (b is most recent) | |
| 41 | + | ||
| 42 | + | // Adding "c" should evict "a" (least recently used) | |
| 43 | + | cache.Set("c", 3) | |
| 44 | + | ||
| 45 | + | _, ok := cache.Get("a") | |
| 46 | + | assert.False(t, ok, "a should have been evicted") | |
| 47 | + | ||
| 48 | + | val, ok := cache.Get("b") | |
| 49 | + | assert.True(t, ok) | |
| 50 | + | assert.Equal(t, 2, val) | |
| 51 | + | ||
| 52 | + | val, ok = cache.Get("c") | |
| 53 | + | assert.True(t, ok) | |
| 54 | + | assert.Equal(t, 3, val) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestLRU_GetUpdatesRecency(t *testing.T) { | |
| 58 | + | cache := NewLRU[string, int](2) | |
| 59 | + | ||
| 60 | + | cache.Set("a", 1) | |
| 61 | + | cache.Set("b", 2) | |
| 62 | + | // Order: [b, a] | |
| 63 | + | ||
| 64 | + | // Access "a" to make it most recent | |
| 65 | + | cache.Get("a") | |
| 66 | + | // Order: [a, b] | |
| 67 | + | ||
| 68 | + | // Adding "c" should now evict "b" (least recently used) | |
| 69 | + | cache.Set("c", 3) | |
| 70 | + | ||
| 71 | + | val, ok := cache.Get("a") | |
| 72 | + | assert.True(t, ok, "a should still exist") | |
| 73 | + | assert.Equal(t, 1, val) | |
| 74 | + | ||
| 75 | + | _, ok = cache.Get("b") | |
| 76 | + | assert.False(t, ok, "b should have been evicted") | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestLRU_UpdateExisting(t *testing.T) { | |
| 80 | + | cache := NewLRU[string, int](2) | |
| 81 | + | ||
| 82 | + | cache.Set("a", 1) | |
| 83 | + | cache.Set("b", 2) | |
| 84 | + | ||
| 85 | + | // Update "a" with new value | |
| 86 | + | cache.Set("a", 100) | |
| 87 | + | ||
| 88 | + | val, ok := cache.Get("a") | |
| 89 | + | assert.True(t, ok) | |
| 90 | + | assert.Equal(t, 100, val) | |
| 91 | + | ||
| 92 | + | // Cache should still have 2 items (no duplicate) | |
| 93 | + | assert.Equal(t, 2, cache.Len()) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestLRU_Remove(t *testing.T) { | |
| 97 | + | cache := NewLRU[string, int](3) | |
| 98 | + | ||
| 99 | + | cache.Set("a", 1) | |
| 100 | + | cache.Set("b", 2) | |
| 101 | + | cache.Set("c", 3) | |
| 102 | + | ||
| 103 | + | cache.Remove("b") | |
| 104 | + | ||
| 105 | + | _, ok := cache.Get("b") | |
| 106 | + | assert.False(t, ok) | |
| 107 | + | assert.Equal(t, 2, cache.Len()) | |
| 108 | + | ||
| 109 | + | // Remove non-existent key should not panic | |
| 110 | + | cache.Remove("notfound") | |
| 111 | + | assert.Equal(t, 2, cache.Len()) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestLRU_Clear(t *testing.T) { | |
| 115 | + | cache := NewLRU[string, int](3) | |
| 116 | + | ||
| 117 | + | cache.Set("a", 1) | |
| 118 | + | cache.Set("b", 2) | |
| 119 | + | cache.Set("c", 3) | |
| 120 | + | ||
| 121 | + | cache.Clear() | |
| 122 | + | ||
| 123 | + | assert.Equal(t, 0, cache.Len()) | |
| 124 | + | _, ok := cache.Get("a") | |
| 125 | + | assert.False(t, ok) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestLRU_ZeroCapacity(t *testing.T) { | |
| 129 | + | // Should default to capacity of 1 | |
| 130 | + | cache := NewLRU[string, int](0) | |
| 131 | + | ||
| 132 | + | cache.Set("a", 1) | |
| 133 | + | val, ok := cache.Get("a") | |
| 134 | + | assert.True(t, ok) | |
| 135 | + | assert.Equal(t, 1, val) | |
| 136 | + | ||
| 137 | + | // Adding another should evict first | |
| 138 | + | cache.Set("b", 2) | |
| 139 | + | _, ok = cache.Get("a") | |
| 140 | + | assert.False(t, ok) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestLRU_ConcurrentAccess(t *testing.T) { | |
| 144 | + | cache := NewLRU[int, int](100) | |
| 145 | + | var wg sync.WaitGroup | |
| 146 | + | ||
| 147 | + | // Concurrent writes | |
| 148 | + | for i := 0; i < 100; i++ { | |
| 149 | + | wg.Add(1) | |
| 150 | + | go func(i int) { | |
| 151 | + | defer wg.Done() | |
| 152 | + | cache.Set(i, i*10) | |
| 153 | + | }(i) | |
| 154 | + | } | |
| 155 | + | wg.Wait() | |
| 156 | + | ||
| 157 | + | // Concurrent reads | |
| 158 | + | for i := 0; i < 100; i++ { | |
| 159 | + | wg.Add(1) | |
| 160 | + | go func(i int) { | |
| 161 | + | defer wg.Done() | |
| 162 | + | cache.Get(i) | |
| 163 | + | }(i) | |
| 164 | + | } | |
| 165 | + | wg.Wait() | |
| 166 | + | ||
| 167 | + | // Concurrent mixed operations | |
| 168 | + | for i := 0; i < 50; i++ { | |
| 169 | + | wg.Add(3) | |
| 170 | + | go func(i int) { | |
| 171 | + | defer wg.Done() | |
| 172 | + | cache.Set(i+100, i) | |
| 173 | + | }(i) | |
| 174 | + | go func(i int) { | |
| 175 | + | defer wg.Done() | |
| 176 | + | cache.Get(i) | |
| 177 | + | }(i) | |
| 178 | + | go func(i int) { | |
| 179 | + | defer wg.Done() | |
| 180 | + | cache.Remove(i + 50) | |
| 181 | + | }(i) | |
| 182 | + | } | |
| 183 | + | wg.Wait() | |
| 184 | + | ||
| 185 | + | // Should not panic and cache should be in valid state | |
| 186 | + | require.LessOrEqual(t, cache.Len(), 100) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func TestLRU_PointerValues(t *testing.T) { | |
| 190 | + | type Data struct { | |
| 191 | + | Name string | |
| 192 | + | Value float64 | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | cache := NewLRU[string, *Data](2) | |
| 196 | + | ||
| 197 | + | cache.Set("key1", &Data{Name: "test1", Value: 1.5}) | |
| 198 | + | cache.Set("key2", &Data{Name: "test2", Value: 2.5}) | |
| 199 | + | ||
| 200 | + | val, ok := cache.Get("key1") | |
| 201 | + | require.True(t, ok) | |
| 202 | + | assert.Equal(t, "test1", val.Name) | |
| 203 | + | assert.InDelta(t, 1.5, val.Value, 0.001) | |
| 204 | + | ||
| 205 | + | // Update pointer value | |
| 206 | + | cache.Set("key1", &Data{Name: "updated", Value: 99.9}) | |
| 207 | + | ||
| 208 | + | val, ok = cache.Get("key1") | |
| 209 | + | require.True(t, ok) | |
| 210 | + | assert.Equal(t, "updated", val.Name) | |
| 211 | + | assert.InDelta(t, 99.9, val.Value, 0.001) | |
| 212 | + | } | |
A internal/infrastructure/colorscheme/config_adapter.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package colorscheme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | // ConfigAdapter adapts config.Config to the ConfigProvider interface. | |
| 8 | + | type ConfigAdapter struct { | |
| 9 | + | cfg *config.Config | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | // NewConfigAdapter creates a new config adapter. | |
| 13 | + | func NewConfigAdapter(cfg *config.Config) *ConfigAdapter { | |
| 14 | + | return &ConfigAdapter{cfg: cfg} | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // GetColorScheme implements ConfigProvider. | |
| 18 | + | func (a *ConfigAdapter) GetColorScheme() string { | |
| 19 | + | if a.cfg == nil { | |
| 20 | + | return "" | |
| 21 | + | } | |
| 22 | + | return a.cfg.Appearance.ColorScheme | |
| 23 | + | } | |
A internal/infrastructure/colorscheme/detector_adwaita.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | package colorscheme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync/atomic" | |
| 5 | + | ||
| 6 | + | "github.com/jwijenbergh/puregotk/v4/adw" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | detectorNameAdwaita = "libadwaita" | |
| 11 | + | priorityAdwaita = 100 | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // AdwaitaDetector detects color scheme from libadwaita's StyleManager. | |
| 15 | + | // This is the most accurate detector after adw.Init() has been called, | |
| 16 | + | // as it reflects the same state that WebKit uses for prefers-color-scheme. | |
| 17 | + | type AdwaitaDetector struct { | |
| 18 | + | available atomic.Bool | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // NewAdwaitaDetector creates a new libadwaita-based detector. | |
| 22 | + | // Initially unavailable until MarkAvailable() is called after adw.Init(). | |
| 23 | + | func NewAdwaitaDetector() *AdwaitaDetector { | |
| 24 | + | return &AdwaitaDetector{} | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Name implements port.ColorSchemeDetector. | |
| 28 | + | func (*AdwaitaDetector) Name() string { | |
| 29 | + | return detectorNameAdwaita | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Priority implements port.ColorSchemeDetector. | |
| 33 | + | func (*AdwaitaDetector) Priority() int { | |
| 34 | + | return priorityAdwaita | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Available implements port.ColorSchemeDetector. | |
| 38 | + | // Returns true only after MarkAvailable() has been called. | |
| 39 | + | func (d *AdwaitaDetector) Available() bool { | |
| 40 | + | return d.available.Load() | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // MarkAvailable should be called after adw.Init() completes. | |
| 44 | + | // This enables the detector to query StyleManager. | |
| 45 | + | func (d *AdwaitaDetector) MarkAvailable() { | |
| 46 | + | d.available.Store(true) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Detect implements port.ColorSchemeDetector. | |
| 50 | + | // Queries libadwaita's StyleManager for the dark mode preference. | |
| 51 | + | func (d *AdwaitaDetector) Detect() (prefersDark, ok bool) { | |
| 52 | + | if !d.Available() { | |
| 53 | + | return false, false | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | styleMgr := adw.StyleManagerGetDefault() | |
| 57 | + | if styleMgr == nil { | |
| 58 | + | return false, false | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | return styleMgr.GetDark(), true | |
| 62 | + | } | |
A internal/infrastructure/colorscheme/detector_env.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package colorscheme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | detectorNameEnv = "GTK_THEME" | |
| 10 | + | priorityEnv = 20 | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // EnvDetector detects color scheme from GTK_THEME environment variable. | |
| 14 | + | // This is useful when users explicitly set their theme via environment. | |
| 15 | + | type EnvDetector struct{} | |
| 16 | + | ||
| 17 | + | // NewEnvDetector creates a new environment variable-based detector. | |
| 18 | + | func NewEnvDetector() *EnvDetector { | |
| 19 | + | return &EnvDetector{} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // Name implements port.ColorSchemeDetector. | |
| 23 | + | func (*EnvDetector) Name() string { | |
| 24 | + | return detectorNameEnv | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Priority implements port.ColorSchemeDetector. | |
| 28 | + | func (*EnvDetector) Priority() int { | |
| 29 | + | return priorityEnv | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Available implements port.ColorSchemeDetector. | |
| 33 | + | // Returns true if GTK_THEME environment variable is set. | |
| 34 | + | func (*EnvDetector) Available() bool { | |
| 35 | + | return os.Getenv("GTK_THEME") != "" | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Detect implements port.ColorSchemeDetector. | |
| 39 | + | // Checks if GTK_THEME contains "dark" (case-insensitive). | |
| 40 | + | func (*EnvDetector) Detect() (prefersDark, ok bool) { | |
| 41 | + | gtkTheme := os.Getenv("GTK_THEME") | |
| 42 | + | if gtkTheme == "" { | |
| 43 | + | return false, false | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // If GTK_THEME contains "dark", prefer dark mode | |
| 47 | + | // Otherwise, assume light mode | |
| 48 | + | prefersDark = strings.Contains(strings.ToLower(gtkTheme), "dark") | |
| 49 | + | return prefersDark, true | |
| 50 | + | } | |
A internal/infrastructure/colorscheme/detector_gsettings.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package colorscheme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os/exec" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | detectorNameGsettings = "gsettings" | |
| 10 | + | priorityGsettings = 10 | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // GsettingsDetector detects color scheme from GNOME gsettings. | |
| 14 | + | // This is the most reliable method for GNOME-based desktops. | |
| 15 | + | type GsettingsDetector struct{} | |
| 16 | + | ||
| 17 | + | // NewGsettingsDetector creates a new gsettings-based detector. | |
| 18 | + | func NewGsettingsDetector() *GsettingsDetector { | |
| 19 | + | return &GsettingsDetector{} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // Name implements port.ColorSchemeDetector. | |
| 23 | + | func (*GsettingsDetector) Name() string { | |
| 24 | + | return detectorNameGsettings | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Priority implements port.ColorSchemeDetector. | |
| 28 | + | func (*GsettingsDetector) Priority() int { | |
| 29 | + | return priorityGsettings | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Available implements port.ColorSchemeDetector. | |
| 33 | + | // Returns true if gsettings command is available. | |
| 34 | + | func (*GsettingsDetector) Available() bool { | |
| 35 | + | _, err := exec.LookPath("gsettings") | |
| 36 | + | return err == nil | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Detect implements port.ColorSchemeDetector. | |
| 40 | + | // Queries org.gnome.desktop.interface color-scheme. | |
| 41 | + | func (*GsettingsDetector) Detect() (prefersDark, ok bool) { | |
| 42 | + | cmd := exec.Command("gsettings", "get", "org.gnome.desktop.interface", "color-scheme") | |
| 43 | + | output, err := cmd.Output() | |
| 44 | + | if err != nil { | |
| 45 | + | return false, false | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Output is like "'prefer-dark'\n", strip quotes and whitespace | |
| 49 | + | result := strings.TrimSpace(string(output)) | |
| 50 | + | result = strings.Trim(result, "'\"") | |
| 51 | + | ||
| 52 | + | switch result { | |
| 53 | + | case "prefer-dark": | |
| 54 | + | return true, true | |
| 55 | + | case "prefer-light": | |
| 56 | + | return false, true | |
| 57 | + | case "default": | |
| 58 | + | // "default" means follow system, which we can't determine here | |
| 59 | + | return false, false | |
| 60 | + | default: | |
| 61 | + | return false, false | |
| 62 | + | } | |
| 63 | + | } | |
A internal/infrastructure/colorscheme/resolver.go +170 -0
| @@ -0,0 +1,170 @@ | |||
| 1 | + | package colorscheme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "cmp" | |
| 5 | + | "slices" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | // sourceFallback indicates no detector provided the preference. | |
| 14 | + | sourceFallback = "fallback" | |
| 15 | + | // sourceConfig indicates the preference came from user config. | |
| 16 | + | sourceConfig = "config" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // ConfigProvider provides access to the color scheme configuration. | |
| 20 | + | type ConfigProvider interface { | |
| 21 | + | // GetColorScheme returns the configured color scheme preference. | |
| 22 | + | // Expected values: "default", "prefer-dark", "prefer-light", "dark", "light" | |
| 23 | + | GetColorScheme() string | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // callbackWrapper wraps a callback function to enable pointer comparison for removal. | |
| 27 | + | type callbackWrapper struct { | |
| 28 | + | fn func(port.ColorSchemePreference) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Resolver implements port.ColorSchemeResolver. | |
| 32 | + | // It manages multiple detectors and respects config overrides. | |
| 33 | + | type Resolver struct { | |
| 34 | + | mu sync.RWMutex | |
| 35 | + | config ConfigProvider | |
| 36 | + | detectors []port.ColorSchemeDetector | |
| 37 | + | sortedDetectors []port.ColorSchemeDetector // cached sorted detectors | |
| 38 | + | current port.ColorSchemePreference | |
| 39 | + | callbacks []*callbackWrapper | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // NewResolver creates a new color scheme resolver. | |
| 43 | + | // The config provider is used to check for explicit user preferences. | |
| 44 | + | func NewResolver(config ConfigProvider) *Resolver { | |
| 45 | + | return &Resolver{ | |
| 46 | + | config: config, | |
| 47 | + | detectors: make([]port.ColorSchemeDetector, 0), | |
| 48 | + | current: port.ColorSchemePreference{ | |
| 49 | + | PrefersDark: true, // Default to dark until first Resolve() | |
| 50 | + | Source: sourceFallback, | |
| 51 | + | }, | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Resolve implements port.ColorSchemeResolver. | |
| 56 | + | func (r *Resolver) Resolve() port.ColorSchemePreference { | |
| 57 | + | r.mu.RLock() | |
| 58 | + | defer r.mu.RUnlock() | |
| 59 | + | return r.resolveInternal() | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // resolveInternal performs the actual resolution without locking. | |
| 63 | + | // Caller must hold at least a read lock. | |
| 64 | + | func (r *Resolver) resolveInternal() port.ColorSchemePreference { | |
| 65 | + | // Check config for explicit override first | |
| 66 | + | if r.config != nil { | |
| 67 | + | scheme := r.config.GetColorScheme() | |
| 68 | + | switch strings.ToLower(scheme) { | |
| 69 | + | case "prefer-dark", "dark": | |
| 70 | + | return port.ColorSchemePreference{ | |
| 71 | + | PrefersDark: true, | |
| 72 | + | Source: sourceConfig, | |
| 73 | + | } | |
| 74 | + | case "prefer-light", "light": | |
| 75 | + | return port.ColorSchemePreference{ | |
| 76 | + | PrefersDark: false, | |
| 77 | + | Source: sourceConfig, | |
| 78 | + | } | |
| 79 | + | // "default" or empty falls through to detector chain | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Try each detector in priority order (already sorted) | |
| 84 | + | for _, detector := range r.sortedDetectors { | |
| 85 | + | if !detector.Available() { | |
| 86 | + | continue | |
| 87 | + | } | |
| 88 | + | if prefersDark, ok := detector.Detect(); ok { | |
| 89 | + | return port.ColorSchemePreference{ | |
| 90 | + | PrefersDark: prefersDark, | |
| 91 | + | Source: detector.Name(), | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // Fallback to dark mode if all detectors fail | |
| 97 | + | return port.ColorSchemePreference{ | |
| 98 | + | PrefersDark: true, | |
| 99 | + | Source: sourceFallback, | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // RegisterDetector implements port.ColorSchemeResolver. | |
| 104 | + | func (r *Resolver) RegisterDetector(detector port.ColorSchemeDetector) { | |
| 105 | + | r.mu.Lock() | |
| 106 | + | defer r.mu.Unlock() | |
| 107 | + | r.detectors = append(r.detectors, detector) | |
| 108 | + | r.rebuildSortedDetectors() | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // rebuildSortedDetectors creates a sorted copy of detectors by priority (highest first). | |
| 112 | + | // Caller must hold the write lock. | |
| 113 | + | func (r *Resolver) rebuildSortedDetectors() { | |
| 114 | + | r.sortedDetectors = make([]port.ColorSchemeDetector, len(r.detectors)) | |
| 115 | + | copy(r.sortedDetectors, r.detectors) | |
| 116 | + | slices.SortFunc(r.sortedDetectors, func(a, b port.ColorSchemeDetector) int { | |
| 117 | + | return cmp.Compare(b.Priority(), a.Priority()) // descending order | |
| 118 | + | }) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // Refresh implements port.ColorSchemeResolver. | |
| 122 | + | func (r *Resolver) Refresh() port.ColorSchemePreference { | |
| 123 | + | r.mu.Lock() | |
| 124 | + | ||
| 125 | + | newPref := r.resolveInternal() | |
| 126 | + | ||
| 127 | + | // Prepare callbacks to invoke if preference changed | |
| 128 | + | var callbacks []*callbackWrapper | |
| 129 | + | if newPref.PrefersDark != r.current.PrefersDark { | |
| 130 | + | r.current = newPref | |
| 131 | + | callbacks = make([]*callbackWrapper, len(r.callbacks)) | |
| 132 | + | copy(callbacks, r.callbacks) | |
| 133 | + | } else { | |
| 134 | + | r.current = newPref | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Release lock before invoking callbacks to avoid deadlocks | |
| 138 | + | r.mu.Unlock() | |
| 139 | + | ||
| 140 | + | // Invoke callbacks outside of lock | |
| 141 | + | for _, cb := range callbacks { | |
| 142 | + | cb.fn(newPref) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | return newPref | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // OnChange implements port.ColorSchemeResolver. | |
| 149 | + | func (r *Resolver) OnChange(callback func(port.ColorSchemePreference)) func() { | |
| 150 | + | r.mu.Lock() | |
| 151 | + | defer r.mu.Unlock() | |
| 152 | + | ||
| 153 | + | // Wrap callback to enable pointer comparison for removal | |
| 154 | + | wrapper := &callbackWrapper{fn: callback} | |
| 155 | + | r.callbacks = append(r.callbacks, wrapper) | |
| 156 | + | ||
| 157 | + | // Return unregister function | |
| 158 | + | return func() { | |
| 159 | + | r.mu.Lock() | |
| 160 | + | defer r.mu.Unlock() | |
| 161 | + | ||
| 162 | + | // Find and remove callback by pointer equality | |
| 163 | + | for i, cb := range r.callbacks { | |
| 164 | + | if cb == wrapper { | |
| 165 | + | r.callbacks = append(r.callbacks[:i], r.callbacks[i+1:]...) | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | } | |
A internal/infrastructure/colorscheme/resolver_test.go +425 -0
| @@ -0,0 +1,425 @@ | |||
| 1 | + | package colorscheme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // mockConfigProvider implements ConfigProvider for testing. | |
| 13 | + | type mockConfigProvider struct { | |
| 14 | + | scheme string | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func (m *mockConfigProvider) GetColorScheme() string { | |
| 18 | + | return m.scheme | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // mockDetector implements port.ColorSchemeDetector for testing. | |
| 22 | + | type mockDetector struct { | |
| 23 | + | name string | |
| 24 | + | priority int | |
| 25 | + | available bool | |
| 26 | + | prefersDark bool | |
| 27 | + | detectOk bool | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (m *mockDetector) Name() string { return m.name } | |
| 31 | + | func (m *mockDetector) Priority() int { return m.priority } | |
| 32 | + | func (m *mockDetector) Available() bool { return m.available } | |
| 33 | + | func (m *mockDetector) Detect() (bool, bool) { return m.prefersDark, m.detectOk } | |
| 34 | + | ||
| 35 | + | func TestResolver_ConfigOverride(t *testing.T) { | |
| 36 | + | tests := []struct { | |
| 37 | + | name string | |
| 38 | + | configValue string | |
| 39 | + | wantDark bool | |
| 40 | + | wantSource string | |
| 41 | + | }{ | |
| 42 | + | { | |
| 43 | + | name: "prefer-dark from config", | |
| 44 | + | configValue: "prefer-dark", | |
| 45 | + | wantDark: true, | |
| 46 | + | wantSource: "config", | |
| 47 | + | }, | |
| 48 | + | { | |
| 49 | + | name: "dark from config", | |
| 50 | + | configValue: "dark", | |
| 51 | + | wantDark: true, | |
| 52 | + | wantSource: "config", | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "prefer-light from config", | |
| 56 | + | configValue: "prefer-light", | |
| 57 | + | wantDark: false, | |
| 58 | + | wantSource: "config", | |
| 59 | + | }, | |
| 60 | + | { | |
| 61 | + | name: "light from config", | |
| 62 | + | configValue: "light", | |
| 63 | + | wantDark: false, | |
| 64 | + | wantSource: "config", | |
| 65 | + | }, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | for _, tt := range tests { | |
| 69 | + | t.Run(tt.name, func(t *testing.T) { | |
| 70 | + | config := &mockConfigProvider{scheme: tt.configValue} | |
| 71 | + | resolver := NewResolver(config) | |
| 72 | + | ||
| 73 | + | pref := resolver.Resolve() | |
| 74 | + | ||
| 75 | + | assert.Equal(t, tt.wantDark, pref.PrefersDark) | |
| 76 | + | assert.Equal(t, tt.wantSource, pref.Source) | |
| 77 | + | }) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestResolver_DefaultFallsThrough(t *testing.T) { | |
| 82 | + | config := &mockConfigProvider{scheme: "default"} | |
| 83 | + | resolver := NewResolver(config) | |
| 84 | + | ||
| 85 | + | // Add a detector that returns light | |
| 86 | + | detector := &mockDetector{ | |
| 87 | + | name: "test", | |
| 88 | + | priority: 50, | |
| 89 | + | available: true, | |
| 90 | + | prefersDark: false, | |
| 91 | + | detectOk: true, | |
| 92 | + | } | |
| 93 | + | resolver.RegisterDetector(detector) | |
| 94 | + | ||
| 95 | + | pref := resolver.Resolve() | |
| 96 | + | ||
| 97 | + | assert.False(t, pref.PrefersDark) | |
| 98 | + | assert.Equal(t, "test", pref.Source) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestResolver_DetectorPriority(t *testing.T) { | |
| 102 | + | config := &mockConfigProvider{scheme: "default"} | |
| 103 | + | resolver := NewResolver(config) | |
| 104 | + | ||
| 105 | + | // Low priority detector returns dark | |
| 106 | + | lowPriority := &mockDetector{ | |
| 107 | + | name: "low", | |
| 108 | + | priority: 10, | |
| 109 | + | available: true, | |
| 110 | + | prefersDark: true, | |
| 111 | + | detectOk: true, | |
| 112 | + | } | |
| 113 | + | // High priority detector returns light | |
| 114 | + | highPriority := &mockDetector{ | |
| 115 | + | name: "high", | |
| 116 | + | priority: 100, | |
| 117 | + | available: true, | |
| 118 | + | prefersDark: false, | |
| 119 | + | detectOk: true, | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // Register low first, high second (order shouldn't matter) | |
| 123 | + | resolver.RegisterDetector(lowPriority) | |
| 124 | + | resolver.RegisterDetector(highPriority) | |
| 125 | + | ||
| 126 | + | pref := resolver.Resolve() | |
| 127 | + | ||
| 128 | + | // Should use high priority detector | |
| 129 | + | assert.False(t, pref.PrefersDark) | |
| 130 | + | assert.Equal(t, "high", pref.Source) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestResolver_SkipsUnavailableDetector(t *testing.T) { | |
| 134 | + | config := &mockConfigProvider{scheme: "default"} | |
| 135 | + | resolver := NewResolver(config) | |
| 136 | + | ||
| 137 | + | // High priority but unavailable | |
| 138 | + | unavailable := &mockDetector{ | |
| 139 | + | name: "unavailable", | |
| 140 | + | priority: 100, | |
| 141 | + | available: false, | |
| 142 | + | prefersDark: false, | |
| 143 | + | detectOk: true, | |
| 144 | + | } | |
| 145 | + | // Low priority but available | |
| 146 | + | available := &mockDetector{ | |
| 147 | + | name: "available", | |
| 148 | + | priority: 10, | |
| 149 | + | available: true, | |
| 150 | + | prefersDark: true, | |
| 151 | + | detectOk: true, | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | resolver.RegisterDetector(unavailable) | |
| 155 | + | resolver.RegisterDetector(available) | |
| 156 | + | ||
| 157 | + | pref := resolver.Resolve() | |
| 158 | + | ||
| 159 | + | // Should use available detector | |
| 160 | + | assert.True(t, pref.PrefersDark) | |
| 161 | + | assert.Equal(t, "available", pref.Source) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestResolver_SkipsFailedDetection(t *testing.T) { | |
| 165 | + | config := &mockConfigProvider{scheme: "default"} | |
| 166 | + | resolver := NewResolver(config) | |
| 167 | + | ||
| 168 | + | // High priority but detection fails | |
| 169 | + | failing := &mockDetector{ | |
| 170 | + | name: "failing", | |
| 171 | + | priority: 100, | |
| 172 | + | available: true, | |
| 173 | + | prefersDark: false, | |
| 174 | + | detectOk: false, // Detection fails | |
| 175 | + | } | |
| 176 | + | // Low priority but succeeds | |
| 177 | + | succeeding := &mockDetector{ | |
| 178 | + | name: "succeeding", | |
| 179 | + | priority: 10, | |
| 180 | + | available: true, | |
| 181 | + | prefersDark: true, | |
| 182 | + | detectOk: true, | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | resolver.RegisterDetector(failing) | |
| 186 | + | resolver.RegisterDetector(succeeding) | |
| 187 | + | ||
| 188 | + | pref := resolver.Resolve() | |
| 189 | + | ||
| 190 | + | // Should use succeeding detector | |
| 191 | + | assert.True(t, pref.PrefersDark) | |
| 192 | + | assert.Equal(t, "succeeding", pref.Source) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func TestResolver_FallbackWhenNoDetectors(t *testing.T) { | |
| 196 | + | config := &mockConfigProvider{scheme: "default"} | |
| 197 | + | resolver := NewResolver(config) | |
| 198 | + | ||
| 199 | + | // No detectors registered | |
| 200 | + | pref := resolver.Resolve() | |
| 201 | + | ||
| 202 | + | // Should fallback to dark | |
| 203 | + | assert.True(t, pref.PrefersDark) | |
| 204 | + | assert.Equal(t, "fallback", pref.Source) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func TestResolver_FallbackWhenAllFail(t *testing.T) { | |
| 208 | + | config := &mockConfigProvider{scheme: "default"} | |
| 209 | + | resolver := NewResolver(config) | |
| 210 | + | ||
| 211 | + | // All detectors fail | |
| 212 | + | resolver.RegisterDetector(&mockDetector{ | |
| 213 | + | name: "fail1", | |
| 214 | + | priority: 100, | |
| 215 | + | available: true, | |
| 216 | + | detectOk: false, | |
| 217 | + | }) | |
| 218 | + | resolver.RegisterDetector(&mockDetector{ | |
| 219 | + | name: "fail2", | |
| 220 | + | priority: 50, | |
| 221 | + | available: false, | |
| 222 | + | }) | |
| 223 | + | ||
| 224 | + | pref := resolver.Resolve() | |
| 225 | + | ||
| 226 | + | // Should fallback to dark | |
| 227 | + | assert.True(t, pref.PrefersDark) | |
| 228 | + | assert.Equal(t, "fallback", pref.Source) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func TestResolver_NilConfig(t *testing.T) { | |
| 232 | + | resolver := NewResolver(nil) | |
| 233 | + | ||
| 234 | + | detector := &mockDetector{ | |
| 235 | + | name: "test", | |
| 236 | + | priority: 50, | |
| 237 | + | available: true, | |
| 238 | + | prefersDark: false, | |
| 239 | + | detectOk: true, | |
| 240 | + | } | |
| 241 | + | resolver.RegisterDetector(detector) | |
| 242 | + | ||
| 243 | + | pref := resolver.Resolve() | |
| 244 | + | ||
| 245 | + | // Should use detector since config is nil | |
| 246 | + | assert.False(t, pref.PrefersDark) | |
| 247 | + | assert.Equal(t, "test", pref.Source) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func TestResolver_Refresh(t *testing.T) { | |
| 251 | + | config := &mockConfigProvider{scheme: "default"} | |
| 252 | + | resolver := NewResolver(config) | |
| 253 | + | ||
| 254 | + | // Start with light-preferring detector | |
| 255 | + | detector := &mockDetector{ | |
| 256 | + | name: "test", | |
| 257 | + | priority: 50, | |
| 258 | + | available: true, | |
| 259 | + | prefersDark: false, | |
| 260 | + | detectOk: true, | |
| 261 | + | } | |
| 262 | + | resolver.RegisterDetector(detector) | |
| 263 | + | ||
| 264 | + | pref1 := resolver.Refresh() | |
| 265 | + | assert.False(t, pref1.PrefersDark) | |
| 266 | + | ||
| 267 | + | // Change detector preference | |
| 268 | + | detector.prefersDark = true | |
| 269 | + | ||
| 270 | + | pref2 := resolver.Refresh() | |
| 271 | + | assert.True(t, pref2.PrefersDark) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func TestResolver_OnChange(t *testing.T) { | |
| 275 | + | config := &mockConfigProvider{scheme: "default"} | |
| 276 | + | resolver := NewResolver(config) | |
| 277 | + | ||
| 278 | + | detector := &mockDetector{ | |
| 279 | + | name: "test", | |
| 280 | + | priority: 50, | |
| 281 | + | available: true, | |
| 282 | + | prefersDark: false, | |
| 283 | + | detectOk: true, | |
| 284 | + | } | |
| 285 | + | resolver.RegisterDetector(detector) | |
| 286 | + | ||
| 287 | + | var callbackPref port.ColorSchemePreference | |
| 288 | + | var callbackCount int | |
| 289 | + | resolver.OnChange(func(pref port.ColorSchemePreference) { | |
| 290 | + | callbackPref = pref | |
| 291 | + | callbackCount++ | |
| 292 | + | }) | |
| 293 | + | ||
| 294 | + | // Initial refresh - callback should be called (default was dark, now light) | |
| 295 | + | resolver.Refresh() | |
| 296 | + | assert.Equal(t, 1, callbackCount) | |
| 297 | + | assert.False(t, callbackPref.PrefersDark) | |
| 298 | + | ||
| 299 | + | // Same preference - callback should NOT be called | |
| 300 | + | resolver.Refresh() | |
| 301 | + | assert.Equal(t, 1, callbackCount) | |
| 302 | + | ||
| 303 | + | // Change preference - callback should be called | |
| 304 | + | detector.prefersDark = true | |
| 305 | + | resolver.Refresh() | |
| 306 | + | assert.Equal(t, 2, callbackCount) | |
| 307 | + | assert.True(t, callbackPref.PrefersDark) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func TestResolver_OnChangeUnregister(t *testing.T) { | |
| 311 | + | config := &mockConfigProvider{scheme: "default"} | |
| 312 | + | resolver := NewResolver(config) | |
| 313 | + | ||
| 314 | + | detector := &mockDetector{ | |
| 315 | + | name: "test", | |
| 316 | + | priority: 50, | |
| 317 | + | available: true, | |
| 318 | + | prefersDark: false, | |
| 319 | + | detectOk: true, | |
| 320 | + | } | |
| 321 | + | resolver.RegisterDetector(detector) | |
| 322 | + | ||
| 323 | + | var callbackCount int | |
| 324 | + | unregister := resolver.OnChange(func(_ port.ColorSchemePreference) { | |
| 325 | + | callbackCount++ | |
| 326 | + | }) | |
| 327 | + | ||
| 328 | + | // Initial refresh triggers callback | |
| 329 | + | resolver.Refresh() | |
| 330 | + | assert.Equal(t, 1, callbackCount) | |
| 331 | + | ||
| 332 | + | // Unregister | |
| 333 | + | unregister() | |
| 334 | + | ||
| 335 | + | // Change preference - callback should NOT be called | |
| 336 | + | detector.prefersDark = true | |
| 337 | + | resolver.Refresh() | |
| 338 | + | assert.Equal(t, 1, callbackCount) // Still 1, not 2 | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func TestResolver_ConcurrentAccess(_ *testing.T) { | |
| 342 | + | config := &mockConfigProvider{scheme: "default"} | |
| 343 | + | resolver := NewResolver(config) | |
| 344 | + | ||
| 345 | + | detector := &mockDetector{ | |
| 346 | + | name: "test", | |
| 347 | + | priority: 50, | |
| 348 | + | available: true, | |
| 349 | + | prefersDark: false, | |
| 350 | + | detectOk: true, | |
| 351 | + | } | |
| 352 | + | resolver.RegisterDetector(detector) | |
| 353 | + | ||
| 354 | + | var wg sync.WaitGroup | |
| 355 | + | const goroutines = 10 | |
| 356 | + | ||
| 357 | + | // Concurrent Resolve calls | |
| 358 | + | for i := 0; i < goroutines; i++ { | |
| 359 | + | wg.Add(1) | |
| 360 | + | go func() { | |
| 361 | + | defer wg.Done() | |
| 362 | + | for j := 0; j < 100; j++ { | |
| 363 | + | resolver.Resolve() | |
| 364 | + | } | |
| 365 | + | }() | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // Concurrent Refresh calls | |
| 369 | + | for i := 0; i < goroutines; i++ { | |
| 370 | + | wg.Add(1) | |
| 371 | + | go func() { | |
| 372 | + | defer wg.Done() | |
| 373 | + | for j := 0; j < 100; j++ { | |
| 374 | + | resolver.Refresh() | |
| 375 | + | } | |
| 376 | + | }() | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // Concurrent RegisterDetector calls | |
| 380 | + | for i := 0; i < goroutines; i++ { | |
| 381 | + | wg.Add(1) | |
| 382 | + | go func(id int) { | |
| 383 | + | defer wg.Done() | |
| 384 | + | resolver.RegisterDetector(&mockDetector{ | |
| 385 | + | name: "concurrent", | |
| 386 | + | priority: id, | |
| 387 | + | available: true, | |
| 388 | + | prefersDark: id%2 == 0, | |
| 389 | + | detectOk: true, | |
| 390 | + | }) | |
| 391 | + | }(i) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | wg.Wait() | |
| 395 | + | // Test passes if no race conditions detected | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func TestResolver_EmptyConfigScheme(t *testing.T) { | |
| 399 | + | config := &mockConfigProvider{scheme: ""} | |
| 400 | + | resolver := NewResolver(config) | |
| 401 | + | ||
| 402 | + | detector := &mockDetector{ | |
| 403 | + | name: "test", | |
| 404 | + | priority: 50, | |
| 405 | + | available: true, | |
| 406 | + | prefersDark: false, | |
| 407 | + | detectOk: true, | |
| 408 | + | } | |
| 409 | + | resolver.RegisterDetector(detector) | |
| 410 | + | ||
| 411 | + | pref := resolver.Resolve() | |
| 412 | + | ||
| 413 | + | // Empty config should fall through to detector | |
| 414 | + | assert.False(t, pref.PrefersDark) | |
| 415 | + | assert.Equal(t, "test", pref.Source) | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | func TestResolver_ImplementsInterface(t *testing.T) { | |
| 419 | + | config := &mockConfigProvider{scheme: "default"} | |
| 420 | + | resolver := NewResolver(config) | |
| 421 | + | ||
| 422 | + | // Verify Resolver implements port.ColorSchemeResolver | |
| 423 | + | var _ port.ColorSchemeResolver = resolver | |
| 424 | + | require.NotNil(t, resolver) | |
| 425 | + | } | |
M internal/infrastructure/config/defaults.go +139 -34
| @@ -7,7 +7,7 @@ const ( | |||
| 7 | 7 | defaultRetentionDays = 365 // 1 year | |
| 8 | 8 | ||
| 9 | 9 | // Dmenu defaults | |
| 10 | - | defaultMaxHistoryItems = 20 // items | |
| 10 | + | defaultMaxHistoryDays = 30 // days | |
| 11 | 11 | ||
| 12 | 12 | // Logging defaults | |
| 13 | 13 | defaultMaxLogAgeDays = 7 // days | |
| @@ -15,33 +15,60 @@ const ( | |||
| 15 | 15 | // Appearance defaults | |
| 16 | 16 | defaultFontSize = 16 // points | |
| 17 | 17 | ||
| 18 | + | // Workspace defaults | |
| 19 | + | defaultNewPaneURL = "about:blank" | |
| 20 | + | ||
| 18 | 21 | // Omnibox defaults | |
| 19 | - | defaultOmniboxInitialBehavior = "recent" | |
| 22 | + | defaultOmniboxInitialBehavior = "recent" | |
| 23 | + | defaultOmniboxAutoOpenOnNewPane = false | |
| 20 | 24 | ||
| 21 | 25 | // 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 | |
| 28 | 43 | ||
| 29 | 44 | // Workspace styling defaults | |
| 30 | 45 | // Active pane border (overlay) | |
| 31 | 46 | defaultBorderWidth = 1 | |
| 32 | 47 | defaultBorderColor = "@theme_selected_bg_color" | |
| 33 | 48 | ||
| 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 | |
| 37 | 51 | ||
| 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 | |
| 41 | 60 | ||
| 42 | 61 | // Other styling | |
| 43 | 62 | defaultTransitionDuration = 120 | |
| 44 | 63 | defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%) | |
| 64 | + | ||
| 65 | + | // Performance defaults | |
| 66 | + | defaultZoomCacheSize = 256 // domains to cache (~20KB memory) | |
| 67 | + | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | |
| 68 | + | ||
| 69 | + | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | |
| 70 | + | defaultSkiaCPUPaintingThreads = 0 | |
| 71 | + | defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting | |
| 45 | 72 | ) | |
| 46 | 73 | ||
| 47 | 74 | // getDefaultLogDir returns the default log directory, falls back to empty string on error | |
| @@ -67,7 +94,7 @@ func DefaultConfig() *Config { | |||
| 67 | 94 | SearchShortcuts: GetDefaultSearchShortcuts(), | |
| 68 | 95 | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | |
| 69 | 96 | Dmenu: DmenuConfig{ | |
| 70 | - | MaxHistoryItems: defaultMaxHistoryItems, | |
| 97 | + | MaxHistoryDays: defaultMaxHistoryDays, | |
| 71 | 98 | ShowVisitCount: true, | |
| 72 | 99 | ShowLastVisited: true, | |
| 73 | 100 | HistoryPrefix: "🕒", | |
| @@ -117,7 +144,7 @@ func DefaultConfig() *Config { | |||
| 117 | 144 | DisableDMABufRenderer: false, | |
| 118 | 145 | ForceCompositingMode: false, | |
| 119 | 146 | DisableCompositingMode: false, | |
| 120 | - | GSKRenderer: GSKRendererVulkan, | |
| 147 | + | GSKRenderer: GSKRendererAuto, // Let GTK choose - Vulkan can conflict with WebKit's DMA-BUF | |
| 121 | 148 | DisableMipmaps: false, | |
| 122 | 149 | PreferGL: false, | |
| 123 | 150 | DrawCompositingIndicators: false, | |
| @@ -128,16 +155,26 @@ func DefaultConfig() *Config { | |||
| 128 | 155 | DefaultWebpageZoom: 1.2, // 120% default zoom for better readability | |
| 129 | 156 | DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200% | |
| 130 | 157 | Workspace: WorkspaceConfig{ | |
| 158 | + | NewPaneURL: defaultNewPaneURL, | |
| 159 | + | SwitchToTabOnMove: true, | |
| 131 | 160 | PaneMode: PaneModeConfig{ | |
| 132 | 161 | ActivationShortcut: defaultPaneActivationShortcut, | |
| 133 | 162 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | |
| 134 | 163 | 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 | + | ||
| 141 | 178 | "focus-right": {"shift+arrowright", "shift+l"}, | |
| 142 | 179 | "focus-left": {"shift+arrowleft", "shift+h"}, | |
| 143 | 180 | "focus-up": {"shift+arrowup", "shift+k"}, | |
| @@ -159,10 +196,35 @@ func DefaultConfig() *Config { | |||
| 159 | 196 | "cancel": {"escape"}, | |
| 160 | 197 | }, | |
| 161 | 198 | }, | |
| 199 | + | ResizeMode: ResizeModeConfig{ | |
| 200 | + | ActivationShortcut: defaultResizeActivationShortcut, | |
| 201 | + | TimeoutMilliseconds: defaultResizeTimeoutMilliseconds, | |
| 202 | + | StepPercent: defaultResizeStepPercent, | |
| 203 | + | MinPanePercent: defaultResizeMinPanePercent, | |
| 204 | + | Actions: map[string][]string{ | |
| 205 | + | "resize-increase-left": {"h", "arrowleft"}, | |
| 206 | + | "resize-increase-down": {"j", "arrowdown"}, | |
| 207 | + | "resize-increase-up": {"k", "arrowup"}, | |
| 208 | + | "resize-increase-right": {"l", "arrowright"}, | |
| 209 | + | "resize-decrease-left": {"H"}, | |
| 210 | + | "resize-decrease-down": {"J"}, | |
| 211 | + | "resize-decrease-up": {"K"}, | |
| 212 | + | "resize-decrease-right": {"L"}, | |
| 213 | + | "resize-increase": {"+", "="}, | |
| 214 | + | "resize-decrease": {"-"}, | |
| 215 | + | "confirm": {"enter"}, | |
| 216 | + | "cancel": {"escape"}, | |
| 217 | + | }, | |
| 218 | + | }, | |
| 162 | 219 | Shortcuts: GlobalShortcutsConfig{ | |
| 163 | 220 | ClosePane: "ctrl+w", | |
| 164 | 221 | NextTab: "ctrl+tab", | |
| 165 | 222 | PreviousTab: "ctrl+shift+tab", | |
| 223 | + | ||
| 224 | + | ConsumeOrExpelLeft: "alt+bracketleft", | |
| 225 | + | ConsumeOrExpelRight: "alt+bracketright", | |
| 226 | + | ConsumeOrExpelUp: "alt+shift+bracketleft", | |
| 227 | + | ConsumeOrExpelDown: "alt+shift+bracketright", | |
| 166 | 228 | }, | |
| 167 | 229 | TabBarPosition: defaultTabBarPosition, | |
| 168 | 230 | HideTabBarWhenSingleTab: true, | |
| @@ -176,13 +238,15 @@ func DefaultConfig() *Config { | |||
| 176 | 238 | OAuthAutoClose: true, // Auto-close OAuth popups on success | |
| 177 | 239 | }, | |
| 178 | 240 | 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, | |
| 186 | 250 | }, | |
| 187 | 251 | }, | |
| 188 | 252 | ContentFiltering: ContentFilteringConfig{ | |
| @@ -190,21 +254,62 @@ func DefaultConfig() *Config { | |||
| 190 | 254 | AutoUpdate: true, // Auto-update filters from GitHub releases | |
| 191 | 255 | }, | |
| 192 | 256 | Omnibox: OmniboxConfig{ | |
| 193 | - | InitialBehavior: defaultOmniboxInitialBehavior, | |
| 257 | + | InitialBehavior: defaultOmniboxInitialBehavior, | |
| 258 | + | AutoOpenOnNewPane: defaultOmniboxAutoOpenOnNewPane, | |
| 259 | + | }, | |
| 260 | + | Session: SessionConfig{ | |
| 261 | + | AutoRestore: false, | |
| 262 | + | SnapshotIntervalMs: defaultSnapshotIntervalMs, | |
| 263 | + | MaxExitedSessions: defaultMaxExitedSessions, | |
| 264 | + | MaxExitedSessionAgeDays: defaultMaxExitedSessionAgeDays, | |
| 265 | + | SessionMode: SessionModeConfig{ | |
| 266 | + | ActivationShortcut: defaultSessionActivationShortcut, | |
| 267 | + | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | |
| 268 | + | Actions: map[string][]string{ | |
| 269 | + | "session-manager": {"s", "w"}, | |
| 270 | + | "confirm": {"enter"}, | |
| 271 | + | "cancel": {"escape"}, | |
| 272 | + | }, | |
| 273 | + | }, | |
| 194 | 274 | }, | |
| 195 | 275 | Media: MediaConfig{ | |
| 196 | 276 | 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 | |
| 199 | 279 | ForceVSync: false, // Let compositor handle VSync | |
| 200 | 280 | GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API | |
| 201 | 281 | 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 | |
| 204 | 284 | }, | |
| 205 | 285 | Runtime: RuntimeConfig{ | |
| 206 | 286 | Prefix: "", | |
| 207 | 287 | }, | |
| 288 | + | Performance: PerformanceConfig{ | |
| 289 | + | Profile: ProfileDefault, // Use WebKit defaults, no tuning | |
| 290 | + | ZoomCacheSize: defaultZoomCacheSize, | |
| 291 | + | WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount, | |
| 292 | + | // Skia threading - balanced defaults (unset = use WebKit defaults) | |
| 293 | + | // These only apply when Profile is "custom" | |
| 294 | + | SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads, | |
| 295 | + | SkiaGPUPaintingThreads: defaultSkiaGPUPaintingThreads, | |
| 296 | + | SkiaEnableCPURendering: false, | |
| 297 | + | // Web process memory pressure - all unset by default | |
| 298 | + | WebProcessMemoryLimitMB: 0, | |
| 299 | + | WebProcessMemoryPollIntervalSec: 0, | |
| 300 | + | WebProcessMemoryConservativeThreshold: 0, | |
| 301 | + | WebProcessMemoryStrictThreshold: 0, | |
| 302 | + | // Network process memory pressure - all unset by default | |
| 303 | + | NetworkProcessMemoryLimitMB: 0, | |
| 304 | + | NetworkProcessMemoryPollIntervalSec: 0, | |
| 305 | + | NetworkProcessMemoryConservativeThreshold: 0, | |
| 306 | + | NetworkProcessMemoryStrictThreshold: 0, | |
| 307 | + | }, | |
| 308 | + | Update: UpdateConfig{ | |
| 309 | + | EnableOnStartup: true, // Check for updates on startup by default | |
| 310 | + | AutoDownload: false, // Conservative: don't auto-download by default | |
| 311 | + | NotifyOnNewSettings: true, // Show toast when new config settings available | |
| 312 | + | }, | |
| 208 | 313 | } | |
| 209 | 314 | } | |
| 210 | 315 | ||
A internal/infrastructure/config/diff_formatter.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ConfigDiffFormatter implements port.DiffFormatter for formatting config changes. | |
| 11 | + | type ConfigDiffFormatter struct{} | |
| 12 | + | ||
| 13 | + | // NewDiffFormatter creates a new ConfigDiffFormatter. | |
| 14 | + | func NewDiffFormatter() *ConfigDiffFormatter { | |
| 15 | + | return &ConfigDiffFormatter{} | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // FormatChangesAsDiff returns changes formatted as a diff for display. | |
| 19 | + | func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string { | |
| 20 | + | if len(changes) == 0 { | |
| 21 | + | return "No changes detected." | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | var sb strings.Builder | |
| 25 | + | sb.WriteString("Config migration changes:\n\n") | |
| 26 | + | ||
| 27 | + | for _, change := range changes { | |
| 28 | + | switch change.Type { | |
| 29 | + | case port.KeyChangeAdded: | |
| 30 | + | sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue)) | |
| 31 | + | case port.KeyChangeRemoved: | |
| 32 | + | sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue)) | |
| 33 | + | case port.KeyChangeRenamed: | |
| 34 | + | sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey)) | |
| 35 | + | sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue)) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | return sb.String() | |
| 40 | + | } | |
M internal/infrastructure/config/loader.go +76 -5
| @@ -212,6 +212,20 @@ func normalizeConfig(config *Config) { | |||
| 212 | 212 | } | |
| 213 | 213 | ||
| 214 | 214 | config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix) | |
| 215 | + | normalizePerformanceProfile(config) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func normalizePerformanceProfile(config *Config) { | |
| 219 | + | switch strings.ToLower(string(config.Performance.Profile)) { | |
| 220 | + | case "", string(ProfileDefault): | |
| 221 | + | config.Performance.Profile = ProfileDefault | |
| 222 | + | case string(ProfileLite): | |
| 223 | + | config.Performance.Profile = ProfileLite | |
| 224 | + | case string(ProfileMax): | |
| 225 | + | config.Performance.Profile = ProfileMax | |
| 226 | + | case string(ProfileCustom): | |
| 227 | + | config.Performance.Profile = ProfileCustom | |
| 228 | + | } | |
| 215 | 229 | } | |
| 216 | 230 | ||
| 217 | 231 | // Get returns the current configuration (thread-safe). | |
| @@ -252,6 +266,9 @@ func (m *Manager) Save(cfg *Config) error { | |||
| 252 | 266 | m.viper.Set("default_ui_scale", cfg.DefaultUIScale) | |
| 253 | 267 | m.viper.Set("default_search_engine", cfg.DefaultSearchEngine) | |
| 254 | 268 | ||
| 269 | + | // Performance profile (requires browser restart to take effect) | |
| 270 | + | m.viper.Set("performance.profile", string(cfg.Performance.Profile)) | |
| 271 | + | ||
| 255 | 272 | if err := m.viper.WriteConfig(); err != nil { | |
| 256 | 273 | return fmt.Errorf("failed to write config: %w", err) | |
| 257 | 274 | } | |
| @@ -314,6 +331,9 @@ func (m *Manager) setDefaults() { | |||
| 314 | 331 | m.setOmniboxDefaults(defaults) | |
| 315 | 332 | m.setMediaDefaults(defaults) | |
| 316 | 333 | m.setRuntimeDefaults(defaults) | |
| 334 | + | m.setSessionDefaults(defaults) | |
| 335 | + | m.setUpdateDefaults(defaults) | |
| 336 | + | m.setPerformanceDefaults(defaults) | |
| 317 | 337 | } | |
| 318 | 338 | ||
| 319 | 339 | func (m *Manager) setHistoryDefaults(defaults *Config) { | |
| @@ -328,7 +348,7 @@ func (m *Manager) setSearchDefaults(defaults *Config) { | |||
| 328 | 348 | } | |
| 329 | 349 | ||
| 330 | 350 | func (m *Manager) setDmenuDefaults(defaults *Config) { | |
| 331 | - | m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems) | |
| 351 | + | m.viper.SetDefault("dmenu.max_history_days", defaults.Dmenu.MaxHistoryDays) | |
| 332 | 352 | m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount) | |
| 333 | 353 | m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited) | |
| 334 | 354 | m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix) | |
| @@ -378,17 +398,28 @@ func (m *Manager) setRenderingDefaults(defaults *Config) { | |||
| 378 | 398 | } | |
| 379 | 399 | ||
| 380 | 400 | func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |
| 401 | + | m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL) | |
| 381 | 402 | m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut) | |
| 382 | 403 | m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds) | |
| 383 | 404 | m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions) | |
| 384 | 405 | m.viper.SetDefault("workspace.tab_mode.activation_shortcut", defaults.Workspace.TabMode.ActivationShortcut) | |
| 385 | 406 | m.viper.SetDefault("workspace.tab_mode.timeout_ms", defaults.Workspace.TabMode.TimeoutMilliseconds) | |
| 386 | 407 | m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions) | |
| 408 | + | m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut) | |
| 409 | + | m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds) | |
| 410 | + | m.viper.SetDefault("workspace.resize_mode.step_percent", defaults.Workspace.ResizeMode.StepPercent) | |
| 411 | + | m.viper.SetDefault("workspace.resize_mode.min_pane_percent", defaults.Workspace.ResizeMode.MinPanePercent) | |
| 412 | + | m.viper.SetDefault("workspace.resize_mode.actions", defaults.Workspace.ResizeMode.Actions) | |
| 387 | 413 | m.viper.SetDefault("workspace.shortcuts.close_pane", defaults.Workspace.Shortcuts.ClosePane) | |
| 388 | 414 | m.viper.SetDefault("workspace.shortcuts.next_tab", defaults.Workspace.Shortcuts.NextTab) | |
| 389 | 415 | m.viper.SetDefault("workspace.shortcuts.previous_tab", defaults.Workspace.Shortcuts.PreviousTab) | |
| 416 | + | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_left", defaults.Workspace.Shortcuts.ConsumeOrExpelLeft) | |
| 417 | + | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_right", defaults.Workspace.Shortcuts.ConsumeOrExpelRight) | |
| 418 | + | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_up", defaults.Workspace.Shortcuts.ConsumeOrExpelUp) | |
| 419 | + | m.viper.SetDefault("workspace.shortcuts.consume_or_expel_down", defaults.Workspace.Shortcuts.ConsumeOrExpelDown) | |
| 390 | 420 | m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition) | |
| 391 | 421 | m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab) | |
| 422 | + | m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove) | |
| 392 | 423 | m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior)) | |
| 393 | 424 | m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement) | |
| 394 | 425 | m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane) | |
| @@ -398,10 +429,12 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 398 | 429 | m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose) | |
| 399 | 430 | m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth) | |
| 400 | 431 | m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor) | |
| 401 | - | m.viper.SetDefault("workspace.styling.pane_mode_border_width", defaults.Workspace.Styling.PaneModeBorderWidth) | |
| 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) | |
| 405 | 438 | m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration) | |
| 406 | 439 | } | |
| 407 | 440 | ||
| @@ -412,6 +445,7 @@ func (m *Manager) setContentFilteringDefaults(defaults *Config) { | |||
| 412 | 445 | ||
| 413 | 446 | func (m *Manager) setOmniboxDefaults(defaults *Config) { | |
| 414 | 447 | m.viper.SetDefault("omnibox.initial_behavior", defaults.Omnibox.InitialBehavior) | |
| 448 | + | m.viper.SetDefault("omnibox.auto_open_on_new_pane", defaults.Omnibox.AutoOpenOnNewPane) | |
| 415 | 449 | } | |
| 416 | 450 | ||
| 417 | 451 | func (m *Manager) setMediaDefaults(defaults *Config) { | |
| @@ -429,6 +463,43 @@ func (m *Manager) setRuntimeDefaults(defaults *Config) { | |||
| 429 | 463 | m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix) | |
| 430 | 464 | } | |
| 431 | 465 | ||
| 466 | + | func (m *Manager) setSessionDefaults(defaults *Config) { | |
| 467 | + | m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore) | |
| 468 | + | m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs) | |
| 469 | + | m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions) | |
| 470 | + | m.viper.SetDefault("session.max_exited_session_age_days", defaults.Session.MaxExitedSessionAgeDays) | |
| 471 | + | m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut) | |
| 472 | + | m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds) | |
| 473 | + | m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions) | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | func (m *Manager) setUpdateDefaults(defaults *Config) { | |
| 477 | + | m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup) | |
| 478 | + | m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload) | |
| 479 | + | m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings) | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | func (m *Manager) setPerformanceDefaults(defaults *Config) { | |
| 483 | + | m.viper.SetDefault("performance.profile", string(defaults.Performance.Profile)) | |
| 484 | + | m.viper.SetDefault("performance.zoom_cache_size", defaults.Performance.ZoomCacheSize) | |
| 485 | + | m.viper.SetDefault("performance.webview_pool_prewarm_count", defaults.Performance.WebViewPoolPrewarmCount) | |
| 486 | + | // Skia threading (only used when profile = "custom") | |
| 487 | + | m.viper.SetDefault("performance.skia_cpu_painting_threads", defaults.Performance.SkiaCPUPaintingThreads) | |
| 488 | + | m.viper.SetDefault("performance.skia_gpu_painting_threads", defaults.Performance.SkiaGPUPaintingThreads) | |
| 489 | + | m.viper.SetDefault("performance.skia_enable_cpu_rendering", defaults.Performance.SkiaEnableCPURendering) | |
| 490 | + | // Web process memory pressure | |
| 491 | + | m.viper.SetDefault("performance.web_process_memory_limit_mb", defaults.Performance.WebProcessMemoryLimitMB) | |
| 492 | + | m.viper.SetDefault("performance.web_process_memory_poll_interval_sec", defaults.Performance.WebProcessMemoryPollIntervalSec) | |
| 493 | + | m.viper.SetDefault("performance.web_process_memory_conservative_threshold", defaults.Performance.WebProcessMemoryConservativeThreshold) | |
| 494 | + | m.viper.SetDefault("performance.web_process_memory_strict_threshold", defaults.Performance.WebProcessMemoryStrictThreshold) | |
| 495 | + | // Network process memory pressure | |
| 496 | + | m.viper.SetDefault("performance.network_process_memory_limit_mb", defaults.Performance.NetworkProcessMemoryLimitMB) | |
| 497 | + | m.viper.SetDefault("performance.network_process_memory_poll_interval_sec", defaults.Performance.NetworkProcessMemoryPollIntervalSec) | |
| 498 | + | netConservativeThreshold := defaults.Performance.NetworkProcessMemoryConservativeThreshold | |
| 499 | + | m.viper.SetDefault("performance.network_process_memory_conservative_threshold", netConservativeThreshold) | |
| 500 | + | m.viper.SetDefault("performance.network_process_memory_strict_threshold", defaults.Performance.NetworkProcessMemoryStrictThreshold) | |
| 501 | + | } | |
| 502 | + | ||
| 432 | 503 | // New returns a new default configuration instance. | |
| 433 | 504 | // This is a convenience function for getting default config without the full manager. | |
| 434 | 505 | func New() *Config { | |
A internal/infrastructure/config/migrate.go +847 -0
| @@ -0,0 +1,847 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "reflect" | |
| 9 | + | "sort" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "github.com/pelletier/go-toml/v2" | |
| 13 | + | "github.com/spf13/viper" | |
| 14 | + | "gopkg.in/yaml.v3" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/dumber/internal/application/port" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Migrator implements port.ConfigMigrator for comparing and merging config files. | |
| 20 | + | type Migrator struct { | |
| 21 | + | // defaultViper holds a Viper instance with all defaults set. | |
| 22 | + | defaultViper *viper.Viper | |
| 23 | + | // defaultConfig holds the default configuration. | |
| 24 | + | defaultConfig *Config | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // NewMigrator creates a new Migrator instance. | |
| 28 | + | func NewMigrator() *Migrator { | |
| 29 | + | v := viper.New() | |
| 30 | + | v.SetConfigType("toml") | |
| 31 | + | ||
| 32 | + | // Create a temporary manager to set defaults | |
| 33 | + | m := &Manager{viper: v} | |
| 34 | + | m.setDefaults() | |
| 35 | + | ||
| 36 | + | return &Migrator{ | |
| 37 | + | defaultViper: v, | |
| 38 | + | defaultConfig: DefaultConfig(), | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // CheckMigration checks if user config is missing any default keys. | |
| 43 | + | // Returns nil if no migration is needed. | |
| 44 | + | func (m *Migrator) CheckMigration() (*port.MigrationResult, error) { | |
| 45 | + | configFile, err := GetConfigFile() | |
| 46 | + | if err != nil { | |
| 47 | + | return nil, fmt.Errorf("failed to get config file path: %w", err) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // If config file doesn't exist, no migration needed (will be created with all defaults) | |
| 51 | + | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |
| 52 | + | return nil, nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Get user-defined keys from the TOML file | |
| 56 | + | userKeys, err := m.getUserConfigKeys(configFile) | |
| 57 | + | if err != nil { | |
| 58 | + | return nil, fmt.Errorf("failed to parse user config: %w", err) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Get all default keys | |
| 62 | + | defaultKeys := m.getAllDefaultKeys() | |
| 63 | + | ||
| 64 | + | // Find missing keys (in defaults but not in user config) | |
| 65 | + | missingKeys := m.findMissingKeys(defaultKeys, userKeys) | |
| 66 | + | ||
| 67 | + | if len(missingKeys) == 0 { | |
| 68 | + | return nil, nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | return &port.MigrationResult{ | |
| 72 | + | MissingKeys: missingKeys, | |
| 73 | + | ConfigFile: configFile, | |
| 74 | + | }, nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // DetectChanges analyzes user config and returns all detected changes. | |
| 78 | + | // This provides a detailed diff-like view of what migration would do. | |
| 79 | + | func (m *Migrator) DetectChanges() ([]port.KeyChange, error) { | |
| 80 | + | configFile, err := GetConfigFile() | |
| 81 | + | if err != nil { | |
| 82 | + | return nil, fmt.Errorf("failed to get config file path: %w", err) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // If config file doesn't exist, no changes to detect | |
| 86 | + | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |
| 87 | + | return nil, nil | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // Get user-defined keys with values | |
| 91 | + | userKeysWithValues, err := m.getUserConfigKeysWithValues(configFile) | |
| 92 | + | if err != nil { | |
| 93 | + | return nil, fmt.Errorf("failed to parse user config: %w", err) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // Get all default keys | |
| 97 | + | defaultKeys := m.getAllDefaultKeys() | |
| 98 | + | defaultKeySet := make(map[string]bool) | |
| 99 | + | for _, k := range defaultKeys { | |
| 100 | + | defaultKeySet[k] = true | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Build user key set for lookup | |
| 104 | + | userKeySet := make(map[string]bool) | |
| 105 | + | for k := range userKeysWithValues { | |
| 106 | + | userKeySet[k] = true | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | var changes []port.KeyChange | |
| 110 | + | ||
| 111 | + | // Find deprecated keys (in user config but not in defaults) | |
| 112 | + | deprecatedKeys := m.findDeprecatedKeys(userKeysWithValues, defaultKeySet) | |
| 113 | + | ||
| 114 | + | // Find missing keys (in defaults but not in user config) | |
| 115 | + | missingKeys := m.findMissingKeys(defaultKeys, userKeySet) | |
| 116 | + | ||
| 117 | + | // Try to match deprecated keys with missing keys (detect renames) | |
| 118 | + | renames, unmatchedDeprecated, unmatchedMissing := m.matchRenamedKeys(deprecatedKeys, missingKeys) | |
| 119 | + | ||
| 120 | + | // Add rename changes | |
| 121 | + | for oldKey, newKey := range renames { | |
| 122 | + | oldValue := m.formatValue(userKeysWithValues[oldKey]) | |
| 123 | + | newValue := m.formatValue(m.defaultViper.Get(newKey)) | |
| 124 | + | changes = append(changes, port.KeyChange{ | |
| 125 | + | Type: port.KeyChangeRenamed, | |
| 126 | + | OldKey: oldKey, | |
| 127 | + | NewKey: newKey, | |
| 128 | + | OldValue: oldValue, | |
| 129 | + | NewValue: newValue, | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Add removed changes (deprecated keys that couldn't be matched) | |
| 134 | + | for _, oldKey := range unmatchedDeprecated { | |
| 135 | + | changes = append(changes, port.KeyChange{ | |
| 136 | + | Type: port.KeyChangeRemoved, | |
| 137 | + | OldKey: oldKey, | |
| 138 | + | OldValue: m.formatValue(userKeysWithValues[oldKey]), | |
| 139 | + | }) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // Add new key changes (missing keys that couldn't be matched) | |
| 143 | + | for _, newKey := range unmatchedMissing { | |
| 144 | + | changes = append(changes, port.KeyChange{ | |
| 145 | + | Type: port.KeyChangeAdded, | |
| 146 | + | NewKey: newKey, | |
| 147 | + | NewValue: m.formatValue(m.defaultViper.Get(newKey)), | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // Sort changes for consistent output | |
| 152 | + | sort.Slice(changes, func(i, j int) bool { | |
| 153 | + | // Sort by type first, then by key | |
| 154 | + | if changes[i].Type != changes[j].Type { | |
| 155 | + | return changes[i].Type < changes[j].Type | |
| 156 | + | } | |
| 157 | + | keyI := changes[i].NewKey | |
| 158 | + | if keyI == "" { | |
| 159 | + | keyI = changes[i].OldKey | |
| 160 | + | } | |
| 161 | + | keyJ := changes[j].NewKey | |
| 162 | + | if keyJ == "" { | |
| 163 | + | keyJ = changes[j].OldKey | |
| 164 | + | } | |
| 165 | + | return keyI < keyJ | |
| 166 | + | }) | |
| 167 | + | ||
| 168 | + | return changes, nil | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // findDeprecatedKeys returns user keys that don't exist in defaults. | |
| 172 | + | func (m *Migrator) findDeprecatedKeys(userKeys map[string]any, defaultKeys map[string]bool) []string { | |
| 173 | + | var deprecated []string | |
| 174 | + | for userKey := range userKeys { | |
| 175 | + | if !m.keyOrRelatedExistsInDefaults(userKey, defaultKeys) { | |
| 176 | + | deprecated = append(deprecated, userKey) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | sort.Strings(deprecated) | |
| 180 | + | return deprecated | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // keyOrRelatedExistsInDefaults checks if a user key exists in defaults. | |
| 184 | + | func (*Migrator) keyOrRelatedExistsInDefaults(userKey string, defaultKeys map[string]bool) bool { | |
| 185 | + | if defaultKeys[userKey] { | |
| 186 | + | return true | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // Check if this is a parent of any default key | |
| 190 | + | userKeyPrefix := userKey + "." | |
| 191 | + | for defaultKey := range defaultKeys { | |
| 192 | + | if strings.HasPrefix(defaultKey, userKeyPrefix) { | |
| 193 | + | return true | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // Check if any parent of this user key exists | |
| 198 | + | parts := strings.Split(userKey, ".") | |
| 199 | + | for i := len(parts) - 1; i > 0; i-- { | |
| 200 | + | parentKey := strings.Join(parts[:i], ".") | |
| 201 | + | if defaultKeys[parentKey] { | |
| 202 | + | return true | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | return false | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // matchRenamedKeys attempts to match deprecated keys with missing keys. | |
| 210 | + | // Returns: renames map, unmatched deprecated, unmatched missing. | |
| 211 | + | func (m *Migrator) matchRenamedKeys(deprecated, missing []string) (map[string]string, []string, []string) { | |
| 212 | + | renames := make(map[string]string) | |
| 213 | + | usedDeprecated := make(map[string]bool) | |
| 214 | + | usedMissing := make(map[string]bool) | |
| 215 | + | ||
| 216 | + | // First pass: exact suffix matching (e.g., "foo_border_color" -> "foo_color") | |
| 217 | + | for _, oldKey := range deprecated { | |
| 218 | + | for _, newKey := range missing { | |
| 219 | + | if usedMissing[newKey] { | |
| 220 | + | continue | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // Check if keys are similar (same parent path + related leaf names) | |
| 224 | + | // AND have compatible types (don't match int keys with string keys) | |
| 225 | + | if keysAreSimilar(oldKey, newKey) && m.typesAreCompatible(oldKey, newKey) { | |
| 226 | + | renames[oldKey] = newKey | |
| 227 | + | usedDeprecated[oldKey] = true | |
| 228 | + | usedMissing[newKey] = true | |
| 229 | + | break | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // Collect unmatched | |
| 235 | + | var unmatchedDeprecated []string | |
| 236 | + | for _, k := range deprecated { | |
| 237 | + | if !usedDeprecated[k] { | |
| 238 | + | unmatchedDeprecated = append(unmatchedDeprecated, k) | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | var unmatchedMissing []string | |
| 243 | + | for _, k := range missing { | |
| 244 | + | if !usedMissing[k] { | |
| 245 | + | unmatchedMissing = append(unmatchedMissing, k) | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | return renames, unmatchedDeprecated, unmatchedMissing | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // Type name constants for migration compatibility checks. | |
| 253 | + | const ( | |
| 254 | + | typeNameInt = "int" | |
| 255 | + | typeNameString = "string" | |
| 256 | + | ) | |
| 257 | + | ||
| 258 | + | // Config format constants. | |
| 259 | + | const ( | |
| 260 | + | configFormatTOML = "toml" | |
| 261 | + | configFormatYAML = "yaml" | |
| 262 | + | configFormatJSON = "json" | |
| 263 | + | ) | |
| 264 | + | ||
| 265 | + | // typesAreCompatible checks if the default types for two keys are compatible. | |
| 266 | + | // This prevents matching int keys with string keys during rename detection. | |
| 267 | + | func (m *Migrator) typesAreCompatible(oldKey, newKey string) bool { | |
| 268 | + | // Get the type of the new key from defaults | |
| 269 | + | newValue := m.defaultViper.Get(newKey) | |
| 270 | + | if newValue == nil { | |
| 271 | + | return false | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | newType := m.getTypeName(newValue) | |
| 275 | + | ||
| 276 | + | // Infer expected old type from key name patterns | |
| 277 | + | // Keys ending in "_width" are typically int | |
| 278 | + | // Keys ending in "_color" are typically string | |
| 279 | + | if strings.HasSuffix(oldKey, "_width") && newType == typeNameString { | |
| 280 | + | return false | |
| 281 | + | } | |
| 282 | + | if strings.HasSuffix(oldKey, "_color") && newType == typeNameInt { | |
| 283 | + | return false | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // Additional checks based on new key patterns | |
| 287 | + | if strings.HasSuffix(newKey, "_width") && newType != typeNameInt { | |
| 288 | + | return false | |
| 289 | + | } | |
| 290 | + | if strings.HasSuffix(newKey, "_color") && newType != typeNameString { | |
| 291 | + | return false | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | return true | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // keysAreSimilar checks if two keys are likely renames of each other. | |
| 298 | + | func keysAreSimilar(oldKey, newKey string) bool { | |
| 299 | + | oldParts := strings.Split(oldKey, ".") | |
| 300 | + | newParts := strings.Split(newKey, ".") | |
| 301 | + | ||
| 302 | + | // Must have same parent path (all but last part) | |
| 303 | + | if len(oldParts) != len(newParts) { | |
| 304 | + | return false | |
| 305 | + | } | |
| 306 | + | if len(oldParts) < 2 { | |
| 307 | + | return false | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // Check parent path matches | |
| 311 | + | for i := 0; i < len(oldParts)-1; i++ { | |
| 312 | + | if oldParts[i] != newParts[i] { | |
| 313 | + | return false | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // Check if leaf names are similar (one is substring of the other, or share a common base) | |
| 318 | + | oldLeaf := oldParts[len(oldParts)-1] | |
| 319 | + | newLeaf := newParts[len(newParts)-1] | |
| 320 | + | ||
| 321 | + | // Direct substring check | |
| 322 | + | if strings.Contains(oldLeaf, newLeaf) || strings.Contains(newLeaf, oldLeaf) { | |
| 323 | + | return true | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // Common prefix/suffix matching (e.g., "pane_mode_border_color" vs "pane_mode_color") | |
| 327 | + | // Extract common parts by splitting on underscore | |
| 328 | + | oldTokens := strings.Split(oldLeaf, "_") | |
| 329 | + | newTokens := strings.Split(newLeaf, "_") | |
| 330 | + | ||
| 331 | + | // Count matching tokens, ensuring each new token is matched at most once | |
| 332 | + | matches := 0 | |
| 333 | + | matchedNew := make([]bool, len(newTokens)) | |
| 334 | + | for _, ot := range oldTokens { | |
| 335 | + | for j, nt := range newTokens { | |
| 336 | + | if ot == nt && !matchedNew[j] { | |
| 337 | + | matches++ | |
| 338 | + | matchedNew[j] = true | |
| 339 | + | break | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | // If most tokens match, consider similar | |
| 345 | + | minTokens := len(oldTokens) | |
| 346 | + | if len(newTokens) < minTokens { | |
| 347 | + | minTokens = len(newTokens) | |
| 348 | + | } | |
| 349 | + | return matches >= minTokens-1 && matches > 0 | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // Migrate adds missing default keys to the user's config file and removes deprecated keys. | |
| 353 | + | func (m *Migrator) Migrate() ([]string, error) { | |
| 354 | + | changes, err := m.DetectChanges() | |
| 355 | + | if err != nil { | |
| 356 | + | return nil, err | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | if len(changes) == 0 { | |
| 360 | + | return nil, nil | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | configFile, err := GetConfigFile() | |
| 364 | + | if err != nil { | |
| 365 | + | return nil, fmt.Errorf("failed to get config file path: %w", err) | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // Read the raw config file into a nested map | |
| 369 | + | rawConfig, err := m.readRawConfig(configFile) | |
| 370 | + | if err != nil { | |
| 371 | + | return nil, fmt.Errorf("failed to read config file: %w", err) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // Build sets of keys to remove and renames to apply | |
| 375 | + | keysToRemove := make(map[string]bool) | |
| 376 | + | renames := make(map[string]string) // oldKey -> newKey | |
| 377 | + | ||
| 378 | + | var appliedKeys []string | |
| 379 | + | for _, change := range changes { | |
| 380 | + | switch change.Type { | |
| 381 | + | case port.KeyChangeRenamed: | |
| 382 | + | renames[change.OldKey] = change.NewKey | |
| 383 | + | keysToRemove[change.OldKey] = true | |
| 384 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("%s -> %s", change.OldKey, change.NewKey)) | |
| 385 | + | case port.KeyChangeAdded: | |
| 386 | + | appliedKeys = append(appliedKeys, change.NewKey) | |
| 387 | + | case port.KeyChangeRemoved: | |
| 388 | + | keysToRemove[change.OldKey] = true | |
| 389 | + | appliedKeys = append(appliedKeys, fmt.Sprintf("(removed: %s)", change.OldKey)) | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | // Apply renames: copy value from old key to new key before removing old key | |
| 394 | + | for oldKey, newKey := range renames { | |
| 395 | + | if value := m.getNestedValue(rawConfig, oldKey); value != nil { | |
| 396 | + | m.setNestedValue(rawConfig, newKey, value) | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // Remove deprecated and renamed keys from the raw config | |
| 401 | + | for key := range keysToRemove { | |
| 402 | + | m.deleteNestedKey(rawConfig, key) | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // Create a new Viper instance with defaults for added keys | |
| 406 | + | userViper := viper.New() | |
| 407 | + | userViper.SetConfigFile(configFile) | |
| 408 | + | userViper.SetConfigType(m.getConfigType(configFile)) | |
| 409 | + | ||
| 410 | + | // Set all defaults first | |
| 411 | + | mgr := &Manager{viper: userViper} | |
| 412 | + | mgr.setDefaults() | |
| 413 | + | ||
| 414 | + | // Merge the cleaned raw config on top of defaults | |
| 415 | + | if err := userViper.MergeConfigMap(rawConfig); err != nil { | |
| 416 | + | return nil, fmt.Errorf("failed to merge config: %w", err) | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | // Write the merged config back | |
| 420 | + | if err := userViper.WriteConfig(); err != nil { | |
| 421 | + | return nil, fmt.Errorf("failed to write config file: %w", err) | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | return appliedKeys, nil | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | // getConfigType returns the config type based on file extension. | |
| 428 | + | func (*Migrator) getConfigType(configFile string) string { | |
| 429 | + | ext := strings.TrimPrefix(filepath.Ext(configFile), ".") | |
| 430 | + | switch ext { | |
| 431 | + | case configFormatYAML, "yml": | |
| 432 | + | return configFormatYAML | |
| 433 | + | case configFormatJSON: | |
| 434 | + | return configFormatJSON | |
| 435 | + | case configFormatTOML: | |
| 436 | + | return configFormatTOML | |
| 437 | + | default: | |
| 438 | + | return configFormatTOML // default to TOML | |
| 439 | + | } | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // readRawConfig reads the config file into a nested map without applying defaults. | |
| 443 | + | func (m *Migrator) readRawConfig(configFile string) (map[string]any, error) { | |
| 444 | + | data, err := os.ReadFile(configFile) | |
| 445 | + | if err != nil { | |
| 446 | + | return nil, err | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | var rawConfig map[string]any | |
| 450 | + | configType := m.getConfigType(configFile) | |
| 451 | + | ||
| 452 | + | switch configType { | |
| 453 | + | case configFormatTOML: | |
| 454 | + | if err := toml.Unmarshal(data, &rawConfig); err != nil { | |
| 455 | + | return nil, fmt.Errorf("failed to parse TOML: %w", err) | |
| 456 | + | } | |
| 457 | + | case configFormatYAML: | |
| 458 | + | if err := yaml.Unmarshal(data, &rawConfig); err != nil { | |
| 459 | + | return nil, fmt.Errorf("failed to parse YAML: %w", err) | |
| 460 | + | } | |
| 461 | + | case configFormatJSON: | |
| 462 | + | if err := json.Unmarshal(data, &rawConfig); err != nil { | |
| 463 | + | return nil, fmt.Errorf("failed to parse JSON: %w", err) | |
| 464 | + | } | |
| 465 | + | default: | |
| 466 | + | return nil, fmt.Errorf("unsupported config type: %s", configType) | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | return rawConfig, nil | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | // getNestedValue retrieves a value from a nested map using dot-notation key. | |
| 473 | + | func (*Migrator) getNestedValue(m map[string]any, key string) any { | |
| 474 | + | parts := strings.Split(key, ".") | |
| 475 | + | current := m | |
| 476 | + | ||
| 477 | + | for i, part := range parts { | |
| 478 | + | val, ok := current[part] | |
| 479 | + | if !ok { | |
| 480 | + | return nil | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | if i == len(parts)-1 { | |
| 484 | + | return val | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | nested, ok := val.(map[string]any) | |
| 488 | + | if !ok { | |
| 489 | + | return nil | |
| 490 | + | } | |
| 491 | + | current = nested | |
| 492 | + | } | |
| 493 | + | return nil | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | // setNestedValue sets a value in a nested map using dot-notation key. | |
| 497 | + | func (*Migrator) setNestedValue(m map[string]any, key string, value any) { | |
| 498 | + | parts := strings.Split(key, ".") | |
| 499 | + | current := m | |
| 500 | + | ||
| 501 | + | for i, part := range parts { | |
| 502 | + | if i == len(parts)-1 { | |
| 503 | + | current[part] = value | |
| 504 | + | return | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | val, ok := current[part] | |
| 508 | + | if !ok { | |
| 509 | + | // Create intermediate map | |
| 510 | + | newMap := make(map[string]any) | |
| 511 | + | current[part] = newMap | |
| 512 | + | current = newMap | |
| 513 | + | continue | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | nested, ok := val.(map[string]any) | |
| 517 | + | if !ok { | |
| 518 | + | // Overwrite non-map value with a map | |
| 519 | + | newMap := make(map[string]any) | |
| 520 | + | current[part] = newMap | |
| 521 | + | current = newMap | |
| 522 | + | continue | |
| 523 | + | } | |
| 524 | + | current = nested | |
| 525 | + | } | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | // deleteNestedKey removes a key from a nested map using dot-notation key. | |
| 529 | + | // It also cleans up empty parent maps. | |
| 530 | + | func (m *Migrator) deleteNestedKey(config map[string]any, key string) { | |
| 531 | + | parts := strings.Split(key, ".") | |
| 532 | + | m.deleteNestedKeyRecursive(config, parts) | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | // deleteNestedKeyRecursive recursively deletes a key and cleans up empty parents. | |
| 536 | + | func (m *Migrator) deleteNestedKeyRecursive(current map[string]any, parts []string) bool { | |
| 537 | + | if len(parts) == 0 { | |
| 538 | + | return false | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | part := parts[0] | |
| 542 | + | val, ok := current[part] | |
| 543 | + | if !ok { | |
| 544 | + | return false | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | if len(parts) == 1 { | |
| 548 | + | // This is the leaf key, delete it | |
| 549 | + | delete(current, part) | |
| 550 | + | return true | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | // Navigate deeper | |
| 554 | + | nested, ok := val.(map[string]any) | |
| 555 | + | if !ok { | |
| 556 | + | return false | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | // Recursively delete in the nested map | |
| 560 | + | deleted := m.deleteNestedKeyRecursive(nested, parts[1:]) | |
| 561 | + | ||
| 562 | + | // If the nested map is now empty, remove it too | |
| 563 | + | if deleted && len(nested) == 0 { | |
| 564 | + | delete(current, part) | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | return deleted | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | // GetKeyInfo returns detailed information about a config key. | |
| 571 | + | func (m *Migrator) GetKeyInfo(key string) port.KeyInfo { | |
| 572 | + | value := m.defaultViper.Get(key) | |
| 573 | + | if value == nil { | |
| 574 | + | return port.KeyInfo{ | |
| 575 | + | Key: key, | |
| 576 | + | Type: "unknown", | |
| 577 | + | DefaultValue: "unknown", | |
| 578 | + | } | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | return port.KeyInfo{ | |
| 582 | + | Key: key, | |
| 583 | + | Type: m.getTypeName(value), | |
| 584 | + | DefaultValue: m.formatValue(value), | |
| 585 | + | } | |
| 586 | + | } | |
| 587 | + | ||
| 588 | + | // GetConfigFile returns the path to the user's config file. | |
| 589 | + | func (*Migrator) GetConfigFile() (string, error) { | |
| 590 | + | return GetConfigFile() | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | // getAllDefaultKeys returns all keys from the default configuration. | |
| 594 | + | func (m *Migrator) getAllDefaultKeys() []string { | |
| 595 | + | keys := m.defaultViper.AllKeys() | |
| 596 | + | ||
| 597 | + | // Filter out keys that should not be migrated | |
| 598 | + | filtered := make([]string, 0, len(keys)) | |
| 599 | + | for _, key := range keys { | |
| 600 | + | // Skip database.path as it's set dynamically | |
| 601 | + | if key == "database.path" { | |
| 602 | + | continue | |
| 603 | + | } | |
| 604 | + | filtered = append(filtered, key) | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | sort.Strings(filtered) | |
| 608 | + | return filtered | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | // getUserConfigKeys parses the user's TOML file and returns all defined keys. | |
| 612 | + | func (m *Migrator) getUserConfigKeys(configFile string) (map[string]bool, error) { | |
| 613 | + | keysWithValues, err := m.getUserConfigKeysWithValues(configFile) | |
| 614 | + | if err != nil { | |
| 615 | + | return nil, err | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | keys := make(map[string]bool) | |
| 619 | + | for k := range keysWithValues { | |
| 620 | + | keys[k] = true | |
| 621 | + | } | |
| 622 | + | return keys, nil | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | // getUserConfigKeysWithValues parses the user's TOML file and returns keys with their values. | |
| 626 | + | func (m *Migrator) getUserConfigKeysWithValues(configFile string) (map[string]any, error) { | |
| 627 | + | data, err := os.ReadFile(configFile) | |
| 628 | + | if err != nil { | |
| 629 | + | return nil, fmt.Errorf("failed to read config file: %w", err) | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | // Parse TOML into a generic map | |
| 633 | + | var rawConfig map[string]any | |
| 634 | + | if err := toml.Unmarshal(data, &rawConfig); err != nil { | |
| 635 | + | return nil, fmt.Errorf("failed to parse TOML: %w", err) | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | // Flatten the map to dot-notation keys with values | |
| 639 | + | result := make(map[string]any) | |
| 640 | + | m.flattenMapWithValues(rawConfig, "", result) | |
| 641 | + | ||
| 642 | + | return result, nil | |
| 643 | + | } | |
| 644 | + | ||
| 645 | + | // flattenMapWithValues recursively flattens a nested map to dot-notation keys with values. | |
| 646 | + | func (m *Migrator) flattenMapWithValues(data map[string]any, prefix string, result map[string]any) { | |
| 647 | + | for k, v := range data { | |
| 648 | + | key := k | |
| 649 | + | if prefix != "" { | |
| 650 | + | key = prefix + "." + k | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | switch val := v.(type) { | |
| 654 | + | case map[string]any: | |
| 655 | + | // Check if this key represents user data (like search_shortcuts, actions) | |
| 656 | + | // that should be treated as a single key rather than recursed into. | |
| 657 | + | if m.isUserDataSection(key) { | |
| 658 | + | result[key] = v | |
| 659 | + | } else { | |
| 660 | + | // Recurse into nested config sections | |
| 661 | + | m.flattenMapWithValues(val, key, result) | |
| 662 | + | } | |
| 663 | + | default: | |
| 664 | + | result[key] = v | |
| 665 | + | } | |
| 666 | + | } | |
| 667 | + | } | |
| 668 | + | ||
| 669 | + | // flattenMap recursively flattens a nested map to dot-notation keys. | |
| 670 | + | func (m *Migrator) flattenMap(data map[string]any, prefix string, keys map[string]bool) { | |
| 671 | + | for k, v := range data { | |
| 672 | + | key := k | |
| 673 | + | if prefix != "" { | |
| 674 | + | key = prefix + "." + k | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | switch val := v.(type) { | |
| 678 | + | case map[string]any: | |
| 679 | + | // Check if this key represents user data (like search_shortcuts, actions) | |
| 680 | + | // that should be treated as a single key rather than recursed into. | |
| 681 | + | if m.isUserDataSection(key) { | |
| 682 | + | keys[key] = true | |
| 683 | + | } else { | |
| 684 | + | // Recurse into nested config sections | |
| 685 | + | m.flattenMap(val, key, keys) | |
| 686 | + | } | |
| 687 | + | default: | |
| 688 | + | keys[key] = true | |
| 689 | + | } | |
| 690 | + | } | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | // isUserDataSection checks if a key path represents user-defined data | |
| 694 | + | // that should not be compared key-by-key (e.g., search shortcuts, action bindings). | |
| 695 | + | func (*Migrator) isUserDataSection(keyPath string) bool { | |
| 696 | + | // User data sections - treat entire section as one key | |
| 697 | + | switch keyPath { | |
| 698 | + | case "search_shortcuts": | |
| 699 | + | return true | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | // Action maps are user-customizable | |
| 703 | + | userDataSuffixes := []string{ | |
| 704 | + | ".actions", // pane_mode.actions, tab_mode.actions, etc. | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | for _, suffix := range userDataSuffixes { | |
| 708 | + | if strings.HasSuffix(keyPath, suffix) { | |
| 709 | + | return true | |
| 710 | + | } | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | return false | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | // findMissingKeys returns keys that are in defaults but not in user config. | |
| 717 | + | func (m *Migrator) findMissingKeys(defaultKeys []string, userKeys map[string]bool) []string { | |
| 718 | + | missing := make([]string, 0) | |
| 719 | + | ||
| 720 | + | for _, key := range defaultKeys { | |
| 721 | + | // Check if key or any related key exists in user config | |
| 722 | + | if m.keyOrRelatedExists(key, userKeys) { | |
| 723 | + | continue | |
| 724 | + | } | |
| 725 | + | missing = append(missing, key) | |
| 726 | + | } | |
| 727 | + | ||
| 728 | + | // Sort for consistent output | |
| 729 | + | sort.Strings(missing) | |
| 730 | + | return missing | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | // keyOrRelatedExists checks if a key, any parent, or any child keys exist in the map. | |
| 734 | + | // This handles cases where: | |
| 735 | + | // - User has defined a parent section but not all children | |
| 736 | + | // - Default is a struct key but user has individual sub-keys (e.g., palettes) | |
| 737 | + | func (*Migrator) keyOrRelatedExists(key string, keys map[string]bool) bool { | |
| 738 | + | if keys[key] { | |
| 739 | + | return true | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | // Check if this is a child of a leaf map (like search_shortcuts.ddg) | |
| 743 | + | // In that case, we should check if the parent exists | |
| 744 | + | parts := strings.Split(key, ".") | |
| 745 | + | for i := len(parts) - 1; i > 0; i-- { | |
| 746 | + | parentKey := strings.Join(parts[:i], ".") | |
| 747 | + | if keys[parentKey] { | |
| 748 | + | // Parent exists as a leaf map, so child is implicitly defined | |
| 749 | + | return true | |
| 750 | + | } | |
| 751 | + | } | |
| 752 | + | ||
| 753 | + | // Check if any child keys exist (e.g., default is "appearance.dark_palette" | |
| 754 | + | // but user has "appearance.dark_palette.background") | |
| 755 | + | keyPrefix := key + "." | |
| 756 | + | for userKey := range keys { | |
| 757 | + | if strings.HasPrefix(userKey, keyPrefix) { | |
| 758 | + | return true | |
| 759 | + | } | |
| 760 | + | } | |
| 761 | + | ||
| 762 | + | return false | |
| 763 | + | } | |
| 764 | + | ||
| 765 | + | // getTypeName returns a human-readable type name for a value. | |
| 766 | + | func (*Migrator) getTypeName(value any) string { | |
| 767 | + | if value == nil { | |
| 768 | + | return "unknown" | |
| 769 | + | } | |
| 770 | + | ||
| 771 | + | t := reflect.TypeOf(value) | |
| 772 | + | switch t.Kind() { | |
| 773 | + | case reflect.Bool: | |
| 774 | + | return "bool" | |
| 775 | + | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: | |
| 776 | + | return "int" | |
| 777 | + | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: | |
| 778 | + | return "int" | |
| 779 | + | case reflect.Float32, reflect.Float64: | |
| 780 | + | return "float" | |
| 781 | + | case reflect.String: | |
| 782 | + | return "string" | |
| 783 | + | case reflect.Slice: | |
| 784 | + | return "list" | |
| 785 | + | case reflect.Map: | |
| 786 | + | return "map" | |
| 787 | + | default: | |
| 788 | + | return t.String() | |
| 789 | + | } | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | // formatValue returns a human-readable string representation of a value. | |
| 793 | + | func (*Migrator) formatValue(value any) string { | |
| 794 | + | if value == nil { | |
| 795 | + | return "null" | |
| 796 | + | } | |
| 797 | + | ||
| 798 | + | switch v := value.(type) { | |
| 799 | + | case bool: | |
| 800 | + | if v { | |
| 801 | + | return "true" | |
| 802 | + | } | |
| 803 | + | return "false" | |
| 804 | + | case int, int8, int16, int32, int64: | |
| 805 | + | return fmt.Sprintf("%d", v) | |
| 806 | + | case uint, uint8, uint16, uint32, uint64: | |
| 807 | + | return fmt.Sprintf("%d", v) | |
| 808 | + | case float32, float64: | |
| 809 | + | return fmt.Sprintf("%v", v) | |
| 810 | + | case string: | |
| 811 | + | if v == "" { | |
| 812 | + | return `""` | |
| 813 | + | } | |
| 814 | + | // Truncate long strings | |
| 815 | + | const maxStringLen = 50 | |
| 816 | + | if len(v) > maxStringLen { | |
| 817 | + | return fmt.Sprintf("%q...", v[:maxStringLen-3]) | |
| 818 | + | } | |
| 819 | + | return fmt.Sprintf("%q", v) | |
| 820 | + | case []any: | |
| 821 | + | if len(v) == 0 { | |
| 822 | + | return "[]" | |
| 823 | + | } | |
| 824 | + | return fmt.Sprintf("[%d items]", len(v)) | |
| 825 | + | case map[string]any: | |
| 826 | + | if len(v) == 0 { | |
| 827 | + | return "{}" | |
| 828 | + | } | |
| 829 | + | return fmt.Sprintf("{%d entries}", len(v)) | |
| 830 | + | default: | |
| 831 | + | // Handle slices of strings (common for action bindings) | |
| 832 | + | rv := reflect.ValueOf(value) | |
| 833 | + | if rv.Kind() == reflect.Slice { | |
| 834 | + | if rv.Len() == 0 { | |
| 835 | + | return "[]" | |
| 836 | + | } | |
| 837 | + | return fmt.Sprintf("[%d items]", rv.Len()) | |
| 838 | + | } | |
| 839 | + | if rv.Kind() == reflect.Map { | |
| 840 | + | if rv.Len() == 0 { | |
| 841 | + | return "{}" | |
| 842 | + | } | |
| 843 | + | return fmt.Sprintf("{%d entries}", rv.Len()) | |
| 844 | + | } | |
| 845 | + | return fmt.Sprintf("%v", v) | |
| 846 | + | } | |
| 847 | + | } | |
A internal/infrastructure/config/migrate_test.go +875 -0
| @@ -0,0 +1,875 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestMigrator_NewMigrator(t *testing.T) { | |
| 13 | + | m := NewMigrator() | |
| 14 | + | require.NotNil(t, m) | |
| 15 | + | require.NotNil(t, m.defaultViper) | |
| 16 | + | require.NotNil(t, m.defaultConfig) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestMigrator_GetAllDefaultKeys(t *testing.T) { | |
| 20 | + | m := NewMigrator() | |
| 21 | + | keys := m.getAllDefaultKeys() | |
| 22 | + | ||
| 23 | + | // Should have many default keys | |
| 24 | + | assert.Greater(t, len(keys), 50, "should have many default keys") | |
| 25 | + | ||
| 26 | + | // Should not include database.path (it's dynamically set) | |
| 27 | + | for _, key := range keys { | |
| 28 | + | assert.NotEqual(t, "database.path", key, "should not include database.path") | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Should include some known keys | |
| 32 | + | keySet := make(map[string]bool) | |
| 33 | + | for _, k := range keys { | |
| 34 | + | keySet[k] = true | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | assert.True(t, keySet["history.max_entries"], "should include history.max_entries") | |
| 38 | + | assert.True(t, keySet["logging.level"], "should include logging.level") | |
| 39 | + | assert.True(t, keySet["appearance.sans_font"], "should include appearance.sans_font") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestMigrator_GetUserConfigKeys(t *testing.T) { | |
| 43 | + | m := NewMigrator() | |
| 44 | + | ||
| 45 | + | // Create a temp config file | |
| 46 | + | tmpDir := t.TempDir() | |
| 47 | + | configFile := filepath.Join(tmpDir, "config.toml") | |
| 48 | + | ||
| 49 | + | content := ` | |
| 50 | + | [history] | |
| 51 | + | max_entries = 5000 | |
| 52 | + | retention_period_days = 180 | |
| 53 | + | ||
| 54 | + | [logging] | |
| 55 | + | level = "debug" | |
| 56 | + | ||
| 57 | + | [appearance] | |
| 58 | + | sans_font = "Arial" | |
| 59 | + | ` | |
| 60 | + | err := os.WriteFile(configFile, []byte(content), 0o644) | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | ||
| 63 | + | keys, err := m.getUserConfigKeys(configFile) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | ||
| 66 | + | // Should have the keys we defined | |
| 67 | + | assert.True(t, keys["history.max_entries"]) | |
| 68 | + | assert.True(t, keys["history.retention_period_days"]) | |
| 69 | + | assert.True(t, keys["logging.level"]) | |
| 70 | + | assert.True(t, keys["appearance.sans_font"]) | |
| 71 | + | ||
| 72 | + | // Should not have keys we didn't define | |
| 73 | + | assert.False(t, keys["history.cleanup_interval_days"]) | |
| 74 | + | assert.False(t, keys["logging.format"]) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestMigrator_FlattenMap(t *testing.T) { | |
| 78 | + | m := NewMigrator() | |
| 79 | + | ||
| 80 | + | tests := []struct { | |
| 81 | + | name string | |
| 82 | + | input map[string]any | |
| 83 | + | prefix string | |
| 84 | + | expected []string | |
| 85 | + | }{ | |
| 86 | + | { | |
| 87 | + | name: "simple flat map", | |
| 88 | + | input: map[string]any{ | |
| 89 | + | "key1": "value1", | |
| 90 | + | "key2": 123, | |
| 91 | + | }, | |
| 92 | + | prefix: "", | |
| 93 | + | expected: []string{"key1", "key2"}, | |
| 94 | + | }, | |
| 95 | + | { | |
| 96 | + | name: "nested map", | |
| 97 | + | input: map[string]any{ | |
| 98 | + | "parent": map[string]any{ | |
| 99 | + | "child1": "value1", | |
| 100 | + | "child2": "value2", | |
| 101 | + | }, | |
| 102 | + | }, | |
| 103 | + | prefix: "", | |
| 104 | + | expected: []string{"parent.child1", "parent.child2"}, | |
| 105 | + | }, | |
| 106 | + | { | |
| 107 | + | name: "with prefix", | |
| 108 | + | input: map[string]any{ | |
| 109 | + | "key": "value", | |
| 110 | + | }, | |
| 111 | + | prefix: "section", | |
| 112 | + | expected: []string{"section.key"}, | |
| 113 | + | }, | |
| 114 | + | { | |
| 115 | + | name: "search_shortcuts treated as single key", | |
| 116 | + | input: map[string]any{ | |
| 117 | + | "search_shortcuts": map[string]any{ | |
| 118 | + | "g": "https://google.com/?q=%s", | |
| 119 | + | "ddg": "https://duckduckgo.com/?q=%s", | |
| 120 | + | }, | |
| 121 | + | }, | |
| 122 | + | prefix: "", | |
| 123 | + | expected: []string{"search_shortcuts"}, // Should be treated as user data | |
| 124 | + | }, | |
| 125 | + | { | |
| 126 | + | name: "actions map treated as single key", | |
| 127 | + | input: map[string]any{ | |
| 128 | + | "pane_mode": map[string]any{ | |
| 129 | + | "actions": map[string]any{ | |
| 130 | + | "split-right": []string{"r"}, | |
| 131 | + | }, | |
| 132 | + | }, | |
| 133 | + | }, | |
| 134 | + | prefix: "", | |
| 135 | + | expected: []string{"pane_mode.actions"}, // Actions treated as user data | |
| 136 | + | }, | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | for _, tt := range tests { | |
| 140 | + | t.Run(tt.name, func(t *testing.T) { | |
| 141 | + | keys := make(map[string]bool) | |
| 142 | + | m.flattenMap(tt.input, tt.prefix, keys) | |
| 143 | + | ||
| 144 | + | for _, expectedKey := range tt.expected { | |
| 145 | + | assert.True(t, keys[expectedKey], "should contain key: %s", expectedKey) | |
| 146 | + | } | |
| 147 | + | }) | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestMigrator_IsUserDataSection(t *testing.T) { | |
| 152 | + | m := NewMigrator() | |
| 153 | + | ||
| 154 | + | tests := []struct { | |
| 155 | + | name string | |
| 156 | + | keyPath string | |
| 157 | + | expected bool | |
| 158 | + | }{ | |
| 159 | + | { | |
| 160 | + | name: "search_shortcuts is user data", | |
| 161 | + | keyPath: "search_shortcuts", | |
| 162 | + | expected: true, | |
| 163 | + | }, | |
| 164 | + | { | |
| 165 | + | name: "pane_mode.actions is user data", | |
| 166 | + | keyPath: "pane_mode.actions", | |
| 167 | + | expected: true, | |
| 168 | + | }, | |
| 169 | + | { | |
| 170 | + | name: "tab_mode.actions is user data", | |
| 171 | + | keyPath: "tab_mode.actions", | |
| 172 | + | expected: true, | |
| 173 | + | }, | |
| 174 | + | { | |
| 175 | + | name: "history is not user data", | |
| 176 | + | keyPath: "history", | |
| 177 | + | expected: false, | |
| 178 | + | }, | |
| 179 | + | { | |
| 180 | + | name: "history.max_entries is not user data", | |
| 181 | + | keyPath: "history.max_entries", | |
| 182 | + | expected: false, | |
| 183 | + | }, | |
| 184 | + | { | |
| 185 | + | name: "appearance.sans_font is not user data", | |
| 186 | + | keyPath: "appearance.sans_font", | |
| 187 | + | expected: false, | |
| 188 | + | }, | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | for _, tt := range tests { | |
| 192 | + | t.Run(tt.name, func(t *testing.T) { | |
| 193 | + | result := m.isUserDataSection(tt.keyPath) | |
| 194 | + | assert.Equal(t, tt.expected, result) | |
| 195 | + | }) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestMigrator_FindMissingKeys(t *testing.T) { | |
| 200 | + | m := NewMigrator() | |
| 201 | + | ||
| 202 | + | tests := []struct { | |
| 203 | + | name string | |
| 204 | + | defaultKeys []string | |
| 205 | + | userKeys map[string]bool | |
| 206 | + | expected []string | |
| 207 | + | }{ | |
| 208 | + | { | |
| 209 | + | name: "all keys present", | |
| 210 | + | defaultKeys: []string{"key1", "key2"}, | |
| 211 | + | userKeys: map[string]bool{"key1": true, "key2": true}, | |
| 212 | + | expected: []string{}, | |
| 213 | + | }, | |
| 214 | + | { | |
| 215 | + | name: "some keys missing", | |
| 216 | + | defaultKeys: []string{"key1", "key2", "key3"}, | |
| 217 | + | userKeys: map[string]bool{"key1": true}, | |
| 218 | + | expected: []string{"key2", "key3"}, | |
| 219 | + | }, | |
| 220 | + | { | |
| 221 | + | name: "all keys missing", | |
| 222 | + | defaultKeys: []string{"key1", "key2"}, | |
| 223 | + | userKeys: map[string]bool{}, | |
| 224 | + | expected: []string{"key1", "key2"}, | |
| 225 | + | }, | |
| 226 | + | { | |
| 227 | + | name: "parent key covers children", | |
| 228 | + | defaultKeys: []string{"parent.child1", "parent.child2"}, | |
| 229 | + | userKeys: map[string]bool{"parent": true}, | |
| 230 | + | expected: []string{}, // Parent covers children | |
| 231 | + | }, | |
| 232 | + | { | |
| 233 | + | name: "child keys cover parent (struct defaults like palettes)", | |
| 234 | + | defaultKeys: []string{"appearance.dark_palette"}, | |
| 235 | + | userKeys: map[string]bool{"appearance.dark_palette.background": true, "appearance.dark_palette.accent": true}, | |
| 236 | + | expected: []string{}, // Child keys mean parent is defined | |
| 237 | + | }, | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | for _, tt := range tests { | |
| 241 | + | t.Run(tt.name, func(t *testing.T) { | |
| 242 | + | result := m.findMissingKeys(tt.defaultKeys, tt.userKeys) | |
| 243 | + | assert.ElementsMatch(t, tt.expected, result) | |
| 244 | + | }) | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func TestMigrator_GetKeyInfo(t *testing.T) { | |
| 249 | + | m := NewMigrator() | |
| 250 | + | ||
| 251 | + | tests := []struct { | |
| 252 | + | name string | |
| 253 | + | key string | |
| 254 | + | expectedType string | |
| 255 | + | }{ | |
| 256 | + | { | |
| 257 | + | name: "bool key", | |
| 258 | + | key: "debug.enable_devtools", | |
| 259 | + | expectedType: "bool", | |
| 260 | + | }, | |
| 261 | + | { | |
| 262 | + | name: "int key", | |
| 263 | + | key: "history.max_entries", | |
| 264 | + | expectedType: "int", | |
| 265 | + | }, | |
| 266 | + | { | |
| 267 | + | name: "string key", | |
| 268 | + | key: "logging.level", | |
| 269 | + | expectedType: "string", | |
| 270 | + | }, | |
| 271 | + | { | |
| 272 | + | name: "float key", | |
| 273 | + | key: "default_webpage_zoom", | |
| 274 | + | expectedType: "float", | |
| 275 | + | }, | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | for _, tt := range tests { | |
| 279 | + | t.Run(tt.name, func(t *testing.T) { | |
| 280 | + | info := m.GetKeyInfo(tt.key) | |
| 281 | + | assert.Equal(t, tt.key, info.Key) | |
| 282 | + | assert.Equal(t, tt.expectedType, info.Type) | |
| 283 | + | assert.NotEmpty(t, info.DefaultValue) | |
| 284 | + | }) | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func TestMigrator_GetTypeName(t *testing.T) { | |
| 289 | + | m := NewMigrator() | |
| 290 | + | ||
| 291 | + | tests := []struct { | |
| 292 | + | name string | |
| 293 | + | value any | |
| 294 | + | expected string | |
| 295 | + | }{ | |
| 296 | + | {name: "bool", value: true, expected: "bool"}, | |
| 297 | + | {name: "int", value: 42, expected: "int"}, | |
| 298 | + | {name: "int64", value: int64(42), expected: "int"}, | |
| 299 | + | {name: "float64", value: 3.14, expected: "float"}, | |
| 300 | + | {name: "string", value: "hello", expected: "string"}, | |
| 301 | + | {name: "slice", value: []any{"a", "b"}, expected: "list"}, | |
| 302 | + | {name: "map", value: map[string]any{"a": 1}, expected: "map"}, | |
| 303 | + | {name: "nil", value: nil, expected: "unknown"}, | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | for _, tt := range tests { | |
| 307 | + | t.Run(tt.name, func(t *testing.T) { | |
| 308 | + | result := m.getTypeName(tt.value) | |
| 309 | + | assert.Equal(t, tt.expected, result) | |
| 310 | + | }) | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func TestMigrator_FormatValue(t *testing.T) { | |
| 315 | + | m := NewMigrator() | |
| 316 | + | ||
| 317 | + | tests := []struct { | |
| 318 | + | name string | |
| 319 | + | value any | |
| 320 | + | expected string | |
| 321 | + | }{ | |
| 322 | + | {name: "true", value: true, expected: "true"}, | |
| 323 | + | {name: "false", value: false, expected: "false"}, | |
| 324 | + | {name: "int", value: 42, expected: "42"}, | |
| 325 | + | {name: "float", value: 3.14, expected: "3.14"}, | |
| 326 | + | {name: "string", value: "hello", expected: `"hello"`}, | |
| 327 | + | {name: "empty string", value: "", expected: `""`}, | |
| 328 | + | {name: "empty slice", value: []any{}, expected: "[]"}, | |
| 329 | + | {name: "slice with items", value: []any{"a", "b", "c"}, expected: "[3 items]"}, | |
| 330 | + | {name: "empty map", value: map[string]any{}, expected: "{}"}, | |
| 331 | + | {name: "map with entries", value: map[string]any{"a": 1, "b": 2}, expected: "{2 entries}"}, | |
| 332 | + | {name: "nil", value: nil, expected: "null"}, | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | for _, tt := range tests { | |
| 336 | + | t.Run(tt.name, func(t *testing.T) { | |
| 337 | + | result := m.formatValue(tt.value) | |
| 338 | + | assert.Equal(t, tt.expected, result) | |
| 339 | + | }) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func TestKeysAreSimilar(t *testing.T) { | |
| 344 | + | tests := []struct { | |
| 345 | + | name string | |
| 346 | + | oldKey string | |
| 347 | + | newKey string | |
| 348 | + | expected bool | |
| 349 | + | }{ | |
| 350 | + | { | |
| 351 | + | name: "identical keys", | |
| 352 | + | oldKey: "section.pane_mode_color", | |
| 353 | + | newKey: "section.pane_mode_color", | |
| 354 | + | expected: true, | |
| 355 | + | }, | |
| 356 | + | { | |
| 357 | + | name: "substring match - old contains new", | |
| 358 | + | oldKey: "section.pane_mode_border_color", | |
| 359 | + | newKey: "section.pane_mode_color", | |
| 360 | + | expected: true, | |
| 361 | + | }, | |
| 362 | + | { | |
| 363 | + | name: "substring match - new contains old", | |
| 364 | + | oldKey: "section.mode_color", | |
| 365 | + | newKey: "section.pane_mode_color", | |
| 366 | + | expected: true, | |
| 367 | + | }, | |
| 368 | + | { | |
| 369 | + | name: "token matching - border removed", | |
| 370 | + | oldKey: "section.tab_mode_border_color", | |
| 371 | + | newKey: "section.tab_mode_color", | |
| 372 | + | expected: true, | |
| 373 | + | }, | |
| 374 | + | { | |
| 375 | + | name: "different parent paths", | |
| 376 | + | oldKey: "section_a.pane_mode_color", | |
| 377 | + | newKey: "section_b.pane_mode_color", | |
| 378 | + | expected: false, | |
| 379 | + | }, | |
| 380 | + | { | |
| 381 | + | name: "different depth", | |
| 382 | + | oldKey: "pane_mode_color", | |
| 383 | + | newKey: "section.pane_mode_color", | |
| 384 | + | expected: false, | |
| 385 | + | }, | |
| 386 | + | { | |
| 387 | + | name: "completely different keys", | |
| 388 | + | oldKey: "section.foo_bar", | |
| 389 | + | newKey: "section.baz_qux", | |
| 390 | + | expected: false, | |
| 391 | + | }, | |
| 392 | + | { | |
| 393 | + | name: "top level keys rejected", | |
| 394 | + | oldKey: "foo", | |
| 395 | + | newKey: "bar", | |
| 396 | + | expected: false, | |
| 397 | + | }, | |
| 398 | + | { | |
| 399 | + | name: "no common tokens", | |
| 400 | + | oldKey: "section.aaa_bbb", | |
| 401 | + | newKey: "section.ccc_ddd", | |
| 402 | + | expected: false, | |
| 403 | + | }, | |
| 404 | + | { | |
| 405 | + | name: "single token difference allowed", | |
| 406 | + | oldKey: "section.pane_mode_border_width", | |
| 407 | + | newKey: "section.mode_border_width", | |
| 408 | + | expected: true, | |
| 409 | + | }, | |
| 410 | + | { | |
| 411 | + | name: "duplicate tokens in old key - no double counting", | |
| 412 | + | oldKey: "section.mode_mode_mode", | |
| 413 | + | newKey: "section.mode_other_another", | |
| 414 | + | expected: false, // oldTokens=[mode,mode,mode], newTokens=[mode,other,another], matches=1 (only one mode can match), minTokens=3, needs 1>=2 which is false | |
| 415 | + | }, | |
| 416 | + | { | |
| 417 | + | name: "enough common tokens with duplicates", | |
| 418 | + | oldKey: "section.pane_mode_border", | |
| 419 | + | newKey: "section.pane_mode_color", | |
| 420 | + | expected: true, // oldTokens=[pane,mode,border], newTokens=[pane,mode,color], matches=2, minTokens=3, 2>=2 && 2>0 = true | |
| 421 | + | }, | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | for _, tt := range tests { | |
| 425 | + | t.Run(tt.name, func(t *testing.T) { | |
| 426 | + | result := keysAreSimilar(tt.oldKey, tt.newKey) | |
| 427 | + | assert.Equal(t, tt.expected, result, "keysAreSimilar(%q, %q)", tt.oldKey, tt.newKey) | |
| 428 | + | }) | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | func TestMigrator_TypesAreCompatible(t *testing.T) { | |
| 433 | + | m := NewMigrator() | |
| 434 | + | ||
| 435 | + | tests := []struct { | |
| 436 | + | name string | |
| 437 | + | oldKey string | |
| 438 | + | newKey string | |
| 439 | + | expected bool | |
| 440 | + | }{ | |
| 441 | + | { | |
| 442 | + | name: "color to color - compatible (using real key)", | |
| 443 | + | oldKey: "workspace.styling.pane_mode_border_color", | |
| 444 | + | newKey: "workspace.styling.pane_mode_color", | |
| 445 | + | expected: true, | |
| 446 | + | }, | |
| 447 | + | { | |
| 448 | + | name: "width to width - compatible (using real key)", | |
| 449 | + | oldKey: "workspace.styling.pane_mode_border_width", | |
| 450 | + | newKey: "workspace.styling.mode_border_width", | |
| 451 | + | expected: true, | |
| 452 | + | }, | |
| 453 | + | { | |
| 454 | + | name: "width suffix to color type - incompatible", | |
| 455 | + | oldKey: "workspace.styling.some_width", | |
| 456 | + | newKey: "workspace.styling.pane_mode_color", // real key with string type | |
| 457 | + | expected: false, | |
| 458 | + | }, | |
| 459 | + | { | |
| 460 | + | name: "color suffix to int type - incompatible", | |
| 461 | + | oldKey: "workspace.styling.some_color", | |
| 462 | + | newKey: "workspace.styling.mode_border_width", // real key with int type | |
| 463 | + | expected: false, | |
| 464 | + | }, | |
| 465 | + | { | |
| 466 | + | name: "generic key to string type - compatible", | |
| 467 | + | oldKey: "workspace.styling.some_setting", | |
| 468 | + | newKey: "workspace.styling.pane_mode_color", // real key with string type | |
| 469 | + | expected: true, | |
| 470 | + | }, | |
| 471 | + | { | |
| 472 | + | name: "non-existent new key - incompatible", | |
| 473 | + | oldKey: "section.old_key", | |
| 474 | + | newKey: "section.nonexistent_key_xyz", | |
| 475 | + | expected: false, | |
| 476 | + | }, | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | for _, tt := range tests { | |
| 480 | + | t.Run(tt.name, func(t *testing.T) { | |
| 481 | + | result := m.typesAreCompatible(tt.oldKey, tt.newKey) | |
| 482 | + | assert.Equal(t, tt.expected, result, "typesAreCompatible(%q, %q)", tt.oldKey, tt.newKey) | |
| 483 | + | }) | |
| 484 | + | } | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | func TestMigrator_FindDeprecatedKeys(t *testing.T) { | |
| 488 | + | m := NewMigrator() | |
| 489 | + | ||
| 490 | + | tests := []struct { | |
| 491 | + | name string | |
| 492 | + | userKeys map[string]any | |
| 493 | + | defaultKeys map[string]bool | |
| 494 | + | expected []string | |
| 495 | + | }{ | |
| 496 | + | { | |
| 497 | + | name: "no deprecated keys", | |
| 498 | + | userKeys: map[string]any{ | |
| 499 | + | "history.max_entries": 1000, | |
| 500 | + | "logging.level": "info", | |
| 501 | + | }, | |
| 502 | + | defaultKeys: map[string]bool{ | |
| 503 | + | "history.max_entries": true, | |
| 504 | + | "logging.level": true, | |
| 505 | + | }, | |
| 506 | + | expected: []string{}, | |
| 507 | + | }, | |
| 508 | + | { | |
| 509 | + | name: "one deprecated key", | |
| 510 | + | userKeys: map[string]any{ | |
| 511 | + | "history.max_entries": 1000, | |
| 512 | + | "old.deprecated_key": "value", | |
| 513 | + | }, | |
| 514 | + | defaultKeys: map[string]bool{ | |
| 515 | + | "history.max_entries": true, | |
| 516 | + | }, | |
| 517 | + | expected: []string{"old.deprecated_key"}, | |
| 518 | + | }, | |
| 519 | + | { | |
| 520 | + | name: "multiple deprecated keys", | |
| 521 | + | userKeys: map[string]any{ | |
| 522 | + | "old.key1": "a", | |
| 523 | + | "old.key2": "b", | |
| 524 | + | "old.key3": "c", | |
| 525 | + | }, | |
| 526 | + | defaultKeys: map[string]bool{ | |
| 527 | + | "new.key1": true, | |
| 528 | + | }, | |
| 529 | + | expected: []string{"old.key1", "old.key2", "old.key3"}, | |
| 530 | + | }, | |
| 531 | + | { | |
| 532 | + | name: "parent key exists in defaults", | |
| 533 | + | userKeys: map[string]any{ | |
| 534 | + | "parent.child": "value", | |
| 535 | + | }, | |
| 536 | + | defaultKeys: map[string]bool{ | |
| 537 | + | "parent.child": true, | |
| 538 | + | "parent.other_child": true, | |
| 539 | + | }, | |
| 540 | + | expected: []string{}, | |
| 541 | + | }, | |
| 542 | + | } | |
| 543 | + | ||
| 544 | + | for _, tt := range tests { | |
| 545 | + | t.Run(tt.name, func(t *testing.T) { | |
| 546 | + | result := m.findDeprecatedKeys(tt.userKeys, tt.defaultKeys) | |
| 547 | + | assert.ElementsMatch(t, tt.expected, result) | |
| 548 | + | }) | |
| 549 | + | } | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | func TestMigrator_MatchRenamedKeys(t *testing.T) { | |
| 553 | + | m := NewMigrator() | |
| 554 | + | ||
| 555 | + | tests := []struct { | |
| 556 | + | name string | |
| 557 | + | deprecated []string | |
| 558 | + | missing []string | |
| 559 | + | expectedRenames map[string]string | |
| 560 | + | expectedUnmatchedOld []string | |
| 561 | + | expectedUnmatchedNew []string | |
| 562 | + | }{ | |
| 563 | + | { | |
| 564 | + | name: "no keys to match", | |
| 565 | + | deprecated: []string{}, | |
| 566 | + | missing: []string{}, | |
| 567 | + | expectedRenames: map[string]string{}, | |
| 568 | + | expectedUnmatchedOld: nil, | |
| 569 | + | expectedUnmatchedNew: nil, | |
| 570 | + | }, | |
| 571 | + | { | |
| 572 | + | name: "simple rename match (using real keys)", | |
| 573 | + | deprecated: []string{"workspace.styling.pane_mode_border_color"}, | |
| 574 | + | missing: []string{"workspace.styling.pane_mode_color"}, | |
| 575 | + | expectedRenames: map[string]string{ | |
| 576 | + | "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color", | |
| 577 | + | }, | |
| 578 | + | expectedUnmatchedOld: nil, | |
| 579 | + | expectedUnmatchedNew: nil, | |
| 580 | + | }, | |
| 581 | + | { | |
| 582 | + | name: "no match possible - different types (width vs color)", | |
| 583 | + | deprecated: []string{"workspace.styling.pane_mode_border_width"}, | |
| 584 | + | missing: []string{"workspace.styling.pane_mode_color"}, | |
| 585 | + | expectedRenames: map[string]string{}, | |
| 586 | + | expectedUnmatchedOld: []string{"workspace.styling.pane_mode_border_width"}, | |
| 587 | + | expectedUnmatchedNew: []string{"workspace.styling.pane_mode_color"}, | |
| 588 | + | }, | |
| 589 | + | { | |
| 590 | + | name: "no match possible - different parents", | |
| 591 | + | deprecated: []string{"old_section.some_key"}, | |
| 592 | + | missing: []string{"new_section.some_key"}, | |
| 593 | + | expectedRenames: map[string]string{}, | |
| 594 | + | expectedUnmatchedOld: []string{"old_section.some_key"}, | |
| 595 | + | expectedUnmatchedNew: []string{"new_section.some_key"}, | |
| 596 | + | }, | |
| 597 | + | { | |
| 598 | + | name: "multiple renames (using real keys)", | |
| 599 | + | deprecated: []string{"workspace.styling.pane_mode_border_color", "workspace.styling.tab_mode_border_color"}, | |
| 600 | + | missing: []string{"workspace.styling.pane_mode_color", "workspace.styling.tab_mode_color"}, | |
| 601 | + | expectedRenames: map[string]string{ | |
| 602 | + | "workspace.styling.pane_mode_border_color": "workspace.styling.pane_mode_color", | |
| 603 | + | "workspace.styling.tab_mode_border_color": "workspace.styling.tab_mode_color", | |
| 604 | + | }, | |
| 605 | + | expectedUnmatchedOld: nil, | |
| 606 | + | expectedUnmatchedNew: nil, | |
| 607 | + | }, | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | for _, tt := range tests { | |
| 611 | + | t.Run(tt.name, func(t *testing.T) { | |
| 612 | + | renames, unmatchedOld, unmatchedNew := m.matchRenamedKeys(tt.deprecated, tt.missing) | |
| 613 | + | assert.Equal(t, tt.expectedRenames, renames, "renames mismatch") | |
| 614 | + | assert.ElementsMatch(t, tt.expectedUnmatchedOld, unmatchedOld, "unmatched old mismatch") | |
| 615 | + | assert.ElementsMatch(t, tt.expectedUnmatchedNew, unmatchedNew, "unmatched new mismatch") | |
| 616 | + | }) | |
| 617 | + | } | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | func TestMigrator_CheckMigration_NoConfigFile(t *testing.T) { | |
| 621 | + | // This test would need to temporarily modify the config path | |
| 622 | + | // which is difficult without refactoring. Skip for now. | |
| 623 | + | t.Skip("requires config path mocking") | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | func TestMigrator_Migrate_NoConfigFile(t *testing.T) { | |
| 627 | + | // This test would need to temporarily modify the config path | |
| 628 | + | // which is difficult without refactoring. Skip for now. | |
| 629 | + | t.Skip("requires config path mocking") | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | func TestMigrator_GetConfigType(t *testing.T) { | |
| 633 | + | m := NewMigrator() | |
| 634 | + | ||
| 635 | + | tests := []struct { | |
| 636 | + | name string | |
| 637 | + | configFile string | |
| 638 | + | expected string | |
| 639 | + | }{ | |
| 640 | + | { | |
| 641 | + | name: "toml file", | |
| 642 | + | configFile: "/path/to/config.toml", | |
| 643 | + | expected: "toml", | |
| 644 | + | }, | |
| 645 | + | { | |
| 646 | + | name: "yaml file", | |
| 647 | + | configFile: "/path/to/config.yaml", | |
| 648 | + | expected: "yaml", | |
| 649 | + | }, | |
| 650 | + | { | |
| 651 | + | name: "yml file", | |
| 652 | + | configFile: "/path/to/config.yml", | |
| 653 | + | expected: "yaml", | |
| 654 | + | }, | |
| 655 | + | { | |
| 656 | + | name: "json file", | |
| 657 | + | configFile: "/path/to/config.json", | |
| 658 | + | expected: "json", | |
| 659 | + | }, | |
| 660 | + | { | |
| 661 | + | name: "unknown extension defaults to toml", | |
| 662 | + | configFile: "/path/to/config.unknown", | |
| 663 | + | expected: "toml", | |
| 664 | + | }, | |
| 665 | + | { | |
| 666 | + | name: "no extension defaults to toml", | |
| 667 | + | configFile: "/path/to/config", | |
| 668 | + | expected: "toml", | |
| 669 | + | }, | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | for _, tt := range tests { | |
| 673 | + | t.Run(tt.name, func(t *testing.T) { | |
| 674 | + | result := m.getConfigType(tt.configFile) | |
| 675 | + | assert.Equal(t, tt.expected, result) | |
| 676 | + | }) | |
| 677 | + | } | |
| 678 | + | } | |
| 679 | + | ||
| 680 | + | func TestMigrator_GetNestedValue(t *testing.T) { | |
| 681 | + | m := NewMigrator() | |
| 682 | + | ||
| 683 | + | config := map[string]any{ | |
| 684 | + | "level1": map[string]any{ | |
| 685 | + | "level2": map[string]any{ | |
| 686 | + | "level3": "deep_value", | |
| 687 | + | }, | |
| 688 | + | "simple": "simple_value", | |
| 689 | + | }, | |
| 690 | + | "top": "top_value", | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | tests := []struct { | |
| 694 | + | name string | |
| 695 | + | key string | |
| 696 | + | expected any | |
| 697 | + | }{ | |
| 698 | + | { | |
| 699 | + | name: "top level key", | |
| 700 | + | key: "top", | |
| 701 | + | expected: "top_value", | |
| 702 | + | }, | |
| 703 | + | { | |
| 704 | + | name: "nested key", | |
| 705 | + | key: "level1.simple", | |
| 706 | + | expected: "simple_value", | |
| 707 | + | }, | |
| 708 | + | { | |
| 709 | + | name: "deeply nested key", | |
| 710 | + | key: "level1.level2.level3", | |
| 711 | + | expected: "deep_value", | |
| 712 | + | }, | |
| 713 | + | { | |
| 714 | + | name: "non-existent key", | |
| 715 | + | key: "nonexistent", | |
| 716 | + | expected: nil, | |
| 717 | + | }, | |
| 718 | + | { | |
| 719 | + | name: "non-existent nested key", | |
| 720 | + | key: "level1.nonexistent", | |
| 721 | + | expected: nil, | |
| 722 | + | }, | |
| 723 | + | } | |
| 724 | + | ||
| 725 | + | for _, tt := range tests { | |
| 726 | + | t.Run(tt.name, func(t *testing.T) { | |
| 727 | + | result := m.getNestedValue(config, tt.key) | |
| 728 | + | assert.Equal(t, tt.expected, result) | |
| 729 | + | }) | |
| 730 | + | } | |
| 731 | + | } | |
| 732 | + | ||
| 733 | + | func TestMigrator_SetNestedValue(t *testing.T) { | |
| 734 | + | m := NewMigrator() | |
| 735 | + | ||
| 736 | + | t.Run("set top level value", func(t *testing.T) { | |
| 737 | + | config := make(map[string]any) | |
| 738 | + | m.setNestedValue(config, "top", "value") | |
| 739 | + | assert.Equal(t, "value", config["top"]) | |
| 740 | + | }) | |
| 741 | + | ||
| 742 | + | t.Run("set nested value with existing parent", func(t *testing.T) { | |
| 743 | + | config := map[string]any{ | |
| 744 | + | "parent": map[string]any{}, | |
| 745 | + | } | |
| 746 | + | m.setNestedValue(config, "parent.child", "value") | |
| 747 | + | parent := config["parent"].(map[string]any) | |
| 748 | + | assert.Equal(t, "value", parent["child"]) | |
| 749 | + | }) | |
| 750 | + | ||
| 751 | + | t.Run("set nested value creates intermediate maps", func(t *testing.T) { | |
| 752 | + | config := make(map[string]any) | |
| 753 | + | m.setNestedValue(config, "a.b.c", "deep_value") | |
| 754 | + | ||
| 755 | + | a := config["a"].(map[string]any) | |
| 756 | + | b := a["b"].(map[string]any) | |
| 757 | + | assert.Equal(t, "deep_value", b["c"]) | |
| 758 | + | }) | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | func TestMigrator_DeleteNestedKey(t *testing.T) { | |
| 762 | + | m := NewMigrator() | |
| 763 | + | ||
| 764 | + | t.Run("delete top level key", func(t *testing.T) { | |
| 765 | + | config := map[string]any{ | |
| 766 | + | "keep": "value", | |
| 767 | + | "delete": "value", | |
| 768 | + | } | |
| 769 | + | m.deleteNestedKey(config, "delete") | |
| 770 | + | assert.Contains(t, config, "keep") | |
| 771 | + | assert.NotContains(t, config, "delete") | |
| 772 | + | }) | |
| 773 | + | ||
| 774 | + | t.Run("delete nested key", func(t *testing.T) { | |
| 775 | + | config := map[string]any{ | |
| 776 | + | "parent": map[string]any{ | |
| 777 | + | "keep": "value", | |
| 778 | + | "delete": "value", | |
| 779 | + | }, | |
| 780 | + | } | |
| 781 | + | m.deleteNestedKey(config, "parent.delete") | |
| 782 | + | parent := config["parent"].(map[string]any) | |
| 783 | + | assert.Contains(t, parent, "keep") | |
| 784 | + | assert.NotContains(t, parent, "delete") | |
| 785 | + | }) | |
| 786 | + | ||
| 787 | + | t.Run("delete nested key cleans up empty parents", func(t *testing.T) { | |
| 788 | + | config := map[string]any{ | |
| 789 | + | "keep": "value", | |
| 790 | + | "parent": map[string]any{ | |
| 791 | + | "child": map[string]any{ | |
| 792 | + | "leaf": "value", | |
| 793 | + | }, | |
| 794 | + | }, | |
| 795 | + | } | |
| 796 | + | m.deleteNestedKey(config, "parent.child.leaf") | |
| 797 | + | // Both parent and child should be removed since they're empty | |
| 798 | + | assert.NotContains(t, config, "parent") | |
| 799 | + | assert.Contains(t, config, "keep") | |
| 800 | + | }) | |
| 801 | + | ||
| 802 | + | t.Run("delete non-existent key is no-op", func(t *testing.T) { | |
| 803 | + | config := map[string]any{ | |
| 804 | + | "keep": "value", | |
| 805 | + | } | |
| 806 | + | m.deleteNestedKey(config, "nonexistent") | |
| 807 | + | assert.Contains(t, config, "keep") | |
| 808 | + | }) | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | func TestMigrator_ReadRawConfig(t *testing.T) { | |
| 812 | + | m := NewMigrator() | |
| 813 | + | ||
| 814 | + | t.Run("read TOML config", func(t *testing.T) { | |
| 815 | + | tmpDir := t.TempDir() | |
| 816 | + | configFile := filepath.Join(tmpDir, "config.toml") | |
| 817 | + | ||
| 818 | + | content := ` | |
| 819 | + | [section] | |
| 820 | + | key = "value" | |
| 821 | + | number = 42 | |
| 822 | + | ` | |
| 823 | + | err := os.WriteFile(configFile, []byte(content), 0o644) | |
| 824 | + | require.NoError(t, err) | |
| 825 | + | ||
| 826 | + | config, err := m.readRawConfig(configFile) | |
| 827 | + | require.NoError(t, err) | |
| 828 | + | ||
| 829 | + | section := config["section"].(map[string]any) | |
| 830 | + | assert.Equal(t, "value", section["key"]) | |
| 831 | + | assert.Equal(t, int64(42), section["number"]) | |
| 832 | + | }) | |
| 833 | + | ||
| 834 | + | t.Run("read YAML config", func(t *testing.T) { | |
| 835 | + | tmpDir := t.TempDir() | |
| 836 | + | configFile := filepath.Join(tmpDir, "config.yaml") | |
| 837 | + | ||
| 838 | + | content := ` | |
| 839 | + | section: | |
| 840 | + | key: value | |
| 841 | + | number: 42 | |
| 842 | + | ` | |
| 843 | + | err := os.WriteFile(configFile, []byte(content), 0o644) | |
| 844 | + | require.NoError(t, err) | |
| 845 | + | ||
| 846 | + | config, err := m.readRawConfig(configFile) | |
| 847 | + | require.NoError(t, err) | |
| 848 | + | ||
| 849 | + | section := config["section"].(map[string]any) | |
| 850 | + | assert.Equal(t, "value", section["key"]) | |
| 851 | + | assert.Equal(t, 42, section["number"]) | |
| 852 | + | }) | |
| 853 | + | ||
| 854 | + | t.Run("read JSON config", func(t *testing.T) { | |
| 855 | + | tmpDir := t.TempDir() | |
| 856 | + | configFile := filepath.Join(tmpDir, "config.json") | |
| 857 | + | ||
| 858 | + | content := `{ | |
| 859 | + | "section": { | |
| 860 | + | "key": "value", | |
| 861 | + | "number": 42 | |
| 862 | + | } | |
| 863 | + | }` | |
| 864 | + | err := os.WriteFile(configFile, []byte(content), 0o644) | |
| 865 | + | require.NoError(t, err) | |
| 866 | + | ||
| 867 | + | config, err := m.readRawConfig(configFile) | |
| 868 | + | require.NoError(t, err) | |
| 869 | + | ||
| 870 | + | section := config["section"].(map[string]any) | |
| 871 | + | assert.Equal(t, "value", section["key"]) | |
| 872 | + | // JSON numbers are float64 | |
| 873 | + | assert.InDelta(t, 42.0, section["number"].(float64), 0.001) | |
| 874 | + | }) | |
| 875 | + | } | |
A internal/infrastructure/config/profile.go +307 -0
| @@ -0,0 +1,307 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "runtime" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | // maxSkiaCPUThreads is the maximum number of Skia CPU painting threads | |
| 11 | + | // supported by WebKitGTK (0-8 range). | |
| 12 | + | maxSkiaCPUThreads = 8 | |
| 13 | + | ||
| 14 | + | // maxSkiaGPUThreads is the maximum number of Skia GPU painting threads | |
| 15 | + | // supported by WebKitGTK (-1 to 8 range, where -1 means "let WebKit decide"). | |
| 16 | + | maxSkiaGPUThreads = 8 | |
| 17 | + | ||
| 18 | + | // Memory tier thresholds (in bytes) | |
| 19 | + | // Explicitly typed as uint64 to prevent overflow on 32-bit systems. | |
| 20 | + | ramTierLow uint64 = 8 * 1024 * 1024 * 1024 // 8 GB | |
| 21 | + | ramTierMedium uint64 = 16 * 1024 * 1024 * 1024 // 16 GB | |
| 22 | + | ramTierHigh uint64 = 32 * 1024 * 1024 * 1024 // 32 GB | |
| 23 | + | ||
| 24 | + | // VRAM tier thresholds (in bytes) | |
| 25 | + | vramTierLow uint64 = 4 * 1024 * 1024 * 1024 // 4 GB (low-end discrete / integrated) | |
| 26 | + | vramTierMid uint64 = 8 * 1024 * 1024 * 1024 // 8 GB (mid-range discrete) | |
| 27 | + | vramTierHigh uint64 = 16 * 1024 * 1024 * 1024 // 16 GB (high-end discrete) | |
| 28 | + | ||
| 29 | + | // GPU thread scaling values | |
| 30 | + | gpuThreadsHigh = 8 // For 16GB+ VRAM | |
| 31 | + | gpuThreadsMid = 6 // For 8-16GB VRAM | |
| 32 | + | gpuThreadsLow = 4 // For 4-8GB VRAM | |
| 33 | + | gpuThreadsMin = 2 // For <4GB VRAM or unknown | |
| 34 | + | ||
| 35 | + | // Memory limit values (in MB) | |
| 36 | + | webMemHigh = 4096 // For 32GB+ RAM | |
| 37 | + | webMemMedium = 3072 // For 16-32GB RAM | |
| 38 | + | webMemLow = 2048 // For 8-16GB RAM | |
| 39 | + | webMemMin = 1024 // For <8GB RAM | |
| 40 | + | netMemHigh = 1024 // For 32GB+ RAM | |
| 41 | + | netMemMedium = 768 // For 16-32GB RAM | |
| 42 | + | netMemLow = 512 // For 8-16GB RAM | |
| 43 | + | netMemMin = 256 // For <8GB RAM | |
| 44 | + | webMemFallback = 2048 // When RAM unknown | |
| 45 | + | netMemFallback = 512 // When RAM unknown | |
| 46 | + | ||
| 47 | + | // WebView pool prewarm counts | |
| 48 | + | poolPrewarmHigh = 12 // For 32GB+ RAM | |
| 49 | + | poolPrewarmMedium = 8 // For 16-32GB RAM | |
| 50 | + | poolPrewarmLow = 6 // For 8-16GB RAM | |
| 51 | + | poolPrewarmMin = 4 // For <8GB RAM | |
| 52 | + | poolPrewarmFallback = 8 // When RAM unknown | |
| 53 | + | ) | |
| 54 | + | ||
| 55 | + | // ResolvedPerformanceSettings contains the computed performance settings | |
| 56 | + | // after profile resolution. These are the actual values to apply. | |
| 57 | + | type ResolvedPerformanceSettings struct { | |
| 58 | + | // Skia threading | |
| 59 | + | SkiaCPUPaintingThreads int | |
| 60 | + | SkiaGPUPaintingThreads int | |
| 61 | + | SkiaEnableCPURendering bool | |
| 62 | + | ||
| 63 | + | // Web process memory pressure | |
| 64 | + | WebProcessMemoryLimitMB int | |
| 65 | + | WebProcessMemoryPollIntervalSec float64 | |
| 66 | + | WebProcessMemoryConservativeThreshold float64 | |
| 67 | + | WebProcessMemoryStrictThreshold float64 | |
| 68 | + | ||
| 69 | + | // Network process memory pressure | |
| 70 | + | NetworkProcessMemoryLimitMB int | |
| 71 | + | NetworkProcessMemoryPollIntervalSec float64 | |
| 72 | + | NetworkProcessMemoryConservativeThreshold float64 | |
| 73 | + | NetworkProcessMemoryStrictThreshold float64 | |
| 74 | + | ||
| 75 | + | // WebView pool | |
| 76 | + | WebViewPoolPrewarmCount int | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // ResolvePerformanceProfile computes the actual performance settings based on | |
| 80 | + | // the selected profile. For ProfileCustom, it returns the user's configured values. | |
| 81 | + | // For other profiles, it computes appropriate values based on detected hardware. | |
| 82 | + | // | |
| 83 | + | // The hw parameter is optional - if nil, fallback values are used. | |
| 84 | + | func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) ResolvedPerformanceSettings { | |
| 85 | + | switch cfg.Profile { | |
| 86 | + | case ProfileLite: | |
| 87 | + | return resolveLiteProfile(hw) | |
| 88 | + | case ProfileMax: | |
| 89 | + | return resolveMaxProfile(hw) | |
| 90 | + | case ProfileCustom: | |
| 91 | + | return resolveCustomProfile(cfg) | |
| 92 | + | default: // ProfileDefault or empty | |
| 93 | + | return resolveDefaultProfile() | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // resolveDefaultProfile returns WebKit defaults (no tuning). | |
| 98 | + | func resolveDefaultProfile() ResolvedPerformanceSettings { | |
| 99 | + | return ResolvedPerformanceSettings{ | |
| 100 | + | // All zeros/negatives mean "unset, use WebKit defaults" | |
| 101 | + | SkiaCPUPaintingThreads: 0, | |
| 102 | + | SkiaGPUPaintingThreads: -1, | |
| 103 | + | SkiaEnableCPURendering: false, | |
| 104 | + | ||
| 105 | + | WebProcessMemoryLimitMB: 0, | |
| 106 | + | WebProcessMemoryPollIntervalSec: 0, | |
| 107 | + | WebProcessMemoryConservativeThreshold: 0, | |
| 108 | + | WebProcessMemoryStrictThreshold: 0, | |
| 109 | + | ||
| 110 | + | NetworkProcessMemoryLimitMB: 0, | |
| 111 | + | NetworkProcessMemoryPollIntervalSec: 0, | |
| 112 | + | NetworkProcessMemoryConservativeThreshold: 0, | |
| 113 | + | NetworkProcessMemoryStrictThreshold: 0, | |
| 114 | + | ||
| 115 | + | WebViewPoolPrewarmCount: 4, | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // resolveLiteProfile returns settings optimized for low-RAM systems. | |
| 120 | + | // Uses hardware info to scale CPU threads if available. | |
| 121 | + | func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings { | |
| 122 | + | // Use 2 CPU threads, or scale down on very low-core systems | |
| 123 | + | cpuThreads := 2 | |
| 124 | + | if hw != nil && hw.CPUCores > 0 && hw.CPUCores < 4 { | |
| 125 | + | cpuThreads = 1 | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | return ResolvedPerformanceSettings{ | |
| 129 | + | SkiaCPUPaintingThreads: cpuThreads, | |
| 130 | + | SkiaGPUPaintingThreads: -1, // unset | |
| 131 | + | SkiaEnableCPURendering: false, | |
| 132 | + | ||
| 133 | + | WebProcessMemoryLimitMB: 768, | |
| 134 | + | WebProcessMemoryPollIntervalSec: 0, // use WebKit default (30s) | |
| 135 | + | WebProcessMemoryConservativeThreshold: 0.25, | |
| 136 | + | WebProcessMemoryStrictThreshold: 0.4, | |
| 137 | + | ||
| 138 | + | NetworkProcessMemoryLimitMB: 384, | |
| 139 | + | NetworkProcessMemoryPollIntervalSec: 0, | |
| 140 | + | NetworkProcessMemoryConservativeThreshold: 0.25, | |
| 141 | + | NetworkProcessMemoryStrictThreshold: 0.4, | |
| 142 | + | ||
| 143 | + | WebViewPoolPrewarmCount: 2, | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // resolveMaxProfile returns settings optimized for maximum responsiveness. | |
| 148 | + | // Scales values based on detected hardware capabilities. | |
| 149 | + | func resolveMaxProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings { | |
| 150 | + | // CPU threads: use physical cores (not threads), capped at WebKitGTK limit | |
| 151 | + | cpuThreads := computeMaxCPUThreads(hw) | |
| 152 | + | ||
| 153 | + | // GPU threads: scale based on VRAM | |
| 154 | + | gpuThreads := computeMaxGPUThreads(hw) | |
| 155 | + | ||
| 156 | + | // WebView pool: scale based on RAM | |
| 157 | + | poolPrewarm := computeMaxPoolPrewarm(hw) | |
| 158 | + | ||
| 159 | + | return ResolvedPerformanceSettings{ | |
| 160 | + | SkiaCPUPaintingThreads: cpuThreads, | |
| 161 | + | SkiaGPUPaintingThreads: gpuThreads, | |
| 162 | + | SkiaEnableCPURendering: false, | |
| 163 | + | ||
| 164 | + | // Max profile: no memory limits, let WebKit use defaults | |
| 165 | + | WebProcessMemoryLimitMB: 0, | |
| 166 | + | WebProcessMemoryPollIntervalSec: 0, | |
| 167 | + | WebProcessMemoryConservativeThreshold: 0, | |
| 168 | + | WebProcessMemoryStrictThreshold: 0, | |
| 169 | + | ||
| 170 | + | NetworkProcessMemoryLimitMB: 0, | |
| 171 | + | NetworkProcessMemoryPollIntervalSec: 0, | |
| 172 | + | NetworkProcessMemoryConservativeThreshold: 0, | |
| 173 | + | NetworkProcessMemoryStrictThreshold: 0, | |
| 174 | + | ||
| 175 | + | WebViewPoolPrewarmCount: poolPrewarm, | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // computeMaxCPUThreads calculates Skia CPU threads for max profile. | |
| 180 | + | // Uses physical cores (not hyperthreaded) capped at WebKitGTK's limit. | |
| 181 | + | func computeMaxCPUThreads(hw *port.HardwareInfo) int { | |
| 182 | + | var cpuThreads int | |
| 183 | + | ||
| 184 | + | if hw != nil && hw.CPUCores > 0 { | |
| 185 | + | // Use physical cores directly - they're better for parallel painting | |
| 186 | + | cpuThreads = hw.CPUCores | |
| 187 | + | } else { | |
| 188 | + | // Fallback: assume hyperthreading, use half of logical CPUs | |
| 189 | + | cpuThreads = runtime.NumCPU() / 2 | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // Minimum 4 for meaningful parallelism | |
| 193 | + | if cpuThreads < 4 { | |
| 194 | + | cpuThreads = 4 | |
| 195 | + | } | |
| 196 | + | // Cap at WebKitGTK Skia limit | |
| 197 | + | if cpuThreads > maxSkiaCPUThreads { | |
| 198 | + | cpuThreads = maxSkiaCPUThreads | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | return cpuThreads | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // computeMaxGPUThreads calculates Skia GPU threads based on VRAM. | |
| 205 | + | func computeMaxGPUThreads(hw *port.HardwareInfo) int { | |
| 206 | + | if hw == nil || hw.VRAM == 0 { | |
| 207 | + | // No VRAM info - use conservative default | |
| 208 | + | return gpuThreadsMin | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // Scale GPU threads based on VRAM tiers | |
| 212 | + | switch { | |
| 213 | + | case hw.VRAM >= vramTierHigh: // 16GB+ VRAM (high-end: RX 7900, RTX 4080+) | |
| 214 | + | return gpuThreadsHigh | |
| 215 | + | case hw.VRAM >= vramTierMid: // 8-16GB VRAM (mid-range: RX 7800, RTX 4070) | |
| 216 | + | return gpuThreadsMid | |
| 217 | + | case hw.VRAM >= vramTierLow: // 4-8GB VRAM (entry discrete) | |
| 218 | + | return gpuThreadsLow | |
| 219 | + | default: // <4GB VRAM (integrated / old discrete) | |
| 220 | + | return gpuThreadsMin | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // computeMaxMemoryLimits returns web process and network process memory limits. | |
| 225 | + | func computeMaxMemoryLimits(hw *port.HardwareInfo) (webMB, netMB int) { | |
| 226 | + | if hw == nil || hw.TotalRAM == 0 { | |
| 227 | + | // Fallback: assume 16GB system | |
| 228 | + | return webMemFallback, netMemFallback | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // Scale memory limits based on system RAM | |
| 232 | + | switch { | |
| 233 | + | case hw.TotalRAM >= ramTierHigh: // 32GB+ RAM | |
| 234 | + | return webMemHigh, netMemHigh | |
| 235 | + | case hw.TotalRAM >= ramTierMedium: // 16-32GB RAM | |
| 236 | + | return webMemMedium, netMemMedium | |
| 237 | + | case hw.TotalRAM >= ramTierLow: // 8-16GB RAM | |
| 238 | + | return webMemLow, netMemLow | |
| 239 | + | default: // <8GB RAM | |
| 240 | + | return webMemMin, netMemMin | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // computeMaxPoolPrewarm returns WebView pool prewarm count based on RAM. | |
| 245 | + | func computeMaxPoolPrewarm(hw *port.HardwareInfo) int { | |
| 246 | + | if hw == nil || hw.TotalRAM == 0 { | |
| 247 | + | return poolPrewarmFallback | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | switch { | |
| 251 | + | case hw.TotalRAM >= ramTierHigh: // 32GB+ | |
| 252 | + | return poolPrewarmHigh | |
| 253 | + | case hw.TotalRAM >= ramTierMedium: // 16-32GB | |
| 254 | + | return poolPrewarmMedium | |
| 255 | + | case hw.TotalRAM >= ramTierLow: // 8-16GB | |
| 256 | + | return poolPrewarmLow | |
| 257 | + | default: // <8GB | |
| 258 | + | return poolPrewarmMin | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | // resolveCustomProfile returns the user's configured values directly. | |
| 263 | + | func resolveCustomProfile(cfg *PerformanceConfig) ResolvedPerformanceSettings { | |
| 264 | + | return ResolvedPerformanceSettings{ | |
| 265 | + | SkiaCPUPaintingThreads: cfg.SkiaCPUPaintingThreads, | |
| 266 | + | SkiaGPUPaintingThreads: cfg.SkiaGPUPaintingThreads, | |
| 267 | + | SkiaEnableCPURendering: cfg.SkiaEnableCPURendering, | |
| 268 | + | ||
| 269 | + | WebProcessMemoryLimitMB: cfg.WebProcessMemoryLimitMB, | |
| 270 | + | WebProcessMemoryPollIntervalSec: cfg.WebProcessMemoryPollIntervalSec, | |
| 271 | + | WebProcessMemoryConservativeThreshold: cfg.WebProcessMemoryConservativeThreshold, | |
| 272 | + | WebProcessMemoryStrictThreshold: cfg.WebProcessMemoryStrictThreshold, | |
| 273 | + | ||
| 274 | + | NetworkProcessMemoryLimitMB: cfg.NetworkProcessMemoryLimitMB, | |
| 275 | + | NetworkProcessMemoryPollIntervalSec: cfg.NetworkProcessMemoryPollIntervalSec, | |
| 276 | + | NetworkProcessMemoryConservativeThreshold: cfg.NetworkProcessMemoryConservativeThreshold, | |
| 277 | + | NetworkProcessMemoryStrictThreshold: cfg.NetworkProcessMemoryStrictThreshold, | |
| 278 | + | ||
| 279 | + | WebViewPoolPrewarmCount: cfg.WebViewPoolPrewarmCount, | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // HasCustomPerformanceFields returns true if any individual performance tuning | |
| 284 | + | // fields are set to non-default values. Used for validation. | |
| 285 | + | func HasCustomPerformanceFields(cfg *PerformanceConfig) bool { | |
| 286 | + | return cfg.SkiaCPUPaintingThreads != 0 || | |
| 287 | + | cfg.SkiaGPUPaintingThreads != -1 || | |
| 288 | + | cfg.SkiaEnableCPURendering || | |
| 289 | + | cfg.WebProcessMemoryLimitMB != 0 || | |
| 290 | + | cfg.WebProcessMemoryPollIntervalSec != 0 || | |
| 291 | + | cfg.WebProcessMemoryConservativeThreshold != 0 || | |
| 292 | + | cfg.WebProcessMemoryStrictThreshold != 0 || | |
| 293 | + | cfg.NetworkProcessMemoryLimitMB != 0 || | |
| 294 | + | cfg.NetworkProcessMemoryPollIntervalSec != 0 || | |
| 295 | + | cfg.NetworkProcessMemoryConservativeThreshold != 0 || | |
| 296 | + | cfg.NetworkProcessMemoryStrictThreshold != 0 | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // IsValidPerformanceProfile returns true if the profile name is recognized. | |
| 300 | + | func IsValidPerformanceProfile(profile PerformanceProfile) bool { | |
| 301 | + | switch profile { | |
| 302 | + | case ProfileDefault, ProfileLite, ProfileMax, ProfileCustom, "": | |
| 303 | + | return true | |
| 304 | + | default: | |
| 305 | + | return false | |
| 306 | + | } | |
| 307 | + | } | |
A internal/infrastructure/config/profile_test.go +401 -0
| @@ -0,0 +1,401 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "runtime" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResolvePerformanceProfile_Default(t *testing.T) { | |
| 11 | + | cfg := &PerformanceConfig{ | |
| 12 | + | Profile: ProfileDefault, | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | result := ResolvePerformanceProfile(cfg, nil) | |
| 16 | + | ||
| 17 | + | // Default should have no tuning (unset values) | |
| 18 | + | if result.SkiaCPUPaintingThreads != 0 { | |
| 19 | + | t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads) | |
| 20 | + | } | |
| 21 | + | if result.SkiaGPUPaintingThreads != -1 { | |
| 22 | + | t.Errorf("expected SkiaGPUPaintingThreads=-1, got %d", result.SkiaGPUPaintingThreads) | |
| 23 | + | } | |
| 24 | + | if result.WebProcessMemoryLimitMB != 0 { | |
| 25 | + | t.Errorf("expected WebProcessMemoryLimitMB=0, got %d", result.WebProcessMemoryLimitMB) | |
| 26 | + | } | |
| 27 | + | if result.WebViewPoolPrewarmCount != 4 { | |
| 28 | + | t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestResolvePerformanceProfile_Empty(t *testing.T) { | |
| 33 | + | cfg := &PerformanceConfig{ | |
| 34 | + | Profile: "", // Empty should behave like default | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | result := ResolvePerformanceProfile(cfg, nil) | |
| 38 | + | ||
| 39 | + | if result.SkiaCPUPaintingThreads != 0 { | |
| 40 | + | t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads) | |
| 41 | + | } | |
| 42 | + | if result.WebViewPoolPrewarmCount != 4 { | |
| 43 | + | t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) { | |
| 48 | + | cfg := &PerformanceConfig{ | |
| 49 | + | Profile: ProfileLite, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | result := ResolvePerformanceProfile(cfg, nil) | |
| 53 | + | ||
| 54 | + | // Lite without hardware info should use default lite values | |
| 55 | + | if result.SkiaCPUPaintingThreads != 2 { | |
| 56 | + | t.Errorf("expected SkiaCPUPaintingThreads=2, got %d", result.SkiaCPUPaintingThreads) | |
| 57 | + | } | |
| 58 | + | if result.WebProcessMemoryLimitMB != 768 { | |
| 59 | + | t.Errorf("expected WebProcessMemoryLimitMB=768, got %d", result.WebProcessMemoryLimitMB) | |
| 60 | + | } | |
| 61 | + | if result.NetworkProcessMemoryLimitMB != 384 { | |
| 62 | + | t.Errorf("expected NetworkProcessMemoryLimitMB=384, got %d", result.NetworkProcessMemoryLimitMB) | |
| 63 | + | } | |
| 64 | + | if result.WebViewPoolPrewarmCount != 2 { | |
| 65 | + | t.Errorf("expected WebViewPoolPrewarmCount=2, got %d", result.WebViewPoolPrewarmCount) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestResolvePerformanceProfile_Lite_LowCoreSystem(t *testing.T) { | |
| 70 | + | cfg := &PerformanceConfig{ | |
| 71 | + | Profile: ProfileLite, | |
| 72 | + | } | |
| 73 | + | hw := &port.HardwareInfo{ | |
| 74 | + | CPUCores: 2, // Very low core count | |
| 75 | + | CPUThreads: 4, | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | result := ResolvePerformanceProfile(cfg, hw) | |
| 79 | + | ||
| 80 | + | // Lite on low-core system should scale down | |
| 81 | + | if result.SkiaCPUPaintingThreads != 1 { | |
| 82 | + | t.Errorf("expected SkiaCPUPaintingThreads=1 for 2-core system, got %d", result.SkiaCPUPaintingThreads) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestResolvePerformanceProfile_Max_NoHardware(t *testing.T) { | |
| 87 | + | cfg := &PerformanceConfig{ | |
| 88 | + | Profile: ProfileMax, | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | result := ResolvePerformanceProfile(cfg, nil) | |
| 92 | + | ||
| 93 | + | // Max without hardware info should use fallback values | |
| 94 | + | // CPU threads: NumCPU/2 (assuming hyperthreading), clamped to [4, 8] | |
| 95 | + | expectedCPUThreads := runtime.NumCPU() / 2 | |
| 96 | + | if expectedCPUThreads < 4 { | |
| 97 | + | expectedCPUThreads = 4 | |
| 98 | + | } | |
| 99 | + | if expectedCPUThreads > 8 { | |
| 100 | + | expectedCPUThreads = 8 | |
| 101 | + | } | |
| 102 | + | if result.SkiaCPUPaintingThreads != expectedCPUThreads { | |
| 103 | + | t.Errorf("expected SkiaCPUPaintingThreads=%d, got %d", expectedCPUThreads, result.SkiaCPUPaintingThreads) | |
| 104 | + | } | |
| 105 | + | // GPU threads: conservative default when no VRAM info | |
| 106 | + | if result.SkiaGPUPaintingThreads != 2 { | |
| 107 | + | t.Errorf("expected SkiaGPUPaintingThreads=2 (fallback), got %d", result.SkiaGPUPaintingThreads) | |
| 108 | + | } | |
| 109 | + | // Max profile: no memory limits (unset) | |
| 110 | + | if result.WebProcessMemoryLimitMB != 0 { | |
| 111 | + | t.Errorf("expected WebProcessMemoryLimitMB=0 (unset), got %d", result.WebProcessMemoryLimitMB) | |
| 112 | + | } | |
| 113 | + | if result.WebViewPoolPrewarmCount != 8 { | |
| 114 | + | t.Errorf("expected WebViewPoolPrewarmCount=8, got %d", result.WebViewPoolPrewarmCount) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestResolvePerformanceProfile_Max_HighEndSystem(t *testing.T) { | |
| 119 | + | cfg := &PerformanceConfig{ | |
| 120 | + | Profile: ProfileMax, | |
| 121 | + | } | |
| 122 | + | hw := &port.HardwareInfo{ | |
| 123 | + | CPUCores: 16, | |
| 124 | + | CPUThreads: 32, | |
| 125 | + | TotalRAM: 64 * 1024 * 1024 * 1024, // 64 GB | |
| 126 | + | VRAM: 16 * 1024 * 1024 * 1024, // 16 GB (high-end GPU) | |
| 127 | + | GPUVendor: port.GPUVendorAMD, | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | result := ResolvePerformanceProfile(cfg, hw) | |
| 131 | + | ||
| 132 | + | // CPU threads: use physical cores capped at 8 (WebKitGTK limit) | |
| 133 | + | if result.SkiaCPUPaintingThreads != 8 { | |
| 134 | + | t.Errorf("expected SkiaCPUPaintingThreads=8 (capped), got %d", result.SkiaCPUPaintingThreads) | |
| 135 | + | } | |
| 136 | + | // GPU threads: high-end tier (16GB+ VRAM) | |
| 137 | + | if result.SkiaGPUPaintingThreads != 8 { | |
| 138 | + | t.Errorf("expected SkiaGPUPaintingThreads=8 for 16GB VRAM, got %d", result.SkiaGPUPaintingThreads) | |
| 139 | + | } | |
| 140 | + | // Max profile: no memory limits (unset) | |
| 141 | + | if result.WebProcessMemoryLimitMB != 0 { | |
| 142 | + | t.Errorf("expected WebProcessMemoryLimitMB=0 (unset) for max profile, got %d", result.WebProcessMemoryLimitMB) | |
| 143 | + | } | |
| 144 | + | if result.NetworkProcessMemoryLimitMB != 0 { | |
| 145 | + | t.Errorf("expected NetworkProcessMemoryLimitMB=0 (unset) for max profile, got %d", result.NetworkProcessMemoryLimitMB) | |
| 146 | + | } | |
| 147 | + | // Pool prewarm: high-end tier | |
| 148 | + | if result.WebViewPoolPrewarmCount != 12 { | |
| 149 | + | t.Errorf("expected WebViewPoolPrewarmCount=12 for 64GB RAM, got %d", result.WebViewPoolPrewarmCount) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func TestResolvePerformanceProfile_Max_MidRangeSystem(t *testing.T) { | |
| 154 | + | cfg := &PerformanceConfig{ | |
| 155 | + | Profile: ProfileMax, | |
| 156 | + | } | |
| 157 | + | hw := &port.HardwareInfo{ | |
| 158 | + | CPUCores: 8, | |
| 159 | + | CPUThreads: 16, | |
| 160 | + | TotalRAM: 16 * 1024 * 1024 * 1024, // 16 GB | |
| 161 | + | VRAM: 8 * 1024 * 1024 * 1024, // 8 GB (mid-range GPU) | |
| 162 | + | GPUVendor: port.GPUVendorNVIDIA, | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | result := ResolvePerformanceProfile(cfg, hw) | |
| 166 | + | ||
| 167 | + | // CPU threads: use physical cores (8) | |
| 168 | + | if result.SkiaCPUPaintingThreads != 8 { | |
| 169 | + | t.Errorf("expected SkiaCPUPaintingThreads=8, got %d", result.SkiaCPUPaintingThreads) | |
| 170 | + | } | |
| 171 | + | // GPU threads: mid-range tier (8-16GB VRAM) | |
| 172 | + | if result.SkiaGPUPaintingThreads != 6 { | |
| 173 | + | t.Errorf("expected SkiaGPUPaintingThreads=6 for 8GB VRAM, got %d", result.SkiaGPUPaintingThreads) | |
| 174 | + | } | |
| 175 | + | // Max profile: no memory limits (unset) | |
| 176 | + | if result.WebProcessMemoryLimitMB != 0 { | |
| 177 | + | t.Errorf("expected WebProcessMemoryLimitMB=0 (unset) for max profile, got %d", result.WebProcessMemoryLimitMB) | |
| 178 | + | } | |
| 179 | + | // Pool prewarm: mid-range tier | |
| 180 | + | if result.WebViewPoolPrewarmCount != 8 { | |
| 181 | + | t.Errorf("expected WebViewPoolPrewarmCount=8 for 16GB RAM, got %d", result.WebViewPoolPrewarmCount) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func TestResolvePerformanceProfile_Max_LowEndSystem(t *testing.T) { | |
| 186 | + | cfg := &PerformanceConfig{ | |
| 187 | + | Profile: ProfileMax, | |
| 188 | + | } | |
| 189 | + | hw := &port.HardwareInfo{ | |
| 190 | + | CPUCores: 4, | |
| 191 | + | CPUThreads: 4, | |
| 192 | + | TotalRAM: 4 * 1024 * 1024 * 1024, // 4 GB | |
| 193 | + | VRAM: 2 * 1024 * 1024 * 1024, // 2 GB (integrated/old discrete) | |
| 194 | + | GPUVendor: port.GPUVendorIntel, | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | result := ResolvePerformanceProfile(cfg, hw) | |
| 198 | + | ||
| 199 | + | // CPU threads: use physical cores (4) | |
| 200 | + | if result.SkiaCPUPaintingThreads != 4 { | |
| 201 | + | t.Errorf("expected SkiaCPUPaintingThreads=4, got %d", result.SkiaCPUPaintingThreads) | |
| 202 | + | } | |
| 203 | + | // GPU threads: low tier (<4GB VRAM) | |
| 204 | + | if result.SkiaGPUPaintingThreads != 2 { | |
| 205 | + | t.Errorf("expected SkiaGPUPaintingThreads=2 for 2GB VRAM, got %d", result.SkiaGPUPaintingThreads) | |
| 206 | + | } | |
| 207 | + | // Max profile: no memory limits (unset) | |
| 208 | + | if result.WebProcessMemoryLimitMB != 0 { | |
| 209 | + | t.Errorf("expected WebProcessMemoryLimitMB=0 (unset) for max profile, got %d", result.WebProcessMemoryLimitMB) | |
| 210 | + | } | |
| 211 | + | // Pool prewarm: low tier | |
| 212 | + | if result.WebViewPoolPrewarmCount != 4 { | |
| 213 | + | t.Errorf("expected WebViewPoolPrewarmCount=4 for 4GB RAM, got %d", result.WebViewPoolPrewarmCount) | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestResolvePerformanceProfile_Custom(t *testing.T) { | |
| 218 | + | cfg := &PerformanceConfig{ | |
| 219 | + | Profile: ProfileCustom, | |
| 220 | + | SkiaCPUPaintingThreads: 6, | |
| 221 | + | SkiaGPUPaintingThreads: 3, | |
| 222 | + | SkiaEnableCPURendering: true, | |
| 223 | + | WebProcessMemoryLimitMB: 1024, | |
| 224 | + | WebProcessMemoryConservativeThreshold: 0.4, | |
| 225 | + | WebViewPoolPrewarmCount: 5, | |
| 226 | + | } | |
| 227 | + | // Hardware info should be ignored for custom profile | |
| 228 | + | hw := &port.HardwareInfo{ | |
| 229 | + | CPUCores: 16, | |
| 230 | + | CPUThreads: 32, | |
| 231 | + | TotalRAM: 64 * 1024 * 1024 * 1024, | |
| 232 | + | VRAM: 16 * 1024 * 1024 * 1024, | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | result := ResolvePerformanceProfile(cfg, hw) | |
| 236 | + | ||
| 237 | + | // Custom should use user-specified values, ignoring hardware | |
| 238 | + | if result.SkiaCPUPaintingThreads != 6 { | |
| 239 | + | t.Errorf("expected SkiaCPUPaintingThreads=6, got %d", result.SkiaCPUPaintingThreads) | |
| 240 | + | } | |
| 241 | + | if result.SkiaGPUPaintingThreads != 3 { | |
| 242 | + | t.Errorf("expected SkiaGPUPaintingThreads=3, got %d", result.SkiaGPUPaintingThreads) | |
| 243 | + | } | |
| 244 | + | if !result.SkiaEnableCPURendering { | |
| 245 | + | t.Error("expected SkiaEnableCPURendering=true") | |
| 246 | + | } | |
| 247 | + | if result.WebProcessMemoryLimitMB != 1024 { | |
| 248 | + | t.Errorf("expected WebProcessMemoryLimitMB=1024, got %d", result.WebProcessMemoryLimitMB) | |
| 249 | + | } | |
| 250 | + | if result.WebViewPoolPrewarmCount != 5 { | |
| 251 | + | t.Errorf("expected WebViewPoolPrewarmCount=5, got %d", result.WebViewPoolPrewarmCount) | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func TestHasCustomPerformanceFields(t *testing.T) { | |
| 256 | + | tests := []struct { | |
| 257 | + | name string | |
| 258 | + | cfg PerformanceConfig | |
| 259 | + | expected bool | |
| 260 | + | }{ | |
| 261 | + | { | |
| 262 | + | name: "all defaults - no custom fields", | |
| 263 | + | cfg: PerformanceConfig{ | |
| 264 | + | SkiaCPUPaintingThreads: 0, | |
| 265 | + | SkiaGPUPaintingThreads: -1, | |
| 266 | + | SkiaEnableCPURendering: false, | |
| 267 | + | }, | |
| 268 | + | expected: false, | |
| 269 | + | }, | |
| 270 | + | { | |
| 271 | + | name: "skia cpu threads set", | |
| 272 | + | cfg: PerformanceConfig{ | |
| 273 | + | SkiaCPUPaintingThreads: 4, | |
| 274 | + | SkiaGPUPaintingThreads: -1, | |
| 275 | + | }, | |
| 276 | + | expected: true, | |
| 277 | + | }, | |
| 278 | + | { | |
| 279 | + | name: "memory limit set", | |
| 280 | + | cfg: PerformanceConfig{ | |
| 281 | + | SkiaGPUPaintingThreads: -1, | |
| 282 | + | WebProcessMemoryLimitMB: 1024, | |
| 283 | + | }, | |
| 284 | + | expected: true, | |
| 285 | + | }, | |
| 286 | + | { | |
| 287 | + | name: "conservative threshold set", | |
| 288 | + | cfg: PerformanceConfig{ | |
| 289 | + | SkiaGPUPaintingThreads: -1, | |
| 290 | + | WebProcessMemoryConservativeThreshold: 0.4, | |
| 291 | + | }, | |
| 292 | + | expected: true, | |
| 293 | + | }, | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | for _, tt := range tests { | |
| 297 | + | t.Run(tt.name, func(t *testing.T) { | |
| 298 | + | result := HasCustomPerformanceFields(&tt.cfg) | |
| 299 | + | if result != tt.expected { | |
| 300 | + | t.Errorf("expected %v, got %v", tt.expected, result) | |
| 301 | + | } | |
| 302 | + | }) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func TestIsValidPerformanceProfile(t *testing.T) { | |
| 307 | + | tests := []struct { | |
| 308 | + | profile PerformanceProfile | |
| 309 | + | expected bool | |
| 310 | + | }{ | |
| 311 | + | {ProfileDefault, true}, | |
| 312 | + | {ProfileLite, true}, | |
| 313 | + | {ProfileMax, true}, | |
| 314 | + | {ProfileCustom, true}, | |
| 315 | + | {"", true}, // Empty is valid (treated as default) | |
| 316 | + | {"invalid", false}, | |
| 317 | + | {"LITE", false}, // Case sensitive | |
| 318 | + | {"Maximum", false}, | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | for _, tt := range tests { | |
| 322 | + | t.Run(string(tt.profile), func(t *testing.T) { | |
| 323 | + | result := IsValidPerformanceProfile(tt.profile) | |
| 324 | + | if result != tt.expected { | |
| 325 | + | t.Errorf("IsValidPerformanceProfile(%q): expected %v, got %v", tt.profile, tt.expected, result) | |
| 326 | + | } | |
| 327 | + | }) | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func TestComputeMaxGPUThreads_VRAMTiers(t *testing.T) { | |
| 332 | + | tests := []struct { | |
| 333 | + | name string | |
| 334 | + | vramGB int | |
| 335 | + | expected int | |
| 336 | + | }{ | |
| 337 | + | {"no VRAM info", 0, 2}, | |
| 338 | + | {"2GB VRAM (integrated)", 2, 2}, | |
| 339 | + | {"4GB VRAM (entry discrete)", 4, 4}, | |
| 340 | + | {"6GB VRAM (low-mid discrete)", 6, 4}, | |
| 341 | + | {"8GB VRAM (mid discrete)", 8, 6}, | |
| 342 | + | {"12GB VRAM (mid-high discrete)", 12, 6}, | |
| 343 | + | {"16GB VRAM (high-end)", 16, 8}, | |
| 344 | + | {"24GB VRAM (workstation)", 24, 8}, | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | for _, tt := range tests { | |
| 348 | + | t.Run(tt.name, func(t *testing.T) { | |
| 349 | + | hw := &port.HardwareInfo{ | |
| 350 | + | VRAM: uint64(tt.vramGB) * 1024 * 1024 * 1024, | |
| 351 | + | } | |
| 352 | + | if tt.vramGB == 0 { | |
| 353 | + | hw = nil | |
| 354 | + | } | |
| 355 | + | result := computeMaxGPUThreads(hw) | |
| 356 | + | if result != tt.expected { | |
| 357 | + | t.Errorf("computeMaxGPUThreads(%dGB VRAM): expected %d, got %d", tt.vramGB, tt.expected, result) | |
| 358 | + | } | |
| 359 | + | }) | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func TestComputeMaxMemoryLimits_RAMTiers(t *testing.T) { | |
| 364 | + | tests := []struct { | |
| 365 | + | name string | |
| 366 | + | ramGB int | |
| 367 | + | expectedWeb int | |
| 368 | + | expectedNet int | |
| 369 | + | expectedPool int | |
| 370 | + | }{ | |
| 371 | + | {"no RAM info", 0, 2048, 512, 8}, | |
| 372 | + | {"4GB RAM", 4, 1024, 256, 4}, | |
| 373 | + | {"8GB RAM", 8, 2048, 512, 6}, | |
| 374 | + | {"16GB RAM", 16, 3072, 768, 8}, | |
| 375 | + | {"32GB RAM", 32, 4096, 1024, 12}, | |
| 376 | + | {"64GB RAM", 64, 4096, 1024, 12}, | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | for _, tt := range tests { | |
| 380 | + | t.Run(tt.name, func(t *testing.T) { | |
| 381 | + | hw := &port.HardwareInfo{ | |
| 382 | + | TotalRAM: uint64(tt.ramGB) * 1024 * 1024 * 1024, | |
| 383 | + | } | |
| 384 | + | if tt.ramGB == 0 { | |
| 385 | + | hw = nil | |
| 386 | + | } | |
| 387 | + | webMB, netMB := computeMaxMemoryLimits(hw) | |
| 388 | + | poolPrewarm := computeMaxPoolPrewarm(hw) | |
| 389 | + | ||
| 390 | + | if webMB != tt.expectedWeb { | |
| 391 | + | t.Errorf("computeMaxMemoryLimits(%dGB RAM) web: expected %d, got %d", tt.ramGB, tt.expectedWeb, webMB) | |
| 392 | + | } | |
| 393 | + | if netMB != tt.expectedNet { | |
| 394 | + | t.Errorf("computeMaxMemoryLimits(%dGB RAM) net: expected %d, got %d", tt.ramGB, tt.expectedNet, netMB) | |
| 395 | + | } | |
| 396 | + | if poolPrewarm != tt.expectedPool { | |
| 397 | + | t.Errorf("computeMaxPoolPrewarm(%dGB RAM): expected %d, got %d", tt.ramGB, tt.expectedPool, poolPrewarm) | |
| 398 | + | } | |
| 399 | + | }) | |
| 400 | + | } | |
| 401 | + | } | |
M internal/infrastructure/config/schema.go +196 -11
| @@ -17,6 +17,8 @@ type Config struct { | |||
| 17 | 17 | DefaultUIScale float64 `mapstructure:"default_ui_scale" yaml:"default_ui_scale" toml:"default_ui_scale"` | |
| 18 | 18 | // Workspace defines workspace, pane, and tab handling behavior. | |
| 19 | 19 | Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"` | |
| 20 | + | // Session controls session persistence and restoration. | |
| 21 | + | Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"` | |
| 20 | 22 | // ContentFiltering controls ad blocking and content filtering | |
| 21 | 23 | ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"` | |
| 22 | 24 | // Omnibox controls the omnibox behavior (initial history display) | |
| @@ -27,6 +29,10 @@ type Config struct { | |||
| 27 | 29 | Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"` | |
| 28 | 30 | // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK). | |
| 29 | 31 | Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"` | |
| 32 | + | // Performance holds internal performance tuning options (not exposed in UI). | |
| 33 | + | Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"` | |
| 34 | + | // Update controls automatic update checking and downloading. | |
| 35 | + | Update UpdateConfig `mapstructure:"update" yaml:"update" toml:"update"` | |
| 30 | 36 | } | |
| 31 | 37 | ||
| 32 | 38 | // RenderingMode selects GPU vs CPU rendering. | |
| @@ -119,6 +125,20 @@ const ( | |||
| 119 | 125 | GLRenderingModeNone GLRenderingMode = "none" | |
| 120 | 126 | ) | |
| 121 | 127 | ||
| 128 | + | // PerformanceProfile selects preset performance tuning settings. | |
| 129 | + | type PerformanceProfile string | |
| 130 | + | ||
| 131 | + | const ( | |
| 132 | + | // ProfileDefault uses WebKit defaults (no tuning applied). | |
| 133 | + | ProfileDefault PerformanceProfile = "default" | |
| 134 | + | // ProfileLite reduces resource usage for low-RAM systems. | |
| 135 | + | ProfileLite PerformanceProfile = "lite" | |
| 136 | + | // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc). | |
| 137 | + | ProfileMax PerformanceProfile = "max" | |
| 138 | + | // ProfileCustom allows full control over individual performance settings. | |
| 139 | + | ProfileCustom PerformanceProfile = "custom" | |
| 140 | + | ) | |
| 141 | + | ||
| 122 | 142 | // ThemeDefault is the default theme setting (follows system). | |
| 123 | 143 | const ThemeDefault = "default" | |
| 124 | 144 | ||
| @@ -172,6 +192,74 @@ type HistoryConfig struct { | |||
| 172 | 192 | CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days" toml:"cleanup_interval_days"` | |
| 173 | 193 | } | |
| 174 | 194 | ||
| 195 | + | // PerformanceConfig holds internal performance tuning options. | |
| 196 | + | // These are not exposed in dumb://config UI but can be set in config file. | |
| 197 | + | type PerformanceConfig struct { | |
| 198 | + | // Profile selects a preset performance configuration. | |
| 199 | + | // Values: "default" (no tuning), "lite" (low RAM), "max" (heavy pages), "custom" (manual control) | |
| 200 | + | // When profile is not "custom", individual tuning fields below are ignored and computed from the profile. | |
| 201 | + | // Default: "default" | |
| 202 | + | Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"` | |
| 203 | + | ||
| 204 | + | // ZoomCacheSize is the number of domain zoom levels to cache in memory. | |
| 205 | + | // Higher values reduce database queries but use more memory. | |
| 206 | + | // Default: 256 domains (~20KB memory) | |
| 207 | + | ZoomCacheSize int `mapstructure:"zoom_cache_size" yaml:"zoom_cache_size" toml:"zoom_cache_size"` | |
| 208 | + | ||
| 209 | + | // WebViewPoolPrewarmCount is the number of WebViews to pre-create at startup. | |
| 210 | + | // Higher values speed up initial tab creation but use more memory. | |
| 211 | + | // Default: 4 (custom mode), computed from profile otherwise | |
| 212 | + | WebViewPoolPrewarmCount int `mapstructure:"webview_pool_prewarm_count" yaml:"webview_pool_prewarm_count" toml:"webview_pool_prewarm_count"` //nolint:lll // struct tags must stay on one line | |
| 213 | + | ||
| 214 | + | // --- Skia rendering threads (env vars) --- | |
| 215 | + | // NOTE: Fields below only apply when Profile is "custom". | |
| 216 | + | // SkiaCPUPaintingThreads sets WEBKIT_SKIA_CPU_PAINTING_THREADS. | |
| 217 | + | // Default: 0 (unset, uses WebKit default) | |
| 218 | + | SkiaCPUPaintingThreads int `mapstructure:"skia_cpu_painting_threads" yaml:"skia_cpu_painting_threads" toml:"skia_cpu_painting_threads"` //nolint:lll // struct tags must stay on one line | |
| 219 | + | ||
| 220 | + | // SkiaGPUPaintingThreads sets WEBKIT_SKIA_GPU_PAINTING_THREADS. | |
| 221 | + | // Default: -1 (unset). Value 0 disables GPU tile painting. | |
| 222 | + | SkiaGPUPaintingThreads int `mapstructure:"skia_gpu_painting_threads" yaml:"skia_gpu_painting_threads" toml:"skia_gpu_painting_threads"` //nolint:lll // struct tags must stay on one line | |
| 223 | + | ||
| 224 | + | // SkiaEnableCPURendering forces CPU rendering via WEBKIT_SKIA_ENABLE_CPU_RENDERING=1. | |
| 225 | + | // Default: false | |
| 226 | + | SkiaEnableCPURendering bool `mapstructure:"skia_enable_cpu_rendering" yaml:"skia_enable_cpu_rendering" toml:"skia_enable_cpu_rendering"` //nolint:lll // struct tags must stay on one line | |
| 227 | + | ||
| 228 | + | // --- Web process memory pressure --- | |
| 229 | + | // WebProcessMemoryLimitMB sets memory limit in MB for web processes. | |
| 230 | + | // Default: 0 (unset, uses WebKit default: system RAM capped at 3GB) | |
| 231 | + | WebProcessMemoryLimitMB int `mapstructure:"web_process_memory_limit_mb" yaml:"web_process_memory_limit_mb" toml:"web_process_memory_limit_mb"` //nolint:lll // struct tags must stay on one line | |
| 232 | + | ||
| 233 | + | // WebProcessMemoryPollIntervalSec sets poll interval for memory checks. | |
| 234 | + | // Default: 0 (unset, uses WebKit default: 30 seconds) | |
| 235 | + | WebProcessMemoryPollIntervalSec float64 `mapstructure:"web_process_memory_poll_interval_sec" yaml:"web_process_memory_poll_interval_sec" toml:"web_process_memory_poll_interval_sec"` //nolint:lll // struct tags must stay on one line | |
| 236 | + | ||
| 237 | + | // WebProcessMemoryConservativeThreshold sets threshold for conservative memory release. | |
| 238 | + | // Valid: (0, 1). Default: 0 (unset, uses WebKit default: 0.33) | |
| 239 | + | WebProcessMemoryConservativeThreshold float64 `mapstructure:"web_process_memory_conservative_threshold" yaml:"web_process_memory_conservative_threshold" toml:"web_process_memory_conservative_threshold"` //nolint:lll // struct tags must stay on one line | |
| 240 | + | ||
| 241 | + | // WebProcessMemoryStrictThreshold sets threshold for strict memory release. | |
| 242 | + | // Valid: (0, 1). Default: 0 (unset, uses WebKit default: 0.5) | |
| 243 | + | WebProcessMemoryStrictThreshold float64 `mapstructure:"web_process_memory_strict_threshold" yaml:"web_process_memory_strict_threshold" toml:"web_process_memory_strict_threshold"` //nolint:lll // struct tags must stay on one line | |
| 244 | + | ||
| 245 | + | // --- Network process memory pressure --- | |
| 246 | + | // NetworkProcessMemoryLimitMB sets memory limit in MB for network process. | |
| 247 | + | // Default: 0 (unset) | |
| 248 | + | NetworkProcessMemoryLimitMB int `mapstructure:"network_process_memory_limit_mb" yaml:"network_process_memory_limit_mb" toml:"network_process_memory_limit_mb"` //nolint:lll // struct tags must stay on one line | |
| 249 | + | ||
| 250 | + | // NetworkProcessMemoryPollIntervalSec sets poll interval for memory checks. | |
| 251 | + | // Default: 0 (unset) | |
| 252 | + | NetworkProcessMemoryPollIntervalSec float64 `mapstructure:"network_process_memory_poll_interval_sec" yaml:"network_process_memory_poll_interval_sec" toml:"network_process_memory_poll_interval_sec"` //nolint:lll // struct tags must stay on one line | |
| 253 | + | ||
| 254 | + | // NetworkProcessMemoryConservativeThreshold sets threshold for conservative memory release. | |
| 255 | + | // Valid: (0, 1). Default: 0 (unset) | |
| 256 | + | NetworkProcessMemoryConservativeThreshold float64 `mapstructure:"network_process_memory_conservative_threshold" yaml:"network_process_memory_conservative_threshold" toml:"network_process_memory_conservative_threshold"` //nolint:lll // struct tags must stay on one line | |
| 257 | + | ||
| 258 | + | // NetworkProcessMemoryStrictThreshold sets threshold for strict memory release. | |
| 259 | + | // Valid: (0, 1). Default: 0 (unset) | |
| 260 | + | NetworkProcessMemoryStrictThreshold float64 `mapstructure:"network_process_memory_strict_threshold" yaml:"network_process_memory_strict_threshold" toml:"network_process_memory_strict_threshold"` //nolint:lll // struct tags must stay on one line | |
| 261 | + | } | |
| 262 | + | ||
| 175 | 263 | // SearchShortcut represents a search shortcut configuration. | |
| 176 | 264 | type SearchShortcut struct { | |
| 177 | 265 | URL string `mapstructure:"url" toml:"url" yaml:"url" json:"url"` | |
| @@ -190,7 +278,7 @@ func (c *Config) ShortcutURLs() map[string]string { | |||
| 190 | 278 | ||
| 191 | 279 | // DmenuConfig holds dmenu/rofi integration configuration. | |
| 192 | 280 | type DmenuConfig struct { | |
| 193 | - | MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"` | |
| 281 | + | MaxHistoryDays int `mapstructure:"max_history_days" yaml:"max_history_days" toml:"max_history_days"` | |
| 194 | 282 | ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"` | |
| 195 | 283 | ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"` | |
| 196 | 284 | HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"` | |
| @@ -254,6 +342,9 @@ type OmniboxConfig struct { | |||
| 254 | 342 | // InitialBehavior controls what to show when omnibox opens with empty input | |
| 255 | 343 | // Values: "recent" (recent visits), "most_visited" (most visited sites), "none" (nothing) | |
| 256 | 344 | InitialBehavior string `mapstructure:"initial_behavior" yaml:"initial_behavior" toml:"initial_behavior"` | |
| 345 | + | // AutoOpenOnNewPane opens the omnibox automatically when a new pane is created. | |
| 346 | + | // Default: false | |
| 347 | + | AutoOpenOnNewPane bool `mapstructure:"auto_open_on_new_pane" yaml:"auto_open_on_new_pane" toml:"auto_open_on_new_pane"` | |
| 257 | 348 | } | |
| 258 | 349 | ||
| 259 | 350 | // DebugConfig holds debug and troubleshooting options | |
| @@ -262,18 +353,40 @@ type DebugConfig struct { | |||
| 262 | 353 | EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"` | |
| 263 | 354 | } | |
| 264 | 355 | ||
| 356 | + | // UpdateConfig holds automatic update settings. | |
| 357 | + | type UpdateConfig struct { | |
| 358 | + | // EnableOnStartup enables checking for updates when the browser starts. | |
| 359 | + | // Default: true | |
| 360 | + | EnableOnStartup bool `mapstructure:"enable_on_startup" yaml:"enable_on_startup" toml:"enable_on_startup"` | |
| 361 | + | // AutoDownload automatically downloads updates in the background. | |
| 362 | + | // When enabled, updates are applied on browser exit. | |
| 363 | + | // Default: false | |
| 364 | + | AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"` | |
| 365 | + | // NotifyOnNewSettings shows a toast notification on startup when new config settings are available. | |
| 366 | + | // Default: true | |
| 367 | + | NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"` | |
| 368 | + | } | |
| 369 | + | ||
| 265 | 370 | // WorkspaceConfig captures layout, pane, and tab behavior preferences. | |
| 266 | 371 | type WorkspaceConfig struct { | |
| 372 | + | // NewPaneURL is the URL loaded when creating a new empty pane/tab. | |
| 373 | + | // Default: "about:blank" | |
| 374 | + | NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url"` | |
| 267 | 375 | // PaneMode defines modal pane behavior and bindings. | |
| 268 | 376 | PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"` | |
| 269 | 377 | // TabMode defines modal tab behavior and bindings (Alt+T). | |
| 270 | 378 | TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"` | |
| 379 | + | // ResizeMode defines modal pane resizing behavior and bindings (Ctrl+N). | |
| 380 | + | ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"` | |
| 271 | 381 | // Shortcuts holds global (non-modal) keyboard shortcuts. | |
| 272 | 382 | Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"` | |
| 273 | 383 | // TabBarPosition determines tab bar placement: "top" or "bottom". | |
| 274 | 384 | TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"` | |
| 275 | 385 | // HideTabBarWhenSingleTab hides the tab bar when only one tab exists. | |
| 276 | 386 | HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line | |
| 387 | + | // SwitchToTabOnMove controls whether moving a pane to another tab switches focus to that tab. | |
| 388 | + | // Default: true | |
| 389 | + | SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line | |
| 277 | 390 | // Popups configures default popup placement rules. | |
| 278 | 391 | Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"` | |
| 279 | 392 | // Styling configures workspace visual appearance. | |
| @@ -318,11 +431,37 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string { | |||
| 318 | 431 | return keyToAction | |
| 319 | 432 | } | |
| 320 | 433 | ||
| 434 | + | // ResizeModeConfig defines modal behavior for resizing panes (Zellij-style). | |
| 435 | + | type ResizeModeConfig struct { | |
| 436 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | |
| 437 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | |
| 438 | + | StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"` | |
| 439 | + | MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line | |
| 440 | + | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | |
| 444 | + | // This is built from the action→keys structure in the config. | |
| 445 | + | func (r *ResizeModeConfig) GetKeyBindings() map[string]string { | |
| 446 | + | keyToAction := make(map[string]string) | |
| 447 | + | for action, keys := range r.Actions { | |
| 448 | + | for _, key := range keys { | |
| 449 | + | keyToAction[key] = action | |
| 450 | + | } | |
| 451 | + | } | |
| 452 | + | return keyToAction | |
| 453 | + | } | |
| 454 | + | ||
| 321 | 455 | // GlobalShortcutsConfig defines global shortcuts (always active, not modal). | |
| 322 | 456 | type GlobalShortcutsConfig struct { | |
| 323 | 457 | ClosePane string `mapstructure:"close_pane" yaml:"close_pane" toml:"close_pane" json:"close_pane"` | |
| 324 | 458 | NextTab string `mapstructure:"next_tab" yaml:"next_tab" toml:"next_tab" json:"next_tab"` | |
| 325 | 459 | PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" toml:"previous_tab" json:"previous_tab"` | |
| 460 | + | ||
| 461 | + | ConsumeOrExpelLeft string `mapstructure:"consume_or_expel_left" yaml:"consume_or_expel_left" toml:"consume_or_expel_left" json:"consume_or_expel_left"` //nolint:lll // struct tags must stay on one line | |
| 462 | + | ConsumeOrExpelRight string `mapstructure:"consume_or_expel_right" yaml:"consume_or_expel_right" toml:"consume_or_expel_right" json:"consume_or_expel_right"` //nolint:lll // struct tags must stay on one line | |
| 463 | + | ConsumeOrExpelUp string `mapstructure:"consume_or_expel_up" yaml:"consume_or_expel_up" toml:"consume_or_expel_up" json:"consume_or_expel_up"` //nolint:lll // struct tags must stay on one line | |
| 464 | + | ConsumeOrExpelDown string `mapstructure:"consume_or_expel_down" yaml:"consume_or_expel_down" toml:"consume_or_expel_down" json:"consume_or_expel_down"` //nolint:lll // struct tags must stay on one line | |
| 326 | 465 | } | |
| 327 | 466 | ||
| 328 | 467 | // PopupBehavior defines how popup windows should be opened | |
| @@ -373,18 +512,64 @@ type WorkspaceStylingConfig struct { | |||
| 373 | 512 | // BorderColor for focused panes (CSS color value or theme variable) | |
| 374 | 513 | BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"` | |
| 375 | 514 | ||
| 376 | - | // PaneModeBorderWidth in pixels for pane mode indicator border (Ctrl+P N overlay) | |
| 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 | |
| 381 | 526 | ||
| 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 | |
| 387 | 530 | ||
| 388 | 531 | // TransitionDuration in milliseconds for border animations | |
| 389 | 532 | TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line | |
| 390 | 533 | } | |
| 534 | + | ||
| 535 | + | // SessionConfig holds session persistence settings. | |
| 536 | + | type SessionConfig struct { | |
| 537 | + | // AutoRestore automatically restores the last session on startup. | |
| 538 | + | // Default: false | |
| 539 | + | AutoRestore bool `mapstructure:"auto_restore" yaml:"auto_restore" toml:"auto_restore" json:"auto_restore"` | |
| 540 | + | ||
| 541 | + | // SnapshotIntervalMs is the minimum interval between snapshots in milliseconds. | |
| 542 | + | // Default: 5000 | |
| 543 | + | SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line | |
| 544 | + | ||
| 545 | + | // MaxExitedSessions is the maximum number of exited sessions to keep. | |
| 546 | + | // Default: 50 | |
| 547 | + | MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line | |
| 548 | + | ||
| 549 | + | // MaxExitedSessionAgeDays is the maximum age in days for exited sessions. | |
| 550 | + | // Sessions older than this will be automatically deleted on startup. | |
| 551 | + | // Default: 7 | |
| 552 | + | MaxExitedSessionAgeDays int `mapstructure:"max_exited_session_age_days" yaml:"max_exited_session_age_days" toml:"max_exited_session_age_days" json:"max_exited_session_age_days"` //nolint:lll // struct tags must stay on one line | |
| 553 | + | ||
| 554 | + | // SessionMode defines modal behavior for session management (Ctrl+O). | |
| 555 | + | SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"` | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | // SessionModeConfig defines modal behavior for session management. | |
| 559 | + | type SessionModeConfig struct { | |
| 560 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | |
| 561 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | |
| 562 | + | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | |
| 566 | + | // This is built from the action→keys structure in the config. | |
| 567 | + | func (s *SessionModeConfig) GetKeyBindings() map[string]string { | |
| 568 | + | keyToAction := make(map[string]string) | |
| 569 | + | for action, keys := range s.Actions { | |
| 570 | + | for _, key := range keys { | |
| 571 | + | keyToAction[key] = action | |
| 572 | + | } | |
| 573 | + | } | |
| 574 | + | return keyToAction | |
| 575 | + | } | |
A internal/infrastructure/config/schema_provider.go +903 -0 collapsed to keep the page light
M internal/infrastructure/config/validation.go +98 -7
| @@ -2,8 +2,10 @@ package config | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "net/url" | |
| 5 | 6 | "strings" | |
| 6 | 7 | ||
| 8 | + | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 7 | 9 | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| @@ -21,9 +23,12 @@ func validateConfig(config *Config) error { | |||
| 21 | 23 | validationErrors = append(validationErrors, validateTabBar(config)...) | |
| 22 | 24 | validationErrors = append(validationErrors, validateTabMode(config)...) | |
| 23 | 25 | validationErrors = append(validationErrors, validateLogging(config)...) | |
| 26 | + | validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...) | |
| 24 | 27 | validationErrors = append(validationErrors, validateOmnibox(config)...) | |
| 25 | 28 | validationErrors = append(validationErrors, validateRendering(config)...) | |
| 26 | 29 | validationErrors = append(validationErrors, validateColorScheme(config)...) | |
| 30 | + | validationErrors = append(validationErrors, validateSession(config)...) | |
| 31 | + | validationErrors = append(validationErrors, validatePerformanceProfile(config)...) | |
| 27 | 32 | ||
| 28 | 33 | // If there are validation errors, return them | |
| 29 | 34 | if len(validationErrors) > 0 { | |
| @@ -48,8 +53,8 @@ func validateHistory(config *Config) []string { | |||
| 48 | 53 | } | |
| 49 | 54 | ||
| 50 | 55 | 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"} | |
| 53 | 58 | } | |
| 54 | 59 | return nil | |
| 55 | 60 | } | |
| @@ -142,11 +147,8 @@ func validateWorkspaceStyling(config *Config) []string { | |||
| 142 | 147 | if config.Workspace.Styling.BorderWidth < 0 { | |
| 143 | 148 | validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative") | |
| 144 | 149 | } | |
| 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") | |
| 150 | 152 | } | |
| 151 | 153 | if config.Workspace.Styling.TransitionDuration < 0 { | |
| 152 | 154 | validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative") | |
| @@ -245,6 +247,37 @@ func validateLogging(config *Config) []string { | |||
| 245 | 247 | return validationErrors | |
| 246 | 248 | } | |
| 247 | 249 | ||
| 250 | + | func validateWorkspaceNewPaneURL(config *Config) []string { | |
| 251 | + | var validationErrors []string | |
| 252 | + | ||
| 253 | + | if config.Workspace.NewPaneURL == "" { | |
| 254 | + | validationErrors = append(validationErrors, "workspace.new_pane_url cannot be empty") | |
| 255 | + | return validationErrors | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | normalized := domainurl.Normalize(config.Workspace.NewPaneURL) | |
| 259 | + | parsed, err := url.Parse(normalized) | |
| 260 | + | if err != nil { | |
| 261 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 262 | + | "workspace.new_pane_url must be a valid URL (got: %s)", | |
| 263 | + | config.Workspace.NewPaneURL, | |
| 264 | + | )) | |
| 265 | + | return validationErrors | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | switch parsed.Scheme { | |
| 269 | + | case "http", "https", "dumb", "file", "about": | |
| 270 | + | // ok | |
| 271 | + | default: | |
| 272 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 273 | + | "workspace.new_pane_url must use one of: http, https, dumb, file, about (got: %s)", | |
| 274 | + | parsed.Scheme, | |
| 275 | + | )) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | return validationErrors | |
| 279 | + | } | |
| 280 | + | ||
| 248 | 281 | func validateOmnibox(config *Config) []string { | |
| 249 | 282 | switch config.Omnibox.InitialBehavior { | |
| 250 | 283 | case "recent", "most_visited", "none": | |
| @@ -301,3 +334,61 @@ func validateColorScheme(config *Config) []string { | |||
| 301 | 334 | )} | |
| 302 | 335 | } | |
| 303 | 336 | } | |
| 337 | + | ||
| 338 | + | func validateSession(config *Config) []string { | |
| 339 | + | var validationErrors []string | |
| 340 | + | if config.Session.MaxExitedSessions < 0 { | |
| 341 | + | validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative") | |
| 342 | + | } | |
| 343 | + | if config.Session.MaxExitedSessionAgeDays < 0 { | |
| 344 | + | validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative") | |
| 345 | + | } | |
| 346 | + | if config.Session.SnapshotIntervalMs < 0 { | |
| 347 | + | validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative") | |
| 348 | + | } | |
| 349 | + | return validationErrors | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func validatePerformanceProfile(config *Config) []string { | |
| 353 | + | var validationErrors []string | |
| 354 | + | ||
| 355 | + | // Validate profile name | |
| 356 | + | if !IsValidPerformanceProfile(config.Performance.Profile) { | |
| 357 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 358 | + | "performance.profile must be one of: default, lite, max, custom (got: %s)", | |
| 359 | + | config.Performance.Profile, | |
| 360 | + | )) | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // When profile is not "custom", warn if individual fields are set | |
| 364 | + | // (they will be ignored in favor of profile-computed values) | |
| 365 | + | if config.Performance.Profile != ProfileCustom && config.Performance.Profile != "" { | |
| 366 | + | if HasCustomPerformanceFields(&config.Performance) { | |
| 367 | + | validationErrors = append(validationErrors, | |
| 368 | + | "performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+ | |
| 369 | + | "set profile = \"custom\" to use individual field values", | |
| 370 | + | ) | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | // Validate memory pressure threshold ordering: WebKit requires conservative < strict | |
| 375 | + | webCons := config.Performance.WebProcessMemoryConservativeThreshold | |
| 376 | + | webStrict := config.Performance.WebProcessMemoryStrictThreshold | |
| 377 | + | if webCons > 0 && webStrict > 0 && webCons >= webStrict { | |
| 378 | + | validationErrors = append(validationErrors, | |
| 379 | + | "performance.web_process_memory_conservative_threshold must be less than "+ | |
| 380 | + | "web_process_memory_strict_threshold when both are set", | |
| 381 | + | ) | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | netCons := config.Performance.NetworkProcessMemoryConservativeThreshold | |
| 385 | + | netStrict := config.Performance.NetworkProcessMemoryStrictThreshold | |
| 386 | + | if netCons > 0 && netStrict > 0 && netCons >= netStrict { | |
| 387 | + | validationErrors = append(validationErrors, | |
| 388 | + | "performance.network_process_memory_conservative_threshold must be less than "+ | |
| 389 | + | "network_process_memory_strict_threshold when both are set", | |
| 390 | + | ) | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | return validationErrors | |
| 394 | + | } | |
M internal/infrastructure/config/webui_gateway.go +32 -0 collapsed to keep the page light
M internal/infrastructure/desktop/adapter.go +54 -0 collapsed to keep the page light
A internal/infrastructure/env/hardware_survey.go +349 -0 collapsed to keep the page light
M internal/infrastructure/env/manager.go +22 -12 collapsed to keep the page light
A internal/infrastructure/env/manager_test.go +172 -0 collapsed to keep the page light
M internal/infrastructure/favicon/cache.go +27 -0 collapsed to keep the page light
M internal/infrastructure/favicon/service.go +36 -0 collapsed to keep the page light
A internal/infrastructure/idle/portal_inhibitor.go +253 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/connection.go +23 -6 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/history_repo.go +63 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/history_repo_test.go +325 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/lazy_db.go +92 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/lazy_db_test.go +125 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/lazy_repos.go +697 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/migrations/006_session_states.sql +14 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/queries/history.sql +18 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/queries/session_states.sql +36 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/queries/sessions.sql +20 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/session_repo.go +33 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/session_state_repo.go +118 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +146 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/sqlc/models.go +9 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +15 -0 collapsed to keep the page light
A internal/infrastructure/persistence/sqlite/sqlc/session_states.sql.go +175 -0 collapsed to keep the page light
M internal/infrastructure/persistence/sqlite/sqlc/sessions.sql.go +44 -0 collapsed to keep the page light
A internal/infrastructure/snapshot/service.go +128 -0 collapsed to keep the page light
A internal/infrastructure/updater/applier.go +203 -0 collapsed to keep the page light
A internal/infrastructure/updater/github.go +519 -0 collapsed to keep the page light
A internal/infrastructure/updater/github_test.go +361 -0 collapsed to keep the page light
A internal/infrastructure/webkit/bgcolor.go +38 -0 collapsed to keep the page light
M internal/infrastructure/webkit/context.go +44 -10 collapsed to keep the page light
M internal/infrastructure/webkit/factory.go +26 -5 collapsed to keep the page light
M internal/infrastructure/webkit/injector.go +107 -85 collapsed to keep the page light
A internal/infrastructure/webkit/memory_pressure.go +117 -0 collapsed to keep the page light
M internal/infrastructure/webkit/messaging.go +53 -3 collapsed to keep the page light
M internal/infrastructure/webkit/pool.go +187 -5 collapsed to keep the page light
M internal/infrastructure/webkit/scheme_handler.go +127 -19 collapsed to keep the page light
M internal/infrastructure/webkit/webview.go +233 -17 collapsed to keep the page light
M internal/infrastructure/xdg/adapter.go +17 -0 collapsed to keep the page light
M internal/logging/logger.go +12 -3 collapsed to keep the page light
M internal/ui/adapter/favicon.go +31 -0 collapsed to keep the page light
M internal/ui/app.go +906 -39 collapsed to keep the page light
A internal/ui/component/link_status.go +154 -0 collapsed to keep the page light
A internal/ui/component/loading_skeleton.go +97 -0 collapsed to keep the page light
A internal/ui/component/loading_skeleton_test.go +121 -0 collapsed to keep the page light
A internal/ui/component/logo.go +37 -0 collapsed to keep the page light
A internal/ui/component/modal_sizing.go +138 -0 collapsed to keep the page light
A internal/ui/component/modal_sizing_test.go +145 -0 collapsed to keep the page light
M internal/ui/component/omnibox.go +424 -131 collapsed to keep the page light
M internal/ui/component/pane_view.go +122 -7 collapsed to keep the page light
A internal/ui/component/pane_view_internal_test.go +71 -0 collapsed to keep the page light
M internal/ui/component/pane_view_test.go +110 -25 collapsed to keep the page light
M internal/ui/component/progress_bar.go +51 -3 collapsed to keep the page light
A internal/ui/component/session_manager.go +1028 -0 collapsed to keep the page light
M internal/ui/component/stacked_pane_manager.go +1 -1
| @@ -76,7 +76,7 @@ func (spm *StackedPaneManager) AddPaneToStack( | |||
| 76 | 76 | Int("stack_size_before", stackedView.Count()). | |
| 77 | 77 | Msg("StackedPaneManager: inserting pane after active position") | |
| 78 | 78 | ||
| 79 | - | stackedView.InsertPaneAfter(ctx, currentActiveIndex, title, "", widget) | |
| 79 | + | stackedView.InsertPaneAfter(ctx, currentActiveIndex, string(newPaneView.PaneID()), title, "", widget) | |
| 80 | 80 | ||
| 81 | 81 | // Register the new pane in the TreeRenderer's tracking | |
| 82 | 82 | tr.RegisterPaneInStack(string(newPaneView.PaneID()), stackedView) | |
A internal/ui/component/tab_picker.go +482 -0 collapsed to keep the page light
M internal/ui/component/toaster.go +239 -8 collapsed to keep the page light
M internal/ui/component/workspace_view.go +40 -7 collapsed to keep the page light
M internal/ui/component/workspace_view_test.go +62 -0 collapsed to keep the page light
A internal/ui/config_migration.go +38 -0 collapsed to keep the page light
M internal/ui/coordinator/content.go +541 -60 collapsed to keep the page light
A internal/ui/coordinator/content_reveal_test.go +179 -0 collapsed to keep the page light
M internal/ui/coordinator/navigation.go +2 -2 collapsed to keep the page light
M internal/ui/coordinator/tab.go +21 -0 collapsed to keep the page light
A internal/ui/coordinator/update.go +190 -0 collapsed to keep the page light
M internal/ui/coordinator/workspace.go +191 -10 collapsed to keep the page light
M internal/ui/deps.go +22 -5 collapsed to keep the page light
M internal/ui/dispatcher/keyboard.go +89 -12 collapsed to keep the page light
M internal/ui/focus/borders.go +11 -2 collapsed to keep the page light
A internal/ui/input/global_shortcuts.go +185 -0 collapsed to keep the page light
M internal/ui/input/keyboard.go +129 -28 collapsed to keep the page light
A internal/ui/input/keycodes.go +75 -0 collapsed to keep the page light
A internal/ui/input/keycodes_test.go +306 -0 collapsed to keep the page light
M internal/ui/input/modal.go +94 -0 collapsed to keep the page light
M internal/ui/input/shortcuts.go +210 -81 collapsed to keep the page light
M internal/ui/input/shortcuts_test.go +28 -1 collapsed to keep the page light
M internal/ui/layout/gtk_widgets.go +74 -0 collapsed to keep the page light
M internal/ui/layout/mocks/mocks.go +1503 -127 collapsed to keep the page light
M internal/ui/layout/split.go +85 -0 collapsed to keep the page light
M internal/ui/layout/split_test.go +2 -0 collapsed to keep the page light
M internal/ui/layout/stacked.go +19 -2 collapsed to keep the page light
M internal/ui/layout/stacked_test.go +31 -31 collapsed to keep the page light
M internal/ui/layout/tree.go +70 -20 collapsed to keep the page light
M internal/ui/layout/tree_test.go +8 -1 collapsed to keep the page light
M internal/ui/layout/widgets.go +12 -0 collapsed to keep the page light
M internal/ui/theme/css.go +335 -13 collapsed to keep the page light
M internal/ui/theme/manager.go +64 -21 collapsed to keep the page light
A internal/ui/theme/manager_test.go +342 -0 collapsed to keep the page light
M internal/ui/theme/palette.go +92 -0 collapsed to keep the page light
D internal/ui/theme/system.go +0 -78 collapsed to keep the page light
A internal/ui/theme/tab_picker_css.go +81 -0 collapsed to keep the page light
M internal/ui/window/main_window.go +15 -0 collapsed to keep the page light
M webui/package-lock.json +96 -0 generated file: collapsed
M webui/package.json +2 -0 collapsed to keep the page light
A webui/public/favicon.png +0 -0 binary file: no text diff
binary file: no text diff
A webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +17 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +17 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte +24 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte +19 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte +19 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte +19 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte +16 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/alert-dialog.svelte +10 -0 collapsed to keep the page light
A webui/src/lib/components/ui/alert-dialog/index.ts +37 -0 collapsed to keep the page light
A webui/src/lib/components/ui/color-picker/color-picker.svelte +89 -0 collapsed to keep the page light
A webui/src/lib/components/ui/color-picker/index.ts +8 -0 collapsed to keep the page light
M webui/src/pages/ConfigPage.svelte +277 -22 collapsed to keep the page light
M webui/src/pages/ErrorPage.svelte +2 -0 collapsed to keep the page light
M webui/src/pages/Homepage.svelte +2 -0 collapsed to keep the page light
M webui/src/pages/homepage/favorites/FavoriteCard.svelte +4 -0
| @@ -21,6 +21,10 @@ | |||
| 21 | 21 | const getFaviconUrl = (fav: Favorite): string => { | |
| 22 | 22 | // Use provided favicon_url if available | |
| 23 | 23 | if (fav.favicon_url) return fav.favicon_url; | |
| 24 | + | // Handle internal dumb:// URLs - use embedded favicon | |
| 25 | + | if (fav.url.startsWith('dumb://')) { | |
| 26 | + | return 'favicon.png'; | |
| 27 | + | } | |
| 24 | 28 | // Construct DuckDuckGo favicon URL from domain | |
| 25 | 29 | const domain = getDomain(fav.url); | |
| 26 | 30 | return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`; | |