Merge pull request #33 from bnema/dev
feat: session cleanup, dynamic window title, simplified purge UI
23 files · +830 -276
M .github/workflows/ci.yml +1 -1
| @@ -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' | |
| @@ -2,7 +2,7 @@ name: CI | |||||
| 2 | 2 | ||||
| 3 | on: | 3 | on: | ||
| 4 | push: | 4 | push: | ||
| 5 | - | branches: [dev, main] | 5 | + | branches: [main] |
| 6 | paths: | 6 | paths: | ||
| 7 | - '**.go' | 7 | - '**.go' | ||
| 8 | - 'go.mod' | 8 | - 'go.mod' | ||
M CHANGELOG.md +4 -2
| @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### Added | |
| 8 | + | - **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. | |
| 8 | 9 | - **Auto-update system**: Automatic update checking and self-updating binary replacement following clean architecture. | |
| 9 | 10 | - **Update check on startup**: Async check against GitHub releases API (configurable via `update.enable_on_startup`, default: true). | |
| 10 | 11 | - **Toast notifications**: Non-intrusive auto-dismiss notifications for update availability. | |
| @@ -17,8 +18,9 @@ All notable changes to this project will be documented in this file. | |||
| 17 | 18 | - **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. | |
| 18 | 19 | - **Session mode**: New modal mode (`Ctrl+O`) with purple border indicator, similar to pane/tab modes. | |
| 19 | 20 | - **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. | |
| 20 | - | - **Session purging**: Integrated into `dumber purge` command for cleaning up old session data. | |
| 21 | - | - **Configuration options**: `session.auto_restore`, `session.snapshot_interval_ms`, `session.max_exited_sessions`, `session.max_listed_sessions`, plus session mode shortcuts and styling. | |
| 21 | + | - **Session purging**: Integrated into `dumber purge` command for cleaning up old session data. Simplified UI with single "Sessions" toggle instead of individual selection. | |
| 22 | + | - **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). | |
| 23 | + | - **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. | |
| 22 | 24 | ||
| 23 | 25 | ### Fixed | |
| 24 | 26 | - **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. | |
| @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. | |||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### Added | ||
| 8 | + | - **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. | |||
| 8 | - **Auto-update system**: Automatic update checking and self-updating binary replacement following clean architecture. | 9 | - **Auto-update system**: Automatic update checking and self-updating binary replacement following clean architecture. | ||
| 9 | - **Update check on startup**: Async check against GitHub releases API (configurable via `update.enable_on_startup`, default: true). | 10 | - **Update check on startup**: Async check against GitHub releases API (configurable via `update.enable_on_startup`, default: true). | ||
| 10 | - **Toast notifications**: Non-intrusive auto-dismiss notifications for update availability. | 11 | - **Toast notifications**: Non-intrusive auto-dismiss notifications for update availability. | ||
| @@ -17,8 +18,9 @@ All notable changes to this project will be documented in this file. | |||||
| 17 | - **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. | 18 | - **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. | ||
| 18 | - **Session mode**: New modal mode (`Ctrl+O`) with purple border indicator, similar to pane/tab modes. | 19 | - **Session mode**: New modal mode (`Ctrl+O`) with purple border indicator, similar to pane/tab modes. | ||
| 19 | - **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. | 20 | - **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. | ||
| 20 | - | - **Session purging**: Integrated into `dumber purge` command for cleaning up old session data. | 21 | + | - **Session purging**: Integrated into `dumber purge` command for cleaning up old session data. Simplified UI with single "Sessions" toggle instead of individual selection. |
| 21 | - | - **Configuration options**: `session.auto_restore`, `session.snapshot_interval_ms`, `session.max_exited_sessions`, `session.max_listed_sessions`, plus session mode shortcuts and styling. | 22 | + | - **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). |
| 23 | + | - **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. | |||
| 22 | 24 | ||||
| 23 | ### Fixed | 25 | ### Fixed | ||
| 24 | - **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. | 26 | - **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. | ||
M docs/CONFIG.md +6 -6
| @@ -285,8 +285,8 @@ cancel = ["escape"] | |||
| 285 | 285 | |-----|------|---------|-------------| | |
| 286 | 286 | | `session.auto_restore` | bool | `false` | Automatically restore the last session on startup | | |
| 287 | 287 | | `session.snapshot_interval_ms` | int | `5000` | Minimum interval between snapshots in milliseconds | | |
| 288 | - | | `session.max_exited_sessions` | int | `10` | Maximum number of exited sessions to keep | | |
| 289 | - | | `session.max_listed_sessions` | int | `50` | Maximum number of sessions to list in CLI/TUI | | |
| 288 | + | | `session.max_exited_sessions` | int | `50` | Maximum number of exited sessions to keep | | |
| 289 | + | | `session.max_exited_session_age_days` | int | `7` | Maximum age in days for exited sessions (auto-deleted on startup) | | |
| 290 | 290 | ||
| 291 | 291 | ### Session Mode | |
| 292 | 292 | ||
| @@ -307,10 +307,10 @@ cancel = ["escape"] | |||
| 307 | 307 | **Example:** | |
| 308 | 308 | ```toml | |
| 309 | 309 | [session] | |
| 310 | - | auto_restore = false # Don't auto-restore on startup | |
| 311 | - | snapshot_interval_ms = 5000 # Save state every 5 seconds (debounced) | |
| 312 | - | max_exited_sessions = 10 # Keep last 10 exited sessions | |
| 313 | - | max_listed_sessions = 50 # Show up to 50 sessions in CLI/TUI | |
| 310 | + | auto_restore = false # Don't auto-restore on startup | |
| 311 | + | snapshot_interval_ms = 5000 # Save state every 5 seconds (debounced) | |
| 312 | + | max_exited_sessions = 50 # Keep last 50 exited sessions | |
| 313 | + | max_exited_session_age_days = 7 # Delete sessions older than 7 days on startup | |
| 314 | 314 | ``` | |
| 315 | 315 | ||
| 316 | 316 | ## Media | |
| @@ -285,8 +285,8 @@ cancel = ["escape"] | |||||
| 285 | |-----|------|---------|-------------| | 285 | |-----|------|---------|-------------| | ||
| 286 | | `session.auto_restore` | bool | `false` | Automatically restore the last session on startup | | 286 | | `session.auto_restore` | bool | `false` | Automatically restore the last session on startup | | ||
| 287 | | `session.snapshot_interval_ms` | int | `5000` | Minimum interval between snapshots in milliseconds | | 287 | | `session.snapshot_interval_ms` | int | `5000` | Minimum interval between snapshots in milliseconds | | ||
| 288 | - | | `session.max_exited_sessions` | int | `10` | Maximum number of exited sessions to keep | | 288 | + | | `session.max_exited_sessions` | int | `50` | Maximum number of exited sessions to keep | |
| 289 | - | | `session.max_listed_sessions` | int | `50` | Maximum number of sessions to list in CLI/TUI | | 289 | + | | `session.max_exited_session_age_days` | int | `7` | Maximum age in days for exited sessions (auto-deleted on startup) | |
| 290 | 290 | ||||
| 291 | ### Session Mode | 291 | ### Session Mode | ||
| 292 | 292 | ||||
| @@ -307,10 +307,10 @@ cancel = ["escape"] | |||||
| 307 | **Example:** | 307 | **Example:** | ||
| 308 | ```toml | 308 | ```toml | ||
| 309 | [session] | 309 | [session] | ||
| 310 | - | auto_restore = false # Don't auto-restore on startup | 310 | + | auto_restore = false # Don't auto-restore on startup |
| 311 | - | snapshot_interval_ms = 5000 # Save state every 5 seconds (debounced) | 311 | + | snapshot_interval_ms = 5000 # Save state every 5 seconds (debounced) |
| 312 | - | max_exited_sessions = 10 # Keep last 10 exited sessions | 312 | + | max_exited_sessions = 50 # Keep last 50 exited sessions |
| 313 | - | max_listed_sessions = 50 # Show up to 50 sessions in CLI/TUI | 313 | + | max_exited_session_age_days = 7 # Delete sessions older than 7 days on startup |
| 314 | ``` | 314 | ``` | ||
| 315 | 315 | ||||
| 316 | ## Media | 316 | ## Media | ||
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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |||
M internal/bootstrap/session.go +14 -0
| @@ -56,6 +56,20 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (* | |||
| 56 | 56 | } | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | + | // Clean up old exited sessions based on config limits | |
| 60 | + | cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo) | |
| 61 | + | cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{ | |
| 62 | + | MaxExitedSessions: cfg.Session.MaxExitedSessions, | |
| 63 | + | MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays, | |
| 64 | + | }) | |
| 65 | + | if err != nil { | |
| 66 | + | log.Warn().Err(err).Msg("failed to cleanup old sessions") | |
| 67 | + | } else if cleanupOutput.TotalDeleted > 0 { | |
| 68 | + | log.Info(). | |
| 69 | + | Int64("deleted", cleanupOutput.TotalDeleted). | |
| 70 | + | Msg("cleaned up old sessions on startup") | |
| 71 | + | } | |
| 72 | + | ||
| 59 | 73 | now := time.Now() | |
| 60 | 74 | out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{ | |
| 61 | 75 | Type: entity.SessionTypeBrowser, | |
| @@ -56,6 +56,20 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (* | |||||
| 56 | } | 56 | } | ||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | + | // Clean up old exited sessions based on config limits | |||
| 60 | + | cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo) | |||
| 61 | + | cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{ | |||
| 62 | + | MaxExitedSessions: cfg.Session.MaxExitedSessions, | |||
| 63 | + | MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays, | |||
| 64 | + | }) | |||
| 65 | + | if err != nil { | |||
| 66 | + | log.Warn().Err(err).Msg("failed to cleanup old sessions") | |||
| 67 | + | } else if cleanupOutput.TotalDeleted > 0 { | |||
| 68 | + | log.Info(). | |||
| 69 | + | Int64("deleted", cleanupOutput.TotalDeleted). | |||
| 70 | + | Msg("cleaned up old sessions on startup") | |||
| 71 | + | } | |||
| 72 | + | ||||
| 59 | now := time.Now() | 73 | now := time.Now() | ||
| 60 | out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{ | 74 | out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{ | ||
| 61 | Type: entity.SessionTypeBrowser, | 75 | Type: entity.SessionTypeBrowser, | ||
M internal/cli/cmd/sessions.go +1 -1
| @@ -61,7 +61,7 @@ func runSessions(_ *cobra.Command, _ []string) error { | |||
| 61 | 61 | RestoreUC: app.RestoreUC, | |
| 62 | 62 | DeleteSessionUC: app.DeleteSessionUC, | |
| 63 | 63 | CurrentSession: currentSessionID, | |
| 64 | - | MaxListedSessions: app.Config.Session.MaxListedSessions, | |
| 64 | + | MaxExitedSessions: app.Config.Session.MaxExitedSessions, | |
| 65 | 65 | }) | |
| 66 | 66 | ||
| 67 | 67 | p := tea.NewProgram(m, tea.WithAltScreen()) | |
| @@ -61,7 +61,7 @@ func runSessions(_ *cobra.Command, _ []string) error { | |||||
| 61 | RestoreUC: app.RestoreUC, | 61 | RestoreUC: app.RestoreUC, | ||
| 62 | DeleteSessionUC: app.DeleteSessionUC, | 62 | DeleteSessionUC: app.DeleteSessionUC, | ||
| 63 | CurrentSession: currentSessionID, | 63 | CurrentSession: currentSessionID, | ||
| 64 | - | MaxListedSessions: app.Config.Session.MaxListedSessions, | 64 | + | MaxExitedSessions: app.Config.Session.MaxExitedSessions, |
| 65 | }) | 65 | }) | ||
| 66 | 66 | ||||
| 67 | p := tea.NewProgram(m, tea.WithAltScreen()) | 67 | p := tea.NewProgram(m, tea.WithAltScreen()) | ||
M internal/cli/model/sessions.go +2 -2
| @@ -115,12 +115,12 @@ type SessionsModelConfig struct { | |||
| 115 | 115 | RestoreUC *usecase.RestoreSessionUseCase | |
| 116 | 116 | DeleteSessionUC *usecase.DeleteSessionUseCase | |
| 117 | 117 | CurrentSession entity.SessionID | |
| 118 | - | MaxListedSessions int | |
| 118 | + | MaxExitedSessions int | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | 121 | // NewSessionsModel creates a new sessions browser model. | |
| 122 | 122 | func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel { | |
| 123 | - | maxListed := cfg.MaxListedSessions | |
| 123 | + | maxListed := cfg.MaxExitedSessions | |
| 124 | 124 | if maxListed <= 0 { | |
| 125 | 125 | maxListed = 50 // fallback default | |
| 126 | 126 | } | |
| @@ -115,12 +115,12 @@ type SessionsModelConfig struct { | |||||
| 115 | RestoreUC *usecase.RestoreSessionUseCase | 115 | RestoreUC *usecase.RestoreSessionUseCase | ||
| 116 | DeleteSessionUC *usecase.DeleteSessionUseCase | 116 | DeleteSessionUC *usecase.DeleteSessionUseCase | ||
| 117 | CurrentSession entity.SessionID | 117 | CurrentSession entity.SessionID | ||
| 118 | - | MaxListedSessions int | 118 | + | MaxExitedSessions int |
| 119 | } | 119 | } | ||
| 120 | 120 | ||||
| 121 | // NewSessionsModel creates a new sessions browser model. | 121 | // NewSessionsModel creates a new sessions browser model. | ||
| 122 | func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel { | 122 | func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel { | ||
| 123 | - | maxListed := cfg.MaxListedSessions | 123 | + | maxListed := cfg.MaxExitedSessions |
| 124 | if maxListed <= 0 { | 124 | if maxListed <= 0 { | ||
| 125 | maxListed = 50 // fallback default | 125 | maxListed = 50 // fallback default | ||
| 126 | } | 126 | } | ||
M internal/cli/styles/purge.go +136 -232
| @@ -3,7 +3,6 @@ package styles | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "strings" | |
| 6 | - | "time" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/charmbracelet/bubbles/key" | |
| 9 | 8 | tea "github.com/charmbracelet/bubbletea" | |
| @@ -18,44 +17,71 @@ type PurgeItem struct { | |||
| 18 | 17 | Selected bool | |
| 19 | 18 | } | |
| 20 | 19 | ||
| 21 | - | // PurgeModel is the multi-select purge modal with sessions support. | |
| 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. | |
| 22 | 22 | type PurgeModel struct { | |
| 23 | 23 | Items []PurgeItem | |
| 24 | 24 | Sessions []entity.SessionPurgeItem | |
| 25 | 25 | SessionsSize int64 // Total size of all session snapshots | |
| 26 | - | Cursor int | |
| 26 | + | Cursor int // Flat cursor index across all rows | |
| 27 | 27 | Confirmed bool | |
| 28 | 28 | Canceled bool | |
| 29 | 29 | theme *Theme | |
| 30 | 30 | ||
| 31 | - | // Track cursor position: false = items section, true = sessions section | |
| 32 | - | inSessionsSection bool | |
| 31 | + | // allSessionsSelected indicates if all sessions should be purged | |
| 32 | + | allSessionsSelected bool | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | // PurgeKeyMap defines keybindings for purge modal. | |
| 36 | 36 | type PurgeKeyMap struct { | |
| 37 | - | Up key.Binding | |
| 38 | - | Down key.Binding | |
| 39 | - | Toggle key.Binding | |
| 40 | - | ToggleAll key.Binding | |
| 41 | - | ToggleSessions key.Binding | |
| 42 | - | Confirm key.Binding | |
| 43 | - | Cancel key.Binding | |
| 37 | + | Up key.Binding | |
| 38 | + | Down key.Binding | |
| 39 | + | Toggle key.Binding | |
| 40 | + | ToggleAll key.Binding | |
| 41 | + | Confirm key.Binding | |
| 42 | + | Cancel key.Binding | |
| 44 | 43 | } | |
| 45 | 44 | ||
| 46 | 45 | // DefaultPurgeKeyMap returns default keybindings. | |
| 47 | 46 | func DefaultPurgeKeyMap() PurgeKeyMap { | |
| 48 | 47 | return PurgeKeyMap{ | |
| 49 | - | Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), | |
| 50 | - | Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), | |
| 51 | - | Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), | |
| 52 | - | ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")), | |
| 53 | - | ToggleSessions: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "toggle sessions")), | |
| 54 | - | Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), | |
| 55 | - | Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")), | |
| 48 | + | Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), | |
| 49 | + | Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), | |
| 50 | + | Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), | |
| 51 | + | ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")), | |
| 52 | + | Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), | |
| 53 | + | Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")), | |
| 56 | 54 | } | |
| 57 | 55 | } | |
| 58 | 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 | + | ||
| 59 | 85 | // NewPurge creates a new purge modal with the given targets. | |
| 60 | 86 | func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel { | |
| 61 | 87 | items := make([]PurgeItem, 0, len(targets)) | |
| @@ -97,8 +123,6 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) { | |||
| 97 | 123 | m.toggleCurrent() | |
| 98 | 124 | case key.Matches(msg, keys.ToggleAll): | |
| 99 | 125 | m.toggleAll() | |
| 100 | - | case key.Matches(msg, keys.ToggleSessions): | |
| 101 | - | m.ToggleAllSessions() | |
| 102 | 126 | case key.Matches(msg, keys.Confirm): | |
| 103 | 127 | m.Confirmed = true | |
| 104 | 128 | case key.Matches(msg, keys.Cancel): | |
| @@ -119,73 +143,53 @@ func (m PurgeModel) isDataSelected() bool { | |||
| 119 | 143 | return false | |
| 120 | 144 | } | |
| 121 | 145 | ||
| 122 | - | // SessionsEnabled returns true if sessions can be individually selected. | |
| 146 | + | // SessionsEnabled returns true if sessions can be selected. | |
| 123 | 147 | // Sessions are disabled when Data is selected (database will be removed). | |
| 124 | 148 | func (m PurgeModel) SessionsEnabled() bool { | |
| 125 | 149 | return !m.isDataSelected() | |
| 126 | 150 | } | |
| 127 | 151 | ||
| 128 | - | // totalSelectableCount returns the total number of selectable items across both sections. | |
| 129 | - | func (m PurgeModel) totalSelectableCount() int { | |
| 130 | - | count := 0 | |
| 131 | - | for _, it := range m.Items { | |
| 132 | - | if it.Exists { | |
| 133 | - | count++ | |
| 134 | - | } | |
| 135 | - | } | |
| 136 | - | if m.SessionsEnabled() { | |
| 137 | - | count += len(m.Sessions) | |
| 138 | - | } | |
| 139 | - | return count | |
| 152 | + | // hasSessionsItem returns true if there are sessions available to purge. | |
| 153 | + | func (m PurgeModel) hasSessionsItem() bool { | |
| 154 | + | return len(m.Sessions) > 0 | |
| 140 | 155 | } | |
| 141 | 156 | ||
| 142 | 157 | func (m *PurgeModel) moveCursor(delta int) { | |
| 143 | - | total := m.totalSelectableCount() | |
| 144 | - | if total == 0 { | |
| 158 | + | rows := m.buildRows() | |
| 159 | + | if len(rows) == 0 { | |
| 145 | 160 | return | |
| 146 | 161 | } | |
| 147 | 162 | ||
| 148 | - | // Build a flat list of selectable indices | |
| 149 | - | // Items first, then sessions (if enabled) | |
| 150 | - | type position struct { | |
| 151 | - | inSessions bool | |
| 152 | - | index int | |
| 153 | - | } | |
| 154 | - | var positions []position | |
| 155 | - | ||
| 156 | - | for i, it := range m.Items { | |
| 157 | - | if it.Exists { | |
| 158 | - | positions = append(positions, position{inSessions: false, index: i}) | |
| 159 | - | } | |
| 160 | - | } | |
| 161 | - | if m.SessionsEnabled() { | |
| 162 | - | for i := range m.Sessions { | |
| 163 | - | positions = append(positions, position{inSessions: true, index: i}) | |
| 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) | |
| 164 | 168 | } | |
| 165 | 169 | } | |
| 166 | 170 | ||
| 167 | - | if len(positions) == 0 { | |
| 171 | + | if len(selectableIndices) == 0 { | |
| 168 | 172 | return | |
| 169 | 173 | } | |
| 170 | 174 | ||
| 171 | - | // Find current position in the flat list | |
| 172 | - | currentFlat := 0 | |
| 173 | - | for i, p := range positions { | |
| 174 | - | if p.inSessions == m.inSessionsSection && p.index == m.Cursor { | |
| 175 | - | currentFlat = i | |
| 175 | + | // Find current position in selectable list | |
| 176 | + | currentPos := 0 | |
| 177 | + | for i, idx := range selectableIndices { | |
| 178 | + | if idx == m.Cursor { | |
| 179 | + | currentPos = i | |
| 176 | 180 | break | |
| 177 | 181 | } | |
| 178 | 182 | } | |
| 179 | 183 | ||
| 180 | - | // Move in the flat list | |
| 181 | - | newFlat := (currentFlat + delta + len(positions)) % len(positions) | |
| 182 | - | m.inSessionsSection = positions[newFlat].inSessions | |
| 183 | - | m.Cursor = positions[newFlat].index | |
| 184 | + | // Move | |
| 185 | + | newPos := (currentPos + delta + len(selectableIndices)) % len(selectableIndices) | |
| 186 | + | m.Cursor = selectableIndices[newPos] | |
| 184 | 187 | } | |
| 185 | 188 | ||
| 186 | 189 | func (m PurgeModel) firstSelectableIndex() int { | |
| 187 | - | for i, it := range m.Items { | |
| 188 | - | if it.Exists { | |
| 190 | + | rows := m.buildRows() | |
| 191 | + | for i, r := range rows { | |
| 192 | + | if r.Exists { | |
| 189 | 193 | return i | |
| 190 | 194 | } | |
| 191 | 195 | } | |
| @@ -193,27 +197,26 @@ func (m PurgeModel) firstSelectableIndex() int { | |||
| 193 | 197 | } | |
| 194 | 198 | ||
| 195 | 199 | func (m *PurgeModel) toggleCurrent() { | |
| 196 | - | if m.inSessionsSection { | |
| 197 | - | if !m.SessionsEnabled() { | |
| 198 | - | return | |
| 199 | - | } | |
| 200 | - | if m.Cursor >= 0 && m.Cursor < len(m.Sessions) { | |
| 201 | - | m.Sessions[m.Cursor].Selected = !m.Sessions[m.Cursor].Selected | |
| 202 | - | } | |
| 200 | + | rows := m.buildRows() | |
| 201 | + | if m.Cursor < 0 || m.Cursor >= len(rows) { | |
| 203 | 202 | return | |
| 204 | 203 | } | |
| 205 | 204 | ||
| 206 | - | if m.Cursor < 0 || m.Cursor >= len(m.Items) { | |
| 205 | + | r := rows[m.Cursor] | |
| 206 | + | if !r.Exists { | |
| 207 | 207 | return | |
| 208 | 208 | } | |
| 209 | - | if !m.Items[m.Cursor].Exists { | |
| 210 | - | return | |
| 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 | |
| 211 | 215 | } | |
| 212 | - | m.Items[m.Cursor].Selected = !m.Items[m.Cursor].Selected | |
| 213 | 216 | } | |
| 214 | 217 | ||
| 215 | 218 | func (m *PurgeModel) toggleAll() { | |
| 216 | - | // Toggle all items | |
| 219 | + | // Check if any selectable item is unselected | |
| 217 | 220 | anyUnselected := false | |
| 218 | 221 | for _, it := range m.Items { | |
| 219 | 222 | if it.Exists && !it.Selected { | |
| @@ -221,46 +224,27 @@ func (m *PurgeModel) toggleAll() { | |||
| 221 | 224 | break | |
| 222 | 225 | } | |
| 223 | 226 | } | |
| 224 | - | // Also check sessions if enabled | |
| 225 | - | if m.SessionsEnabled() { | |
| 226 | - | for _, s := range m.Sessions { | |
| 227 | - | if !s.Selected { | |
| 228 | - | anyUnselected = true | |
| 229 | - | break | |
| 230 | - | } | |
| 231 | - | } | |
| 227 | + | if m.hasSessionsItem() && m.SessionsEnabled() && !m.allSessionsSelected { | |
| 228 | + | anyUnselected = true | |
| 232 | 229 | } | |
| 233 | 230 | ||
| 231 | + | // Set all to the opposite state | |
| 234 | 232 | for i := range m.Items { | |
| 235 | - | if !m.Items[i].Exists { | |
| 236 | - | continue | |
| 233 | + | if m.Items[i].Exists { | |
| 234 | + | m.Items[i].Selected = anyUnselected | |
| 237 | 235 | } | |
| 238 | - | m.Items[i].Selected = anyUnselected | |
| 239 | 236 | } | |
| 240 | - | if m.SessionsEnabled() { | |
| 241 | - | for i := range m.Sessions { | |
| 242 | - | m.Sessions[i].Selected = anyUnselected | |
| 243 | - | } | |
| 237 | + | if m.hasSessionsItem() && m.SessionsEnabled() { | |
| 238 | + | m.allSessionsSelected = anyUnselected | |
| 244 | 239 | } | |
| 245 | 240 | } | |
| 246 | 241 | ||
| 247 | 242 | // ToggleAllSessions toggles the selection state of all sessions. | |
| 248 | 243 | func (m *PurgeModel) ToggleAllSessions() { | |
| 249 | - | if !m.SessionsEnabled() || len(m.Sessions) == 0 { | |
| 244 | + | if !m.hasSessionsItem() || !m.SessionsEnabled() { | |
| 250 | 245 | return | |
| 251 | 246 | } | |
| 252 | - | ||
| 253 | - | anyUnselected := false | |
| 254 | - | for _, s := range m.Sessions { | |
| 255 | - | if !s.Selected { | |
| 256 | - | anyUnselected = true | |
| 257 | - | break | |
| 258 | - | } | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | for i := range m.Sessions { | |
| 262 | - | m.Sessions[i].Selected = anyUnselected | |
| 263 | - | } | |
| 247 | + | m.allSessionsSelected = !m.allSessionsSelected | |
| 264 | 248 | } | |
| 265 | 249 | ||
| 266 | 250 | // View implements tea.Model. | |
| @@ -270,34 +254,21 @@ func (m PurgeModel) View() string { | |||
| 270 | 254 | header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash)) | |
| 271 | 255 | subtitle := t.Subtle.Render("Select items to remove") | |
| 272 | 256 | ||
| 273 | - | // Application Data section | |
| 274 | - | dataSectionHeader := t.Subtle.Render("── Application Data ──") | |
| 257 | + | // Build rows and render | |
| 258 | + | rows := m.buildRows() | |
| 275 | 259 | var itemRows []string | |
| 276 | - | for i, it := range m.Items { | |
| 277 | - | itemRows = append(itemRows, m.renderItemRow(i, it)) | |
| 278 | - | } | |
| 279 | - | itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...) | |
| 280 | - | ||
| 281 | - | // Sessions section | |
| 282 | - | var sessionsList string | |
| 283 | - | if len(m.Sessions) > 0 { | |
| 284 | - | sessionsEnabled := m.SessionsEnabled() | |
| 285 | - | var sessionHeader string | |
| 286 | - | if sessionsEnabled { | |
| 287 | - | sessionHeader = t.Subtle.Render(fmt.Sprintf("── Inactive Sessions (%d total, %s) ──", | |
| 288 | - | len(m.Sessions), formatSize(m.SessionsSize))) | |
| 289 | - | } else { | |
| 290 | - | sessionHeader = t.Subtle.Render("── Inactive Sessions (database will be removed) ──") | |
| 291 | - | } | |
| 292 | - | ||
| 293 | - | var sessionRows []string | |
| 294 | - | sessionRows = append(sessionRows, sessionHeader) | |
| 295 | - | for i, s := range m.Sessions { | |
| 296 | - | sessionRows = append(sessionRows, m.renderSessionRow(i, s, sessionsEnabled)) | |
| 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)) | |
| 297 | 267 | } | |
| 298 | - | sessionsList = lipgloss.JoinVertical(lipgloss.Left, sessionRows...) | |
| 299 | 268 | } | |
| 300 | 269 | ||
| 270 | + | itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...) | |
| 271 | + | ||
| 301 | 272 | selectedCount := m.SelectedCount() | |
| 302 | 273 | size := m.SelectedSize() | |
| 303 | 274 | var summary string | |
| @@ -314,35 +285,30 @@ func (m PurgeModel) View() string { | |||
| 314 | 285 | summary = t.Subtle.Render("0 selected") | |
| 315 | 286 | } | |
| 316 | 287 | ||
| 317 | - | helpText := "↑/↓ j/k move • space toggle • a all" | |
| 318 | - | if len(m.Sessions) > 0 && m.SessionsEnabled() { | |
| 319 | - | helpText += " • s sessions" | |
| 320 | - | } | |
| 321 | - | helpText += " • enter • esc" | |
| 288 | + | helpText := "↑/↓ j/k move • space toggle • a all • enter • esc" | |
| 322 | 289 | help := t.Subtle.Render(helpText) | |
| 323 | 290 | ||
| 324 | 291 | contentParts := []string{ | |
| 325 | 292 | header, | |
| 326 | 293 | subtitle, | |
| 327 | 294 | "", | |
| 328 | - | dataSectionHeader, | |
| 329 | 295 | itemsList, | |
| 296 | + | "", | |
| 297 | + | summary, | |
| 298 | + | "", | |
| 299 | + | help, | |
| 330 | 300 | } | |
| 331 | - | if sessionsList != "" { | |
| 332 | - | contentParts = append(contentParts, "", sessionsList) | |
| 333 | - | } | |
| 334 | - | contentParts = append(contentParts, "", summary, "", help) | |
| 335 | 301 | ||
| 336 | 302 | content := lipgloss.JoinVertical(lipgloss.Left, contentParts...) | |
| 337 | 303 | ||
| 338 | 304 | return t.Box.Render(content) | |
| 339 | 305 | } | |
| 340 | 306 | ||
| 341 | - | func (m PurgeModel) renderItemRow(i int, it PurgeItem) string { | |
| 307 | + | func (m PurgeModel) renderItemRow(_ int, it PurgeItem, isCursor bool) string { | |
| 342 | 308 | t := m.theme | |
| 343 | 309 | ||
| 344 | 310 | cursor := " " | |
| 345 | - | if !m.inSessionsSection && i == m.Cursor { | |
| 311 | + | if isCursor && it.Exists { | |
| 346 | 312 | cursor = IconCursor + " " | |
| 347 | 313 | } | |
| 348 | 314 | ||
| @@ -391,11 +357,13 @@ func (m PurgeModel) renderItemRow(i int, it PurgeItem) string { | |||
| 391 | 357 | return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing) | |
| 392 | 358 | } | |
| 393 | 359 | ||
| 394 | - | func (m PurgeModel) renderSessionRow(i int, s entity.SessionPurgeItem, enabled bool) string { | |
| 360 | + | // renderSessionsItem renders the single "Sessions" item. | |
| 361 | + | func (m PurgeModel) renderSessionsItem(isCursor bool) string { | |
| 395 | 362 | t := m.theme | |
| 363 | + | enabled := m.SessionsEnabled() | |
| 396 | 364 | ||
| 397 | 365 | cursor := " " | |
| 398 | - | if m.inSessionsSection && i == m.Cursor && enabled { | |
| 366 | + | if isCursor && enabled { | |
| 399 | 367 | cursor = IconCursor + " " | |
| 400 | 368 | } | |
| 401 | 369 | ||
| @@ -406,44 +374,31 @@ func (m PurgeModel) renderSessionRow(i int, s entity.SessionPurgeItem, enabled b | |||
| 406 | 374 | detailStyle := t.Subtle | |
| 407 | 375 | ||
| 408 | 376 | if enabled { | |
| 409 | - | if s.Selected { | |
| 377 | + | if m.allSessionsSelected { | |
| 410 | 378 | checkbox = IconCheckboxChecked | |
| 411 | 379 | } | |
| 412 | 380 | checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent) | |
| 413 | 381 | cursorStyle = lipgloss.NewStyle().Foreground(t.Accent) | |
| 414 | 382 | labelStyle = t.Normal | |
| 415 | - | detailStyle = t.Subtle | |
| 416 | 383 | } | |
| 417 | 384 | ||
| 418 | - | // Format: ...abc1 2 tabs 3 panes 2d ago | |
| 419 | - | shortID := s.Info.Session.ShortID() | |
| 420 | - | tabLabel := "tab" | |
| 421 | - | if s.Info.TabCount != 1 { | |
| 422 | - | tabLabel = "tabs" | |
| 423 | - | } | |
| 424 | - | paneLabel := "pane" | |
| 425 | - | if s.Info.PaneCount != 1 { | |
| 426 | - | paneLabel = "panes" | |
| 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)) | |
| 427 | 392 | } | |
| 428 | 393 | ||
| 429 | - | const idPadWidth = 8 | |
| 430 | - | const tabPadWidth = 8 | |
| 431 | - | const panePadWidth = 9 | |
| 432 | - | ||
| 433 | - | idPart := labelStyle.Render(padRight("..."+shortID, idPadWidth)) | |
| 434 | - | tabPart := detailStyle.Render(padRight(fmt.Sprintf("%d %s", s.Info.TabCount, tabLabel), tabPadWidth)) | |
| 435 | - | panePart := detailStyle.Render(padRight(fmt.Sprintf("%d %s", s.Info.PaneCount, paneLabel), panePadWidth)) | |
| 436 | - | timePart := detailStyle.Render(getRelativeTime(s.Info.UpdatedAt)) | |
| 437 | - | ||
| 394 | + | const labelPadWidth = 12 | |
| 438 | 395 | return lipgloss.JoinHorizontal( | |
| 439 | 396 | lipgloss.Left, | |
| 440 | 397 | cursorStyle.Render(cursor), | |
| 441 | 398 | checkboxStyle.Render(checkbox), | |
| 442 | 399 | " ", | |
| 443 | - | idPart, | |
| 444 | - | tabPart, | |
| 445 | - | panePart, | |
| 446 | - | timePart, | |
| 400 | + | labelStyle.Render(padRight(label, labelPadWidth)), | |
| 401 | + | detailStyle.Render(detail), | |
| 447 | 402 | ) | |
| 448 | 403 | } | |
| 449 | 404 | ||
| @@ -493,45 +448,6 @@ func formatSize(bytes int64) string { | |||
| 493 | 448 | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) | |
| 494 | 449 | } | |
| 495 | 450 | ||
| 496 | - | func getRelativeTime(t time.Time) string { | |
| 497 | - | now := time.Now() | |
| 498 | - | diff := now.Sub(t) | |
| 499 | - | ||
| 500 | - | const ( | |
| 501 | - | hoursPerDay = 24 | |
| 502 | - | daysPerWeek = 7 | |
| 503 | - | ) | |
| 504 | - | ||
| 505 | - | switch { | |
| 506 | - | case diff < time.Minute: | |
| 507 | - | return "just now" | |
| 508 | - | case diff < time.Hour: | |
| 509 | - | mins := int(diff.Minutes()) | |
| 510 | - | if mins == 1 { | |
| 511 | - | return "1m ago" | |
| 512 | - | } | |
| 513 | - | return fmt.Sprintf("%dm ago", mins) | |
| 514 | - | case diff < hoursPerDay*time.Hour: | |
| 515 | - | hours := int(diff.Hours()) | |
| 516 | - | if hours == 1 { | |
| 517 | - | return "1h ago" | |
| 518 | - | } | |
| 519 | - | return fmt.Sprintf("%dh ago", hours) | |
| 520 | - | case diff < daysPerWeek*hoursPerDay*time.Hour: | |
| 521 | - | days := int(diff.Hours() / hoursPerDay) | |
| 522 | - | if days == 1 { | |
| 523 | - | return "1d ago" | |
| 524 | - | } | |
| 525 | - | return fmt.Sprintf("%dd ago", days) | |
| 526 | - | default: | |
| 527 | - | weeks := int(diff.Hours() / hoursPerDay / daysPerWeek) | |
| 528 | - | if weeks == 1 { | |
| 529 | - | return "1w ago" | |
| 530 | - | } | |
| 531 | - | return fmt.Sprintf("%dw ago", weeks) | |
| 532 | - | } | |
| 533 | - | } | |
| 534 | - | ||
| 535 | 451 | // Done returns true if the modal is complete. | |
| 536 | 452 | func (m PurgeModel) Done() bool { | |
| 537 | 453 | return m.Confirmed || m.Canceled | |
| @@ -553,7 +469,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType { | |||
| 553 | 469 | return out | |
| 554 | 470 | } | |
| 555 | 471 | ||
| 556 | - | // SelectedCount returns the number of selected items (targets + sessions). | |
| 472 | + | // SelectedCount returns the number of selected items (targets + sessions as 1 if selected). | |
| 557 | 473 | func (m PurgeModel) SelectedCount() int { | |
| 558 | 474 | count := 0 | |
| 559 | 475 | for _, it := range m.Items { | |
| @@ -561,13 +477,9 @@ func (m PurgeModel) SelectedCount() int { | |||
| 561 | 477 | count++ | |
| 562 | 478 | } | |
| 563 | 479 | } | |
| 564 | - | // Only count sessions if they're enabled (Data not selected) | |
| 565 | - | if m.SessionsEnabled() { | |
| 566 | - | for _, s := range m.Sessions { | |
| 567 | - | if s.Selected { | |
| 568 | - | count++ | |
| 569 | - | } | |
| 570 | - | } | |
| 480 | + | // Count sessions as 1 item if selected (and enabled) | |
| 481 | + | if m.hasSessionsItem() && m.SessionsEnabled() && m.allSessionsSelected { | |
| 482 | + | count++ | |
| 571 | 483 | } | |
| 572 | 484 | return count | |
| 573 | 485 | } | |
| @@ -584,35 +496,27 @@ func (m PurgeModel) SelectedSize() int64 { | |||
| 584 | 496 | // If Data is selected, sessions are implicitly included | |
| 585 | 497 | if m.isDataSelected() { | |
| 586 | 498 | total += m.SessionsSize | |
| 587 | - | } else { | |
| 588 | - | // Otherwise, count only selected sessions | |
| 589 | - | // Note: individual session sizes aren't tracked, only total | |
| 590 | - | // When sessions are selected, we include the total size proportionally | |
| 591 | - | selectedCount := 0 | |
| 592 | - | for _, s := range m.Sessions { | |
| 593 | - | if s.Selected { | |
| 594 | - | selectedCount++ | |
| 595 | - | } | |
| 596 | - | } | |
| 597 | - | if len(m.Sessions) > 0 && selectedCount > 0 { | |
| 598 | - | total += m.SessionsSize * int64(selectedCount) / int64(len(m.Sessions)) | |
| 599 | - | } | |
| 499 | + | } else if m.allSessionsSelected { | |
| 500 | + | // Sessions are explicitly selected | |
| 501 | + | total += m.SessionsSize | |
| 600 | 502 | } | |
| 601 | 503 | return total | |
| 602 | 504 | } | |
| 603 | 505 | ||
| 604 | - | // SelectedSessionIDs returns the IDs of selected sessions. | |
| 605 | - | // Returns nil if Data is selected (sessions are deleted with the database). | |
| 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. | |
| 606 | 509 | func (m PurgeModel) SelectedSessionIDs() []entity.SessionID { | |
| 607 | 510 | if m.isDataSelected() { | |
| 608 | 511 | return nil | |
| 609 | 512 | } | |
| 513 | + | if !m.allSessionsSelected { | |
| 514 | + | return nil | |
| 515 | + | } | |
| 610 | 516 | ||
| 611 | - | var ids []entity.SessionID | |
| 517 | + | ids := make([]entity.SessionID, 0, len(m.Sessions)) | |
| 612 | 518 | for _, s := range m.Sessions { | |
| 613 | - | if s.Selected { | |
| 614 | - | ids = append(ids, s.Info.Session.ID) | |
| 615 | - | } | |
| 519 | + | ids = append(ids, s.Info.Session.ID) | |
| 616 | 520 | } | |
| 617 | 521 | return ids | |
| 618 | 522 | } | |
| @@ -3,7 +3,6 @@ package styles | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | - | "time" | |||
| 7 | 6 | ||||
| 8 | "github.com/charmbracelet/bubbles/key" | 7 | "github.com/charmbracelet/bubbles/key" | ||
| 9 | tea "github.com/charmbracelet/bubbletea" | 8 | tea "github.com/charmbracelet/bubbletea" | ||
| @@ -18,44 +17,71 @@ type PurgeItem struct { | |||||
| 18 | Selected bool | 17 | Selected bool | ||
| 19 | } | 18 | } | ||
| 20 | 19 | ||||
| 21 | - | // PurgeModel is the multi-select purge modal with sessions support. | 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. | |||
| 22 | type PurgeModel struct { | 22 | type PurgeModel struct { | ||
| 23 | Items []PurgeItem | 23 | Items []PurgeItem | ||
| 24 | Sessions []entity.SessionPurgeItem | 24 | Sessions []entity.SessionPurgeItem | ||
| 25 | SessionsSize int64 // Total size of all session snapshots | 25 | SessionsSize int64 // Total size of all session snapshots | ||
| 26 | - | Cursor int | 26 | + | Cursor int // Flat cursor index across all rows |
| 27 | Confirmed bool | 27 | Confirmed bool | ||
| 28 | Canceled bool | 28 | Canceled bool | ||
| 29 | theme *Theme | 29 | theme *Theme | ||
| 30 | 30 | ||||
| 31 | - | // Track cursor position: false = items section, true = sessions section | 31 | + | // allSessionsSelected indicates if all sessions should be purged |
| 32 | - | inSessionsSection bool | 32 | + | allSessionsSelected bool |
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | // PurgeKeyMap defines keybindings for purge modal. | 35 | // PurgeKeyMap defines keybindings for purge modal. | ||
| 36 | type PurgeKeyMap struct { | 36 | type PurgeKeyMap struct { | ||
| 37 | - | Up key.Binding | 37 | + | Up key.Binding |
| 38 | - | Down key.Binding | 38 | + | Down key.Binding |
| 39 | - | Toggle key.Binding | 39 | + | Toggle key.Binding |
| 40 | - | ToggleAll key.Binding | 40 | + | ToggleAll key.Binding |
| 41 | - | ToggleSessions key.Binding | 41 | + | Confirm key.Binding |
| 42 | - | Confirm key.Binding | 42 | + | Cancel key.Binding |
| 43 | - | Cancel key.Binding | |||
| 44 | } | 43 | } | ||
| 45 | 44 | ||||
| 46 | // DefaultPurgeKeyMap returns default keybindings. | 45 | // DefaultPurgeKeyMap returns default keybindings. | ||
| 47 | func DefaultPurgeKeyMap() PurgeKeyMap { | 46 | func DefaultPurgeKeyMap() PurgeKeyMap { | ||
| 48 | return PurgeKeyMap{ | 47 | return PurgeKeyMap{ | ||
| 49 | - | Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), | 48 | + | Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), |
| 50 | - | Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), | 49 | + | Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), |
| 51 | - | Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), | 50 | + | Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), |
| 52 | - | ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")), | 51 | + | ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")), |
| 53 | - | ToggleSessions: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "toggle sessions")), | 52 | + | Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), |
| 54 | - | Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), | 53 | + | Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")), |
| 55 | - | Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")), | |||
| 56 | } | 54 | } | ||
| 57 | } | 55 | } | ||
| 58 | 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 | + | ||||
| 59 | // NewPurge creates a new purge modal with the given targets. | 85 | // NewPurge creates a new purge modal with the given targets. | ||
| 60 | func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel { | 86 | func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel { | ||
| 61 | items := make([]PurgeItem, 0, len(targets)) | 87 | items := make([]PurgeItem, 0, len(targets)) | ||
| @@ -97,8 +123,6 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) { | |||||
| 97 | m.toggleCurrent() | 123 | m.toggleCurrent() | ||
| 98 | case key.Matches(msg, keys.ToggleAll): | 124 | case key.Matches(msg, keys.ToggleAll): | ||
| 99 | m.toggleAll() | 125 | m.toggleAll() | ||
| 100 | - | case key.Matches(msg, keys.ToggleSessions): | |||
| 101 | - | m.ToggleAllSessions() | |||
| 102 | case key.Matches(msg, keys.Confirm): | 126 | case key.Matches(msg, keys.Confirm): | ||
| 103 | m.Confirmed = true | 127 | m.Confirmed = true | ||
| 104 | case key.Matches(msg, keys.Cancel): | 128 | case key.Matches(msg, keys.Cancel): | ||
| @@ -119,73 +143,53 @@ func (m PurgeModel) isDataSelected() bool { | |||||
| 119 | return false | 143 | return false | ||
| 120 | } | 144 | } | ||
| 121 | 145 | ||||
| 122 | - | // SessionsEnabled returns true if sessions can be individually selected. | 146 | + | // SessionsEnabled returns true if sessions can be selected. |
| 123 | // Sessions are disabled when Data is selected (database will be removed). | 147 | // Sessions are disabled when Data is selected (database will be removed). | ||
| 124 | func (m PurgeModel) SessionsEnabled() bool { | 148 | func (m PurgeModel) SessionsEnabled() bool { | ||
| 125 | return !m.isDataSelected() | 149 | return !m.isDataSelected() | ||
| 126 | } | 150 | } | ||
| 127 | 151 | ||||
| 128 | - | // totalSelectableCount returns the total number of selectable items across both sections. | 152 | + | // hasSessionsItem returns true if there are sessions available to purge. |
| 129 | - | func (m PurgeModel) totalSelectableCount() int { | 153 | + | func (m PurgeModel) hasSessionsItem() bool { |
| 130 | - | count := 0 | 154 | + | return len(m.Sessions) > 0 |
| 131 | - | for _, it := range m.Items { | |||
| 132 | - | if it.Exists { | |||
| 133 | - | count++ | |||
| 134 | - | } | |||
| 135 | - | } | |||
| 136 | - | if m.SessionsEnabled() { | |||
| 137 | - | count += len(m.Sessions) | |||
| 138 | - | } | |||
| 139 | - | return count | |||
| 140 | } | 155 | } | ||
| 141 | 156 | ||||
| 142 | func (m *PurgeModel) moveCursor(delta int) { | 157 | func (m *PurgeModel) moveCursor(delta int) { | ||
| 143 | - | total := m.totalSelectableCount() | 158 | + | rows := m.buildRows() |
| 144 | - | if total == 0 { | 159 | + | if len(rows) == 0 { |
| 145 | return | 160 | return | ||
| 146 | } | 161 | } | ||
| 147 | 162 | ||||
| 148 | - | // Build a flat list of selectable indices | 163 | + | // Build list of selectable row indices |
| 149 | - | // Items first, then sessions (if enabled) | 164 | + | var selectableIndices []int |
| 150 | - | type position struct { | 165 | + | for i, r := range rows { |
| 151 | - | inSessions bool | 166 | + | if r.Exists { |
| 152 | - | index int | 167 | + | selectableIndices = append(selectableIndices, i) |
| 153 | - | } | |||
| 154 | - | var positions []position | |||
| 155 | - | ||||
| 156 | - | for i, it := range m.Items { | |||
| 157 | - | if it.Exists { | |||
| 158 | - | positions = append(positions, position{inSessions: false, index: i}) | |||
| 159 | - | } | |||
| 160 | - | } | |||
| 161 | - | if m.SessionsEnabled() { | |||
| 162 | - | for i := range m.Sessions { | |||
| 163 | - | positions = append(positions, position{inSessions: true, index: i}) | |||
| 164 | } | 168 | } | ||
| 165 | } | 169 | } | ||
| 166 | 170 | ||||
| 167 | - | if len(positions) == 0 { | 171 | + | if len(selectableIndices) == 0 { |
| 168 | return | 172 | return | ||
| 169 | } | 173 | } | ||
| 170 | 174 | ||||
| 171 | - | // Find current position in the flat list | 175 | + | // Find current position in selectable list |
| 172 | - | currentFlat := 0 | 176 | + | currentPos := 0 |
| 173 | - | for i, p := range positions { | 177 | + | for i, idx := range selectableIndices { |
| 174 | - | if p.inSessions == m.inSessionsSection && p.index == m.Cursor { | 178 | + | if idx == m.Cursor { |
| 175 | - | currentFlat = i | 179 | + | currentPos = i |
| 176 | break | 180 | break | ||
| 177 | } | 181 | } | ||
| 178 | } | 182 | } | ||
| 179 | 183 | ||||
| 180 | - | // Move in the flat list | 184 | + | // Move |
| 181 | - | newFlat := (currentFlat + delta + len(positions)) % len(positions) | 185 | + | newPos := (currentPos + delta + len(selectableIndices)) % len(selectableIndices) |
| 182 | - | m.inSessionsSection = positions[newFlat].inSessions | 186 | + | m.Cursor = selectableIndices[newPos] |
| 183 | - | m.Cursor = positions[newFlat].index | |||
| 184 | } | 187 | } | ||
| 185 | 188 | ||||
| 186 | func (m PurgeModel) firstSelectableIndex() int { | 189 | func (m PurgeModel) firstSelectableIndex() int { | ||
| 187 | - | for i, it := range m.Items { | 190 | + | rows := m.buildRows() |
| 188 | - | if it.Exists { | 191 | + | for i, r := range rows { |
| 192 | + | if r.Exists { | |||
| 189 | return i | 193 | return i | ||
| 190 | } | 194 | } | ||
| 191 | } | 195 | } | ||
| @@ -193,27 +197,26 @@ func (m PurgeModel) firstSelectableIndex() int { | |||||
| 193 | } | 197 | } | ||
| 194 | 198 | ||||
| 195 | func (m *PurgeModel) toggleCurrent() { | 199 | func (m *PurgeModel) toggleCurrent() { | ||
| 196 | - | if m.inSessionsSection { | 200 | + | rows := m.buildRows() |
| 197 | - | if !m.SessionsEnabled() { | 201 | + | if m.Cursor < 0 || m.Cursor >= len(rows) { |
| 198 | - | return | |||
| 199 | - | } | |||
| 200 | - | if m.Cursor >= 0 && m.Cursor < len(m.Sessions) { | |||
| 201 | - | m.Sessions[m.Cursor].Selected = !m.Sessions[m.Cursor].Selected | |||
| 202 | - | } | |||
| 203 | return | 202 | return | ||
| 204 | } | 203 | } | ||
| 205 | 204 | ||||
| 206 | - | if m.Cursor < 0 || m.Cursor >= len(m.Items) { | 205 | + | r := rows[m.Cursor] |
| 206 | + | if !r.Exists { | |||
| 207 | return | 207 | return | ||
| 208 | } | 208 | } | ||
| 209 | - | if !m.Items[m.Cursor].Exists { | 209 | + | |
| 210 | - | return | 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 | |||
| 211 | } | 215 | } | ||
| 212 | - | m.Items[m.Cursor].Selected = !m.Items[m.Cursor].Selected | |||
| 213 | } | 216 | } | ||
| 214 | 217 | ||||
| 215 | func (m *PurgeModel) toggleAll() { | 218 | func (m *PurgeModel) toggleAll() { | ||
| 216 | - | // Toggle all items | 219 | + | // Check if any selectable item is unselected |
| 217 | anyUnselected := false | 220 | anyUnselected := false | ||
| 218 | for _, it := range m.Items { | 221 | for _, it := range m.Items { | ||
| 219 | if it.Exists && !it.Selected { | 222 | if it.Exists && !it.Selected { | ||
| @@ -221,46 +224,27 @@ func (m *PurgeModel) toggleAll() { | |||||
| 221 | break | 224 | break | ||
| 222 | } | 225 | } | ||
| 223 | } | 226 | } | ||
| 224 | - | // Also check sessions if enabled | 227 | + | if m.hasSessionsItem() && m.SessionsEnabled() && !m.allSessionsSelected { |
| 225 | - | if m.SessionsEnabled() { | 228 | + | anyUnselected = true |
| 226 | - | for _, s := range m.Sessions { | |||
| 227 | - | if !s.Selected { | |||
| 228 | - | anyUnselected = true | |||
| 229 | - | break | |||
| 230 | - | } | |||
| 231 | - | } | |||
| 232 | } | 229 | } | ||
| 233 | 230 | ||||
| 231 | + | // Set all to the opposite state | |||
| 234 | for i := range m.Items { | 232 | for i := range m.Items { | ||
| 235 | - | if !m.Items[i].Exists { | 233 | + | if m.Items[i].Exists { |
| 236 | - | continue | 234 | + | m.Items[i].Selected = anyUnselected |
| 237 | } | 235 | } | ||
| 238 | - | m.Items[i].Selected = anyUnselected | |||
| 239 | } | 236 | } | ||
| 240 | - | if m.SessionsEnabled() { | 237 | + | if m.hasSessionsItem() && m.SessionsEnabled() { |
| 241 | - | for i := range m.Sessions { | 238 | + | m.allSessionsSelected = anyUnselected |
| 242 | - | m.Sessions[i].Selected = anyUnselected | |||
| 243 | - | } | |||
| 244 | } | 239 | } | ||
| 245 | } | 240 | } | ||
| 246 | 241 | ||||
| 247 | // ToggleAllSessions toggles the selection state of all sessions. | 242 | // ToggleAllSessions toggles the selection state of all sessions. | ||
| 248 | func (m *PurgeModel) ToggleAllSessions() { | 243 | func (m *PurgeModel) ToggleAllSessions() { | ||
| 249 | - | if !m.SessionsEnabled() || len(m.Sessions) == 0 { | 244 | + | if !m.hasSessionsItem() || !m.SessionsEnabled() { |
| 250 | return | 245 | return | ||
| 251 | } | 246 | } | ||
| 252 | - | 247 | + | m.allSessionsSelected = !m.allSessionsSelected | |
| 253 | - | anyUnselected := false | |||
| 254 | - | for _, s := range m.Sessions { | |||
| 255 | - | if !s.Selected { | |||
| 256 | - | anyUnselected = true | |||
| 257 | - | break | |||
| 258 | - | } | |||
| 259 | - | } | |||
| 260 | - | ||||
| 261 | - | for i := range m.Sessions { | |||
| 262 | - | m.Sessions[i].Selected = anyUnselected | |||
| 263 | - | } | |||
| 264 | } | 248 | } | ||
| 265 | 249 | ||||
| 266 | // View implements tea.Model. | 250 | // View implements tea.Model. | ||
| @@ -270,34 +254,21 @@ func (m PurgeModel) View() string { | |||||
| 270 | header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash)) | 254 | header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash)) | ||
| 271 | subtitle := t.Subtle.Render("Select items to remove") | 255 | subtitle := t.Subtle.Render("Select items to remove") | ||
| 272 | 256 | ||||
| 273 | - | // Application Data section | 257 | + | // Build rows and render |
| 274 | - | dataSectionHeader := t.Subtle.Render("── Application Data ──") | 258 | + | rows := m.buildRows() |
| 275 | var itemRows []string | 259 | var itemRows []string | ||
| 276 | - | for i, it := range m.Items { | 260 | + | for i, r := range rows { |
| 277 | - | itemRows = append(itemRows, m.renderItemRow(i, it)) | 261 | + | isCursor := i == m.Cursor |
| 278 | - | } | 262 | + | switch r.Type { |
| 279 | - | itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...) | 263 | + | case rowTypeItem: |
| 280 | - | 264 | + | itemRows = append(itemRows, m.renderItemRow(r.ItemIndex, m.Items[r.ItemIndex], isCursor)) | |
| 281 | - | // Sessions section | 265 | + | case rowTypeSessions: |
| 282 | - | var sessionsList string | 266 | + | itemRows = append(itemRows, m.renderSessionsItem(isCursor)) |
| 283 | - | if len(m.Sessions) > 0 { | |||
| 284 | - | sessionsEnabled := m.SessionsEnabled() | |||
| 285 | - | var sessionHeader string | |||
| 286 | - | if sessionsEnabled { | |||
| 287 | - | sessionHeader = t.Subtle.Render(fmt.Sprintf("── Inactive Sessions (%d total, %s) ──", | |||
| 288 | - | len(m.Sessions), formatSize(m.SessionsSize))) | |||
| 289 | - | } else { | |||
| 290 | - | sessionHeader = t.Subtle.Render("── Inactive Sessions (database will be removed) ──") | |||
| 291 | - | } | |||
| 292 | - | ||||
| 293 | - | var sessionRows []string | |||
| 294 | - | sessionRows = append(sessionRows, sessionHeader) | |||
| 295 | - | for i, s := range m.Sessions { | |||
| 296 | - | sessionRows = append(sessionRows, m.renderSessionRow(i, s, sessionsEnabled)) | |||
| 297 | } | 267 | } | ||
| 298 | - | sessionsList = lipgloss.JoinVertical(lipgloss.Left, sessionRows...) | |||
| 299 | } | 268 | } | ||
| 300 | 269 | ||||
| 270 | + | itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...) | |||
| 271 | + | ||||
| 301 | selectedCount := m.SelectedCount() | 272 | selectedCount := m.SelectedCount() | ||
| 302 | size := m.SelectedSize() | 273 | size := m.SelectedSize() | ||
| 303 | var summary string | 274 | var summary string | ||
| @@ -314,35 +285,30 @@ func (m PurgeModel) View() string { | |||||
| 314 | summary = t.Subtle.Render("0 selected") | 285 | summary = t.Subtle.Render("0 selected") | ||
| 315 | } | 286 | } | ||
| 316 | 287 | ||||
| 317 | - | helpText := "↑/↓ j/k move • space toggle • a all" | 288 | + | helpText := "↑/↓ j/k move • space toggle • a all • enter • esc" |
| 318 | - | if len(m.Sessions) > 0 && m.SessionsEnabled() { | |||
| 319 | - | helpText += " • s sessions" | |||
| 320 | - | } | |||
| 321 | - | helpText += " • enter • esc" | |||
| 322 | help := t.Subtle.Render(helpText) | 289 | help := t.Subtle.Render(helpText) | ||
| 323 | 290 | ||||
| 324 | contentParts := []string{ | 291 | contentParts := []string{ | ||
| 325 | header, | 292 | header, | ||
| 326 | subtitle, | 293 | subtitle, | ||
| 327 | "", | 294 | "", | ||
| 328 | - | dataSectionHeader, | |||
| 329 | itemsList, | 295 | itemsList, | ||
| 296 | + | "", | |||
| 297 | + | summary, | |||
| 298 | + | "", | |||
| 299 | + | help, | |||
| 330 | } | 300 | } | ||
| 331 | - | if sessionsList != "" { | |||
| 332 | - | contentParts = append(contentParts, "", sessionsList) | |||
| 333 | - | } | |||
| 334 | - | contentParts = append(contentParts, "", summary, "", help) | |||
| 335 | 301 | ||||
| 336 | content := lipgloss.JoinVertical(lipgloss.Left, contentParts...) | 302 | content := lipgloss.JoinVertical(lipgloss.Left, contentParts...) | ||
| 337 | 303 | ||||
| 338 | return t.Box.Render(content) | 304 | return t.Box.Render(content) | ||
| 339 | } | 305 | } | ||
| 340 | 306 | ||||
| 341 | - | func (m PurgeModel) renderItemRow(i int, it PurgeItem) string { | 307 | + | func (m PurgeModel) renderItemRow(_ int, it PurgeItem, isCursor bool) string { |
| 342 | t := m.theme | 308 | t := m.theme | ||
| 343 | 309 | ||||
| 344 | cursor := " " | 310 | cursor := " " | ||
| 345 | - | if !m.inSessionsSection && i == m.Cursor { | 311 | + | if isCursor && it.Exists { |
| 346 | cursor = IconCursor + " " | 312 | cursor = IconCursor + " " | ||
| 347 | } | 313 | } | ||
| 348 | 314 | ||||
| @@ -391,11 +357,13 @@ func (m PurgeModel) renderItemRow(i int, it PurgeItem) string { | |||||
| 391 | return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing) | 357 | return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing) | ||
| 392 | } | 358 | } | ||
| 393 | 359 | ||||
| 394 | - | func (m PurgeModel) renderSessionRow(i int, s entity.SessionPurgeItem, enabled bool) string { | 360 | + | // renderSessionsItem renders the single "Sessions" item. |
| 361 | + | func (m PurgeModel) renderSessionsItem(isCursor bool) string { | |||
| 395 | t := m.theme | 362 | t := m.theme | ||
| 363 | + | enabled := m.SessionsEnabled() | |||
| 396 | 364 | ||||
| 397 | cursor := " " | 365 | cursor := " " | ||
| 398 | - | if m.inSessionsSection && i == m.Cursor && enabled { | 366 | + | if isCursor && enabled { |
| 399 | cursor = IconCursor + " " | 367 | cursor = IconCursor + " " | ||
| 400 | } | 368 | } | ||
| 401 | 369 | ||||
| @@ -406,44 +374,31 @@ func (m PurgeModel) renderSessionRow(i int, s entity.SessionPurgeItem, enabled b | |||||
| 406 | detailStyle := t.Subtle | 374 | detailStyle := t.Subtle | ||
| 407 | 375 | ||||
| 408 | if enabled { | 376 | if enabled { | ||
| 409 | - | if s.Selected { | 377 | + | if m.allSessionsSelected { |
| 410 | checkbox = IconCheckboxChecked | 378 | checkbox = IconCheckboxChecked | ||
| 411 | } | 379 | } | ||
| 412 | checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent) | 380 | checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent) | ||
| 413 | cursorStyle = lipgloss.NewStyle().Foreground(t.Accent) | 381 | cursorStyle = lipgloss.NewStyle().Foreground(t.Accent) | ||
| 414 | labelStyle = t.Normal | 382 | labelStyle = t.Normal | ||
| 415 | - | detailStyle = t.Subtle | |||
| 416 | } | 383 | } | ||
| 417 | 384 | ||||
| 418 | - | // Format: ...abc1 2 tabs 3 panes 2d ago | 385 | + | // Format: Sessions (5 inactive, 12.3 MB) or (included with Data) |
| 419 | - | shortID := s.Info.Session.ShortID() | 386 | + | label := fmt.Sprintf("%s Sessions", IconSession) |
| 420 | - | tabLabel := "tab" | 387 | + | var detail string |
| 421 | - | if s.Info.TabCount != 1 { | 388 | + | if enabled { |
| 422 | - | tabLabel = "tabs" | 389 | + | detail = fmt.Sprintf("(%d inactive, %s)", len(m.Sessions), formatSize(m.SessionsSize)) |
| 423 | - | } | 390 | + | } else { |
| 424 | - | paneLabel := "pane" | 391 | + | detail = fmt.Sprintf("(%d inactive, included with Data)", len(m.Sessions)) |
| 425 | - | if s.Info.PaneCount != 1 { | |||
| 426 | - | paneLabel = "panes" | |||
| 427 | } | 392 | } | ||
| 428 | 393 | ||||
| 429 | - | const idPadWidth = 8 | 394 | + | const labelPadWidth = 12 |
| 430 | - | const tabPadWidth = 8 | |||
| 431 | - | const panePadWidth = 9 | |||
| 432 | - | ||||
| 433 | - | idPart := labelStyle.Render(padRight("..."+shortID, idPadWidth)) | |||
| 434 | - | tabPart := detailStyle.Render(padRight(fmt.Sprintf("%d %s", s.Info.TabCount, tabLabel), tabPadWidth)) | |||
| 435 | - | panePart := detailStyle.Render(padRight(fmt.Sprintf("%d %s", s.Info.PaneCount, paneLabel), panePadWidth)) | |||
| 436 | - | timePart := detailStyle.Render(getRelativeTime(s.Info.UpdatedAt)) | |||
| 437 | - | ||||
| 438 | return lipgloss.JoinHorizontal( | 395 | return lipgloss.JoinHorizontal( | ||
| 439 | lipgloss.Left, | 396 | lipgloss.Left, | ||
| 440 | cursorStyle.Render(cursor), | 397 | cursorStyle.Render(cursor), | ||
| 441 | checkboxStyle.Render(checkbox), | 398 | checkboxStyle.Render(checkbox), | ||
| 442 | " ", | 399 | " ", | ||
| 443 | - | idPart, | 400 | + | labelStyle.Render(padRight(label, labelPadWidth)), |
| 444 | - | tabPart, | 401 | + | detailStyle.Render(detail), |
| 445 | - | panePart, | |||
| 446 | - | timePart, | |||
| 447 | ) | 402 | ) | ||
| 448 | } | 403 | } | ||
| 449 | 404 | ||||
| @@ -493,45 +448,6 @@ func formatSize(bytes int64) string { | |||||
| 493 | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) | 448 | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) | ||
| 494 | } | 449 | } | ||
| 495 | 450 | ||||
| 496 | - | func getRelativeTime(t time.Time) string { | |||
| 497 | - | now := time.Now() | |||
| 498 | - | diff := now.Sub(t) | |||
| 499 | - | ||||
| 500 | - | const ( | |||
| 501 | - | hoursPerDay = 24 | |||
| 502 | - | daysPerWeek = 7 | |||
| 503 | - | ) | |||
| 504 | - | ||||
| 505 | - | switch { | |||
| 506 | - | case diff < time.Minute: | |||
| 507 | - | return "just now" | |||
| 508 | - | case diff < time.Hour: | |||
| 509 | - | mins := int(diff.Minutes()) | |||
| 510 | - | if mins == 1 { | |||
| 511 | - | return "1m ago" | |||
| 512 | - | } | |||
| 513 | - | return fmt.Sprintf("%dm ago", mins) | |||
| 514 | - | case diff < hoursPerDay*time.Hour: | |||
| 515 | - | hours := int(diff.Hours()) | |||
| 516 | - | if hours == 1 { | |||
| 517 | - | return "1h ago" | |||
| 518 | - | } | |||
| 519 | - | return fmt.Sprintf("%dh ago", hours) | |||
| 520 | - | case diff < daysPerWeek*hoursPerDay*time.Hour: | |||
| 521 | - | days := int(diff.Hours() / hoursPerDay) | |||
| 522 | - | if days == 1 { | |||
| 523 | - | return "1d ago" | |||
| 524 | - | } | |||
| 525 | - | return fmt.Sprintf("%dd ago", days) | |||
| 526 | - | default: | |||
| 527 | - | weeks := int(diff.Hours() / hoursPerDay / daysPerWeek) | |||
| 528 | - | if weeks == 1 { | |||
| 529 | - | return "1w ago" | |||
| 530 | - | } | |||
| 531 | - | return fmt.Sprintf("%dw ago", weeks) | |||
| 532 | - | } | |||
| 533 | - | } | |||
| 534 | - | ||||
| 535 | // Done returns true if the modal is complete. | 451 | // Done returns true if the modal is complete. | ||
| 536 | func (m PurgeModel) Done() bool { | 452 | func (m PurgeModel) Done() bool { | ||
| 537 | return m.Confirmed || m.Canceled | 453 | return m.Confirmed || m.Canceled | ||
| @@ -553,7 +469,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType { | |||||
| 553 | return out | 469 | return out | ||
| 554 | } | 470 | } | ||
| 555 | 471 | ||||
| 556 | - | // SelectedCount returns the number of selected items (targets + sessions). | 472 | + | // SelectedCount returns the number of selected items (targets + sessions as 1 if selected). |
| 557 | func (m PurgeModel) SelectedCount() int { | 473 | func (m PurgeModel) SelectedCount() int { | ||
| 558 | count := 0 | 474 | count := 0 | ||
| 559 | for _, it := range m.Items { | 475 | for _, it := range m.Items { | ||
| @@ -561,13 +477,9 @@ func (m PurgeModel) SelectedCount() int { | |||||
| 561 | count++ | 477 | count++ | ||
| 562 | } | 478 | } | ||
| 563 | } | 479 | } | ||
| 564 | - | // Only count sessions if they're enabled (Data not selected) | 480 | + | // Count sessions as 1 item if selected (and enabled) |
| 565 | - | if m.SessionsEnabled() { | 481 | + | if m.hasSessionsItem() && m.SessionsEnabled() && m.allSessionsSelected { |
| 566 | - | for _, s := range m.Sessions { | 482 | + | count++ |
| 567 | - | if s.Selected { | |||
| 568 | - | count++ | |||
| 569 | - | } | |||
| 570 | - | } | |||
| 571 | } | 483 | } | ||
| 572 | return count | 484 | return count | ||
| 573 | } | 485 | } | ||
| @@ -584,35 +496,27 @@ func (m PurgeModel) SelectedSize() int64 { | |||||
| 584 | // If Data is selected, sessions are implicitly included | 496 | // If Data is selected, sessions are implicitly included | ||
| 585 | if m.isDataSelected() { | 497 | if m.isDataSelected() { | ||
| 586 | total += m.SessionsSize | 498 | total += m.SessionsSize | ||
| 587 | - | } else { | 499 | + | } else if m.allSessionsSelected { |
| 588 | - | // Otherwise, count only selected sessions | 500 | + | // Sessions are explicitly selected |
| 589 | - | // Note: individual session sizes aren't tracked, only total | 501 | + | total += m.SessionsSize |
| 590 | - | // When sessions are selected, we include the total size proportionally | |||
| 591 | - | selectedCount := 0 | |||
| 592 | - | for _, s := range m.Sessions { | |||
| 593 | - | if s.Selected { | |||
| 594 | - | selectedCount++ | |||
| 595 | - | } | |||
| 596 | - | } | |||
| 597 | - | if len(m.Sessions) > 0 && selectedCount > 0 { | |||
| 598 | - | total += m.SessionsSize * int64(selectedCount) / int64(len(m.Sessions)) | |||
| 599 | - | } | |||
| 600 | } | 502 | } | ||
| 601 | return total | 503 | return total | ||
| 602 | } | 504 | } | ||
| 603 | 505 | ||||
| 604 | - | // SelectedSessionIDs returns the IDs of selected sessions. | 506 | + | // SelectedSessionIDs returns the IDs of all sessions if sessions are selected. |
| 605 | - | // Returns nil if Data is selected (sessions are deleted with the database). | 507 | + | // Returns nil if Data is selected (sessions are deleted with the database) |
| 508 | + | // or if sessions are not selected. | |||
| 606 | func (m PurgeModel) SelectedSessionIDs() []entity.SessionID { | 509 | func (m PurgeModel) SelectedSessionIDs() []entity.SessionID { | ||
| 607 | if m.isDataSelected() { | 510 | if m.isDataSelected() { | ||
| 608 | return nil | 511 | return nil | ||
| 609 | } | 512 | } | ||
| 513 | + | if !m.allSessionsSelected { | |||
| 514 | + | return nil | |||
| 515 | + | } | |||
| 610 | 516 | ||||
| 611 | - | var ids []entity.SessionID | 517 | + | ids := make([]entity.SessionID, 0, len(m.Sessions)) |
| 612 | for _, s := range m.Sessions { | 518 | for _, s := range m.Sessions { | ||
| 613 | - | if s.Selected { | 519 | + | ids = append(ids, s.Info.Session.ID) |
| 614 | - | ids = append(ids, s.Info.Session.ID) | |||
| 615 | - | } | |||
| 616 | } | 520 | } | ||
| 617 | return ids | 521 | return ids | ||
| 618 | } | 522 | } | ||
M internal/cli/styles/purge_test.go +27 -20
| @@ -68,7 +68,6 @@ func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T) | |||
| 68 | 68 | EndedAt: &endedAt, | |
| 69 | 69 | }, | |
| 70 | 70 | }, | |
| 71 | - | Selected: true, | |
| 72 | 71 | }, | |
| 73 | 72 | } | |
| 74 | 73 | ||
| @@ -78,8 +77,9 @@ func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T) | |||
| 78 | 77 | ids := m.SelectedSessionIDs() | |
| 79 | 78 | assert.Nil(t, ids, "should return nil when Data is selected") | |
| 80 | 79 | ||
| 81 | - | // Deselect Data | |
| 80 | + | // Deselect Data and select all sessions | |
| 82 | 81 | m.Items[0].Selected = false | |
| 82 | + | m.ToggleAllSessions() // Now allSessionsSelected = true | |
| 83 | 83 | ids = m.SelectedSessionIDs() | |
| 84 | 84 | require.Len(t, ids, 1) | |
| 85 | 85 | assert.Equal(t, entity.SessionID("session1"), ids[0]) | |
| @@ -104,7 +104,6 @@ func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) { | |||
| 104 | 104 | EndedAt: &endedAt, | |
| 105 | 105 | }, | |
| 106 | 106 | }, | |
| 107 | - | Selected: true, | |
| 108 | 107 | }, | |
| 109 | 108 | { | |
| 110 | 109 | Info: entity.SessionInfo{ | |
| @@ -115,13 +114,17 @@ func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) { | |||
| 115 | 114 | EndedAt: &endedAt, | |
| 116 | 115 | }, | |
| 117 | 116 | }, | |
| 118 | - | Selected: false, // Not selected | |
| 119 | 117 | }, | |
| 120 | 118 | } | |
| 121 | 119 | ||
| 122 | 120 | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | |
| 123 | 121 | ||
| 124 | - | // 2 targets + 1 selected session = 3 | |
| 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 | |
| 125 | 128 | assert.Equal(t, 3, m.SelectedCount()) | |
| 126 | 129 | } | |
| 127 | 130 | ||
| @@ -143,7 +146,6 @@ func TestPurgeModel_ToggleAllSessions(t *testing.T) { | |||
| 143 | 146 | EndedAt: &endedAt, | |
| 144 | 147 | }, | |
| 145 | 148 | }, | |
| 146 | - | Selected: true, | |
| 147 | 149 | }, | |
| 148 | 150 | { | |
| 149 | 151 | Info: entity.SessionInfo{ | |
| @@ -154,24 +156,27 @@ func TestPurgeModel_ToggleAllSessions(t *testing.T) { | |||
| 154 | 156 | EndedAt: &endedAt, | |
| 155 | 157 | }, | |
| 156 | 158 | }, | |
| 157 | - | Selected: true, | |
| 158 | 159 | }, | |
| 159 | 160 | } | |
| 160 | 161 | ||
| 161 | 162 | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | |
| 162 | 163 | ||
| 163 | - | // All sessions are selected, toggle should deselect all | |
| 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 | |
| 164 | 169 | m.ToggleAllSessions() | |
| 165 | - | assert.False(t, m.Sessions[0].Selected) | |
| 166 | - | assert.False(t, m.Sessions[1].Selected) | |
| 170 | + | ids = m.SelectedSessionIDs() | |
| 171 | + | require.Len(t, ids, 2, "all sessions should be selected after toggle") | |
| 167 | 172 | ||
| 168 | - | // Now toggle again to select all | |
| 173 | + | // Toggle again to deselect all | |
| 169 | 174 | m.ToggleAllSessions() | |
| 170 | - | assert.True(t, m.Sessions[0].Selected) | |
| 171 | - | assert.True(t, m.Sessions[1].Selected) | |
| 175 | + | ids = m.SelectedSessionIDs() | |
| 176 | + | assert.Nil(t, ids, "sessions should be deselected after second toggle") | |
| 172 | 177 | } | |
| 173 | 178 | ||
| 174 | - | func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) { | |
| 179 | + | func TestPurgeModel_SelectedSize_IncludesAllSessionsWhenSelected(t *testing.T) { | |
| 175 | 180 | theme := testTheme() | |
| 176 | 181 | ||
| 177 | 182 | targets := []entity.PurgeTarget{ | |
| @@ -189,7 +194,6 @@ func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) { | |||
| 189 | 194 | EndedAt: &endedAt, | |
| 190 | 195 | }, | |
| 191 | 196 | }, | |
| 192 | - | Selected: true, | |
| 193 | 197 | }, | |
| 194 | 198 | { | |
| 195 | 199 | Info: entity.SessionInfo{ | |
| @@ -200,15 +204,19 @@ func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) { | |||
| 200 | 204 | EndedAt: &endedAt, | |
| 201 | 205 | }, | |
| 202 | 206 | }, | |
| 203 | - | Selected: false, // Not selected | |
| 204 | 207 | }, | |
| 205 | 208 | } | |
| 206 | 209 | ||
| 207 | - | // Total sessions size is 1000, 1 of 2 sessions selected = 500 | |
| 210 | + | // Total sessions size is 1000 | |
| 208 | 211 | m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000) | |
| 209 | 212 | ||
| 210 | - | // 100 (config) + 500 (1/2 of sessions) = 600 | |
| 211 | - | assert.Equal(t, int64(600), m.SelectedSize()) | |
| 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()) | |
| 212 | 220 | } | |
| 213 | 221 | ||
| 214 | 222 | func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *testing.T) { | |
| @@ -229,7 +237,6 @@ func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *tes | |||
| 229 | 237 | EndedAt: &endedAt, | |
| 230 | 238 | }, | |
| 231 | 239 | }, | |
| 232 | - | Selected: false, // Not individually selected, but Data is selected | |
| 233 | 240 | }, | |
| 234 | 241 | } | |
| 235 | 242 | ||
| @@ -68,7 +68,6 @@ func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T) | |||||
| 68 | EndedAt: &endedAt, | 68 | EndedAt: &endedAt, | ||
| 69 | }, | 69 | }, | ||
| 70 | }, | 70 | }, | ||
| 71 | - | Selected: true, | |||
| 72 | }, | 71 | }, | ||
| 73 | } | 72 | } | ||
| 74 | 73 | ||||
| @@ -78,8 +77,9 @@ func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T) | |||||
| 78 | ids := m.SelectedSessionIDs() | 77 | ids := m.SelectedSessionIDs() | ||
| 79 | assert.Nil(t, ids, "should return nil when Data is selected") | 78 | assert.Nil(t, ids, "should return nil when Data is selected") | ||
| 80 | 79 | ||||
| 81 | - | // Deselect Data | 80 | + | // Deselect Data and select all sessions |
| 82 | m.Items[0].Selected = false | 81 | m.Items[0].Selected = false | ||
| 82 | + | m.ToggleAllSessions() // Now allSessionsSelected = true | |||
| 83 | ids = m.SelectedSessionIDs() | 83 | ids = m.SelectedSessionIDs() | ||
| 84 | require.Len(t, ids, 1) | 84 | require.Len(t, ids, 1) | ||
| 85 | assert.Equal(t, entity.SessionID("session1"), ids[0]) | 85 | assert.Equal(t, entity.SessionID("session1"), ids[0]) | ||
| @@ -104,7 +104,6 @@ func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) { | |||||
| 104 | EndedAt: &endedAt, | 104 | EndedAt: &endedAt, | ||
| 105 | }, | 105 | }, | ||
| 106 | }, | 106 | }, | ||
| 107 | - | Selected: true, | |||
| 108 | }, | 107 | }, | ||
| 109 | { | 108 | { | ||
| 110 | Info: entity.SessionInfo{ | 109 | Info: entity.SessionInfo{ | ||
| @@ -115,13 +114,17 @@ func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) { | |||||
| 115 | EndedAt: &endedAt, | 114 | EndedAt: &endedAt, | ||
| 116 | }, | 115 | }, | ||
| 117 | }, | 116 | }, | ||
| 118 | - | Selected: false, // Not selected | |||
| 119 | }, | 117 | }, | ||
| 120 | } | 118 | } | ||
| 121 | 119 | ||||
| 122 | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | 120 | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | ||
| 123 | 121 | ||||
| 124 | - | // 2 targets + 1 selected session = 3 | 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 | |||
| 125 | assert.Equal(t, 3, m.SelectedCount()) | 128 | assert.Equal(t, 3, m.SelectedCount()) | ||
| 126 | } | 129 | } | ||
| 127 | 130 | ||||
| @@ -143,7 +146,6 @@ func TestPurgeModel_ToggleAllSessions(t *testing.T) { | |||||
| 143 | EndedAt: &endedAt, | 146 | EndedAt: &endedAt, | ||
| 144 | }, | 147 | }, | ||
| 145 | }, | 148 | }, | ||
| 146 | - | Selected: true, | |||
| 147 | }, | 149 | }, | ||
| 148 | { | 150 | { | ||
| 149 | Info: entity.SessionInfo{ | 151 | Info: entity.SessionInfo{ | ||
| @@ -154,24 +156,27 @@ func TestPurgeModel_ToggleAllSessions(t *testing.T) { | |||||
| 154 | EndedAt: &endedAt, | 156 | EndedAt: &endedAt, | ||
| 155 | }, | 157 | }, | ||
| 156 | }, | 158 | }, | ||
| 157 | - | Selected: true, | |||
| 158 | }, | 159 | }, | ||
| 159 | } | 160 | } | ||
| 160 | 161 | ||||
| 161 | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | 162 | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | ||
| 162 | 163 | ||||
| 163 | - | // All sessions are selected, toggle should deselect all | 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 | |||
| 164 | m.ToggleAllSessions() | 169 | m.ToggleAllSessions() | ||
| 165 | - | assert.False(t, m.Sessions[0].Selected) | 170 | + | ids = m.SelectedSessionIDs() |
| 166 | - | assert.False(t, m.Sessions[1].Selected) | 171 | + | require.Len(t, ids, 2, "all sessions should be selected after toggle") |
| 167 | 172 | ||||
| 168 | - | // Now toggle again to select all | 173 | + | // Toggle again to deselect all |
| 169 | m.ToggleAllSessions() | 174 | m.ToggleAllSessions() | ||
| 170 | - | assert.True(t, m.Sessions[0].Selected) | 175 | + | ids = m.SelectedSessionIDs() |
| 171 | - | assert.True(t, m.Sessions[1].Selected) | 176 | + | assert.Nil(t, ids, "sessions should be deselected after second toggle") |
| 172 | } | 177 | } | ||
| 173 | 178 | ||||
| 174 | - | func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) { | 179 | + | func TestPurgeModel_SelectedSize_IncludesAllSessionsWhenSelected(t *testing.T) { |
| 175 | theme := testTheme() | 180 | theme := testTheme() | ||
| 176 | 181 | ||||
| 177 | targets := []entity.PurgeTarget{ | 182 | targets := []entity.PurgeTarget{ | ||
| @@ -189,7 +194,6 @@ func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) { | |||||
| 189 | EndedAt: &endedAt, | 194 | EndedAt: &endedAt, | ||
| 190 | }, | 195 | }, | ||
| 191 | }, | 196 | }, | ||
| 192 | - | Selected: true, | |||
| 193 | }, | 197 | }, | ||
| 194 | { | 198 | { | ||
| 195 | Info: entity.SessionInfo{ | 199 | Info: entity.SessionInfo{ | ||
| @@ -200,15 +204,19 @@ func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) { | |||||
| 200 | EndedAt: &endedAt, | 204 | EndedAt: &endedAt, | ||
| 201 | }, | 205 | }, | ||
| 202 | }, | 206 | }, | ||
| 203 | - | Selected: false, // Not selected | |||
| 204 | }, | 207 | }, | ||
| 205 | } | 208 | } | ||
| 206 | 209 | ||||
| 207 | - | // Total sessions size is 1000, 1 of 2 sessions selected = 500 | 210 | + | // Total sessions size is 1000 |
| 208 | m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000) | 211 | m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000) | ||
| 209 | 212 | ||||
| 210 | - | // 100 (config) + 500 (1/2 of sessions) = 600 | 213 | + | // Only config selected = 100 |
| 211 | - | assert.Equal(t, int64(600), m.SelectedSize()) | 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()) | |||
| 212 | } | 220 | } | ||
| 213 | 221 | ||||
| 214 | func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *testing.T) { | 222 | func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *testing.T) { | ||
| @@ -229,7 +237,6 @@ func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *tes | |||||
| 229 | EndedAt: &endedAt, | 237 | EndedAt: &endedAt, | ||
| 230 | }, | 238 | }, | ||
| 231 | }, | 239 | }, | ||
| 232 | - | Selected: false, // Not individually selected, but Data is selected | |||
| 233 | }, | 240 | }, | ||
| 234 | } | 241 | } | ||
| 235 | 242 | ||||
M internal/domain/repository/mocks/mocks.go +132 -0
| @@ -2803,6 +2803,138 @@ func (_c *MockSessionRepository_Delete_Call) RunAndReturn(run func(ctx context.C | |||
| 2803 | 2803 | return _c | |
| 2804 | 2804 | } | |
| 2805 | 2805 | ||
| 2806 | + | // DeleteExitedBefore provides a mock function for the type MockSessionRepository | |
| 2807 | + | func (_mock *MockSessionRepository) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) { | |
| 2808 | + | ret := _mock.Called(ctx, cutoff) | |
| 2809 | + | ||
| 2810 | + | if len(ret) == 0 { | |
| 2811 | + | panic("no return value specified for DeleteExitedBefore") | |
| 2812 | + | } | |
| 2813 | + | ||
| 2814 | + | var r0 int64 | |
| 2815 | + | var r1 error | |
| 2816 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (int64, error)); ok { | |
| 2817 | + | return returnFunc(ctx, cutoff) | |
| 2818 | + | } | |
| 2819 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) int64); ok { | |
| 2820 | + | r0 = returnFunc(ctx, cutoff) | |
| 2821 | + | } else { | |
| 2822 | + | r0 = ret.Get(0).(int64) | |
| 2823 | + | } | |
| 2824 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok { | |
| 2825 | + | r1 = returnFunc(ctx, cutoff) | |
| 2826 | + | } else { | |
| 2827 | + | r1 = ret.Error(1) | |
| 2828 | + | } | |
| 2829 | + | return r0, r1 | |
| 2830 | + | } | |
| 2831 | + | ||
| 2832 | + | // MockSessionRepository_DeleteExitedBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteExitedBefore' | |
| 2833 | + | type MockSessionRepository_DeleteExitedBefore_Call struct { | |
| 2834 | + | *mock.Call | |
| 2835 | + | } | |
| 2836 | + | ||
| 2837 | + | // DeleteExitedBefore is a helper method to define mock.On call | |
| 2838 | + | // - ctx context.Context | |
| 2839 | + | // - cutoff time.Time | |
| 2840 | + | func (_e *MockSessionRepository_Expecter) DeleteExitedBefore(ctx interface{}, cutoff interface{}) *MockSessionRepository_DeleteExitedBefore_Call { | |
| 2841 | + | return &MockSessionRepository_DeleteExitedBefore_Call{Call: _e.mock.On("DeleteExitedBefore", ctx, cutoff)} | |
| 2842 | + | } | |
| 2843 | + | ||
| 2844 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) Run(run func(ctx context.Context, cutoff time.Time)) *MockSessionRepository_DeleteExitedBefore_Call { | |
| 2845 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2846 | + | var arg0 context.Context | |
| 2847 | + | if args[0] != nil { | |
| 2848 | + | arg0 = args[0].(context.Context) | |
| 2849 | + | } | |
| 2850 | + | var arg1 time.Time | |
| 2851 | + | if args[1] != nil { | |
| 2852 | + | arg1 = args[1].(time.Time) | |
| 2853 | + | } | |
| 2854 | + | run( | |
| 2855 | + | arg0, | |
| 2856 | + | arg1, | |
| 2857 | + | ) | |
| 2858 | + | }) | |
| 2859 | + | return _c | |
| 2860 | + | } | |
| 2861 | + | ||
| 2862 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) Return(n int64, err error) *MockSessionRepository_DeleteExitedBefore_Call { | |
| 2863 | + | _c.Call.Return(n, err) | |
| 2864 | + | return _c | |
| 2865 | + | } | |
| 2866 | + | ||
| 2867 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) RunAndReturn(run func(ctx context.Context, cutoff time.Time) (int64, error)) *MockSessionRepository_DeleteExitedBefore_Call { | |
| 2868 | + | _c.Call.Return(run) | |
| 2869 | + | return _c | |
| 2870 | + | } | |
| 2871 | + | ||
| 2872 | + | // DeleteOldestExited provides a mock function for the type MockSessionRepository | |
| 2873 | + | func (_mock *MockSessionRepository) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) { | |
| 2874 | + | ret := _mock.Called(ctx, keepCount) | |
| 2875 | + | ||
| 2876 | + | if len(ret) == 0 { | |
| 2877 | + | panic("no return value specified for DeleteOldestExited") | |
| 2878 | + | } | |
| 2879 | + | ||
| 2880 | + | var r0 int64 | |
| 2881 | + | var r1 error | |
| 2882 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) (int64, error)); ok { | |
| 2883 | + | return returnFunc(ctx, keepCount) | |
| 2884 | + | } | |
| 2885 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) int64); ok { | |
| 2886 | + | r0 = returnFunc(ctx, keepCount) | |
| 2887 | + | } else { | |
| 2888 | + | r0 = ret.Get(0).(int64) | |
| 2889 | + | } | |
| 2890 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |
| 2891 | + | r1 = returnFunc(ctx, keepCount) | |
| 2892 | + | } else { | |
| 2893 | + | r1 = ret.Error(1) | |
| 2894 | + | } | |
| 2895 | + | return r0, r1 | |
| 2896 | + | } | |
| 2897 | + | ||
| 2898 | + | // MockSessionRepository_DeleteOldestExited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteOldestExited' | |
| 2899 | + | type MockSessionRepository_DeleteOldestExited_Call struct { | |
| 2900 | + | *mock.Call | |
| 2901 | + | } | |
| 2902 | + | ||
| 2903 | + | // DeleteOldestExited is a helper method to define mock.On call | |
| 2904 | + | // - ctx context.Context | |
| 2905 | + | // - keepCount int | |
| 2906 | + | func (_e *MockSessionRepository_Expecter) DeleteOldestExited(ctx interface{}, keepCount interface{}) *MockSessionRepository_DeleteOldestExited_Call { | |
| 2907 | + | return &MockSessionRepository_DeleteOldestExited_Call{Call: _e.mock.On("DeleteOldestExited", ctx, keepCount)} | |
| 2908 | + | } | |
| 2909 | + | ||
| 2910 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) Run(run func(ctx context.Context, keepCount int)) *MockSessionRepository_DeleteOldestExited_Call { | |
| 2911 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2912 | + | var arg0 context.Context | |
| 2913 | + | if args[0] != nil { | |
| 2914 | + | arg0 = args[0].(context.Context) | |
| 2915 | + | } | |
| 2916 | + | var arg1 int | |
| 2917 | + | if args[1] != nil { | |
| 2918 | + | arg1 = args[1].(int) | |
| 2919 | + | } | |
| 2920 | + | run( | |
| 2921 | + | arg0, | |
| 2922 | + | arg1, | |
| 2923 | + | ) | |
| 2924 | + | }) | |
| 2925 | + | return _c | |
| 2926 | + | } | |
| 2927 | + | ||
| 2928 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) Return(n int64, err error) *MockSessionRepository_DeleteOldestExited_Call { | |
| 2929 | + | _c.Call.Return(n, err) | |
| 2930 | + | return _c | |
| 2931 | + | } | |
| 2932 | + | ||
| 2933 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) RunAndReturn(run func(ctx context.Context, keepCount int) (int64, error)) *MockSessionRepository_DeleteOldestExited_Call { | |
| 2934 | + | _c.Call.Return(run) | |
| 2935 | + | return _c | |
| 2936 | + | } | |
| 2937 | + | ||
| 2806 | 2938 | // FindByID provides a mock function for the type MockSessionRepository | |
| 2807 | 2939 | func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) { | |
| 2808 | 2940 | ret := _mock.Called(ctx, id) | |
| @@ -2803,6 +2803,138 @@ func (_c *MockSessionRepository_Delete_Call) RunAndReturn(run func(ctx context.C | |||||
| 2803 | return _c | 2803 | return _c | ||
| 2804 | } | 2804 | } | ||
| 2805 | 2805 | ||||
| 2806 | + | // DeleteExitedBefore provides a mock function for the type MockSessionRepository | |||
| 2807 | + | func (_mock *MockSessionRepository) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) { | |||
| 2808 | + | ret := _mock.Called(ctx, cutoff) | |||
| 2809 | + | ||||
| 2810 | + | if len(ret) == 0 { | |||
| 2811 | + | panic("no return value specified for DeleteExitedBefore") | |||
| 2812 | + | } | |||
| 2813 | + | ||||
| 2814 | + | var r0 int64 | |||
| 2815 | + | var r1 error | |||
| 2816 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (int64, error)); ok { | |||
| 2817 | + | return returnFunc(ctx, cutoff) | |||
| 2818 | + | } | |||
| 2819 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) int64); ok { | |||
| 2820 | + | r0 = returnFunc(ctx, cutoff) | |||
| 2821 | + | } else { | |||
| 2822 | + | r0 = ret.Get(0).(int64) | |||
| 2823 | + | } | |||
| 2824 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok { | |||
| 2825 | + | r1 = returnFunc(ctx, cutoff) | |||
| 2826 | + | } else { | |||
| 2827 | + | r1 = ret.Error(1) | |||
| 2828 | + | } | |||
| 2829 | + | return r0, r1 | |||
| 2830 | + | } | |||
| 2831 | + | ||||
| 2832 | + | // MockSessionRepository_DeleteExitedBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteExitedBefore' | |||
| 2833 | + | type MockSessionRepository_DeleteExitedBefore_Call struct { | |||
| 2834 | + | *mock.Call | |||
| 2835 | + | } | |||
| 2836 | + | ||||
| 2837 | + | // DeleteExitedBefore is a helper method to define mock.On call | |||
| 2838 | + | // - ctx context.Context | |||
| 2839 | + | // - cutoff time.Time | |||
| 2840 | + | func (_e *MockSessionRepository_Expecter) DeleteExitedBefore(ctx interface{}, cutoff interface{}) *MockSessionRepository_DeleteExitedBefore_Call { | |||
| 2841 | + | return &MockSessionRepository_DeleteExitedBefore_Call{Call: _e.mock.On("DeleteExitedBefore", ctx, cutoff)} | |||
| 2842 | + | } | |||
| 2843 | + | ||||
| 2844 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) Run(run func(ctx context.Context, cutoff time.Time)) *MockSessionRepository_DeleteExitedBefore_Call { | |||
| 2845 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2846 | + | var arg0 context.Context | |||
| 2847 | + | if args[0] != nil { | |||
| 2848 | + | arg0 = args[0].(context.Context) | |||
| 2849 | + | } | |||
| 2850 | + | var arg1 time.Time | |||
| 2851 | + | if args[1] != nil { | |||
| 2852 | + | arg1 = args[1].(time.Time) | |||
| 2853 | + | } | |||
| 2854 | + | run( | |||
| 2855 | + | arg0, | |||
| 2856 | + | arg1, | |||
| 2857 | + | ) | |||
| 2858 | + | }) | |||
| 2859 | + | return _c | |||
| 2860 | + | } | |||
| 2861 | + | ||||
| 2862 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) Return(n int64, err error) *MockSessionRepository_DeleteExitedBefore_Call { | |||
| 2863 | + | _c.Call.Return(n, err) | |||
| 2864 | + | return _c | |||
| 2865 | + | } | |||
| 2866 | + | ||||
| 2867 | + | func (_c *MockSessionRepository_DeleteExitedBefore_Call) RunAndReturn(run func(ctx context.Context, cutoff time.Time) (int64, error)) *MockSessionRepository_DeleteExitedBefore_Call { | |||
| 2868 | + | _c.Call.Return(run) | |||
| 2869 | + | return _c | |||
| 2870 | + | } | |||
| 2871 | + | ||||
| 2872 | + | // DeleteOldestExited provides a mock function for the type MockSessionRepository | |||
| 2873 | + | func (_mock *MockSessionRepository) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) { | |||
| 2874 | + | ret := _mock.Called(ctx, keepCount) | |||
| 2875 | + | ||||
| 2876 | + | if len(ret) == 0 { | |||
| 2877 | + | panic("no return value specified for DeleteOldestExited") | |||
| 2878 | + | } | |||
| 2879 | + | ||||
| 2880 | + | var r0 int64 | |||
| 2881 | + | var r1 error | |||
| 2882 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) (int64, error)); ok { | |||
| 2883 | + | return returnFunc(ctx, keepCount) | |||
| 2884 | + | } | |||
| 2885 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) int64); ok { | |||
| 2886 | + | r0 = returnFunc(ctx, keepCount) | |||
| 2887 | + | } else { | |||
| 2888 | + | r0 = ret.Get(0).(int64) | |||
| 2889 | + | } | |||
| 2890 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |||
| 2891 | + | r1 = returnFunc(ctx, keepCount) | |||
| 2892 | + | } else { | |||
| 2893 | + | r1 = ret.Error(1) | |||
| 2894 | + | } | |||
| 2895 | + | return r0, r1 | |||
| 2896 | + | } | |||
| 2897 | + | ||||
| 2898 | + | // MockSessionRepository_DeleteOldestExited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteOldestExited' | |||
| 2899 | + | type MockSessionRepository_DeleteOldestExited_Call struct { | |||
| 2900 | + | *mock.Call | |||
| 2901 | + | } | |||
| 2902 | + | ||||
| 2903 | + | // DeleteOldestExited is a helper method to define mock.On call | |||
| 2904 | + | // - ctx context.Context | |||
| 2905 | + | // - keepCount int | |||
| 2906 | + | func (_e *MockSessionRepository_Expecter) DeleteOldestExited(ctx interface{}, keepCount interface{}) *MockSessionRepository_DeleteOldestExited_Call { | |||
| 2907 | + | return &MockSessionRepository_DeleteOldestExited_Call{Call: _e.mock.On("DeleteOldestExited", ctx, keepCount)} | |||
| 2908 | + | } | |||
| 2909 | + | ||||
| 2910 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) Run(run func(ctx context.Context, keepCount int)) *MockSessionRepository_DeleteOldestExited_Call { | |||
| 2911 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2912 | + | var arg0 context.Context | |||
| 2913 | + | if args[0] != nil { | |||
| 2914 | + | arg0 = args[0].(context.Context) | |||
| 2915 | + | } | |||
| 2916 | + | var arg1 int | |||
| 2917 | + | if args[1] != nil { | |||
| 2918 | + | arg1 = args[1].(int) | |||
| 2919 | + | } | |||
| 2920 | + | run( | |||
| 2921 | + | arg0, | |||
| 2922 | + | arg1, | |||
| 2923 | + | ) | |||
| 2924 | + | }) | |||
| 2925 | + | return _c | |||
| 2926 | + | } | |||
| 2927 | + | ||||
| 2928 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) Return(n int64, err error) *MockSessionRepository_DeleteOldestExited_Call { | |||
| 2929 | + | _c.Call.Return(n, err) | |||
| 2930 | + | return _c | |||
| 2931 | + | } | |||
| 2932 | + | ||||
| 2933 | + | func (_c *MockSessionRepository_DeleteOldestExited_Call) RunAndReturn(run func(ctx context.Context, keepCount int) (int64, error)) *MockSessionRepository_DeleteOldestExited_Call { | |||
| 2934 | + | _c.Call.Return(run) | |||
| 2935 | + | return _c | |||
| 2936 | + | } | |||
| 2937 | + | ||||
| 2806 | // FindByID provides a mock function for the type MockSessionRepository | 2938 | // FindByID provides a mock function for the type MockSessionRepository | ||
| 2807 | func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) { | 2939 | func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) { | ||
| 2808 | ret := _mock.Called(ctx, id) | 2940 | ret := _mock.Called(ctx, id) | ||
M internal/domain/repository/session.go +8 -0
| @@ -19,4 +19,12 @@ type SessionRepository interface { | |||
| 19 | 19 | ||
| 20 | 20 | // Delete removes a session record. | |
| 21 | 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) | |
| 22 | 30 | } | |
| @@ -19,4 +19,12 @@ type SessionRepository interface { | |||||
| 19 | 19 | ||||
| 20 | // Delete removes a session record. | 20 | // Delete removes a session record. | ||
| 21 | Delete(ctx context.Context, id entity.SessionID) error | 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) | |||
| 22 | } | 30 | } | ||
M internal/infrastructure/config/defaults.go +6 -6
| @@ -30,8 +30,8 @@ const ( | |||
| 30 | 30 | defaultSessionActivationShortcut = "ctrl+o" | |
| 31 | 31 | defaultSessionTimeoutMilliseconds = 3000 | |
| 32 | 32 | defaultSnapshotIntervalMs = 5000 | |
| 33 | - | defaultMaxExitedSessions = 10 | |
| 34 | - | defaultMaxListedSessions = 50 | |
| 33 | + | defaultMaxExitedSessions = 50 | |
| 34 | + | defaultMaxExitedSessionAgeDays = 7 | |
| 35 | 35 | ||
| 36 | 36 | // Workspace styling defaults | |
| 37 | 37 | // Active pane border (overlay) | |
| @@ -210,10 +210,10 @@ func DefaultConfig() *Config { | |||
| 210 | 210 | InitialBehavior: defaultOmniboxInitialBehavior, | |
| 211 | 211 | }, | |
| 212 | 212 | Session: SessionConfig{ | |
| 213 | - | AutoRestore: false, | |
| 214 | - | SnapshotIntervalMs: defaultSnapshotIntervalMs, | |
| 215 | - | MaxExitedSessions: defaultMaxExitedSessions, | |
| 216 | - | MaxListedSessions: defaultMaxListedSessions, | |
| 213 | + | AutoRestore: false, | |
| 214 | + | SnapshotIntervalMs: defaultSnapshotIntervalMs, | |
| 215 | + | MaxExitedSessions: defaultMaxExitedSessions, | |
| 216 | + | MaxExitedSessionAgeDays: defaultMaxExitedSessionAgeDays, | |
| 217 | 217 | SessionMode: SessionModeConfig{ | |
| 218 | 218 | ActivationShortcut: defaultSessionActivationShortcut, | |
| 219 | 219 | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | |
| @@ -30,8 +30,8 @@ const ( | |||||
| 30 | defaultSessionActivationShortcut = "ctrl+o" | 30 | defaultSessionActivationShortcut = "ctrl+o" | ||
| 31 | defaultSessionTimeoutMilliseconds = 3000 | 31 | defaultSessionTimeoutMilliseconds = 3000 | ||
| 32 | defaultSnapshotIntervalMs = 5000 | 32 | defaultSnapshotIntervalMs = 5000 | ||
| 33 | - | defaultMaxExitedSessions = 10 | 33 | + | defaultMaxExitedSessions = 50 |
| 34 | - | defaultMaxListedSessions = 50 | 34 | + | defaultMaxExitedSessionAgeDays = 7 |
| 35 | 35 | ||||
| 36 | // Workspace styling defaults | 36 | // Workspace styling defaults | ||
| 37 | // Active pane border (overlay) | 37 | // Active pane border (overlay) | ||
| @@ -210,10 +210,10 @@ func DefaultConfig() *Config { | |||||
| 210 | InitialBehavior: defaultOmniboxInitialBehavior, | 210 | InitialBehavior: defaultOmniboxInitialBehavior, | ||
| 211 | }, | 211 | }, | ||
| 212 | Session: SessionConfig{ | 212 | Session: SessionConfig{ | ||
| 213 | - | AutoRestore: false, | 213 | + | AutoRestore: false, |
| 214 | - | SnapshotIntervalMs: defaultSnapshotIntervalMs, | 214 | + | SnapshotIntervalMs: defaultSnapshotIntervalMs, |
| 215 | - | MaxExitedSessions: defaultMaxExitedSessions, | 215 | + | MaxExitedSessions: defaultMaxExitedSessions, |
| 216 | - | MaxListedSessions: defaultMaxListedSessions, | 216 | + | MaxExitedSessionAgeDays: defaultMaxExitedSessionAgeDays, |
| 217 | SessionMode: SessionModeConfig{ | 217 | SessionMode: SessionModeConfig{ | ||
| 218 | ActivationShortcut: defaultSessionActivationShortcut, | 218 | ActivationShortcut: defaultSessionActivationShortcut, | ||
| 219 | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | 219 | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | ||
M internal/infrastructure/config/loader.go +1 -1
| @@ -437,7 +437,7 @@ func (m *Manager) setSessionDefaults(defaults *Config) { | |||
| 437 | 437 | m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore) | |
| 438 | 438 | m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs) | |
| 439 | 439 | m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions) | |
| 440 | - | m.viper.SetDefault("session.max_listed_sessions", defaults.Session.MaxListedSessions) | |
| 440 | + | m.viper.SetDefault("session.max_exited_session_age_days", defaults.Session.MaxExitedSessionAgeDays) | |
| 441 | 441 | m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut) | |
| 442 | 442 | m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds) | |
| 443 | 443 | m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions) | |
| @@ -437,7 +437,7 @@ func (m *Manager) setSessionDefaults(defaults *Config) { | |||||
| 437 | m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore) | 437 | m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore) | ||
| 438 | m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs) | 438 | m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs) | ||
| 439 | m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions) | 439 | m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions) | ||
| 440 | - | m.viper.SetDefault("session.max_listed_sessions", defaults.Session.MaxListedSessions) | 440 | + | m.viper.SetDefault("session.max_exited_session_age_days", defaults.Session.MaxExitedSessionAgeDays) |
| 441 | m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut) | 441 | m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut) | ||
| 442 | m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds) | 442 | m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds) | ||
| 443 | m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions) | 443 | m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions) | ||
M internal/infrastructure/config/schema.go +5 -4
| @@ -437,12 +437,13 @@ type SessionConfig struct { | |||
| 437 | 437 | 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 | |
| 438 | 438 | ||
| 439 | 439 | // MaxExitedSessions is the maximum number of exited sessions to keep. | |
| 440 | - | // Default: 10 | |
| 440 | + | // Default: 50 | |
| 441 | 441 | 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 | |
| 442 | 442 | ||
| 443 | - | // MaxListedSessions is the maximum number of sessions to list in CLI/TUI. | |
| 444 | - | // Default: 50 | |
| 445 | - | MaxListedSessions int `mapstructure:"max_listed_sessions" yaml:"max_listed_sessions" toml:"max_listed_sessions" json:"max_listed_sessions"` //nolint:lll // struct tags must stay on one line | |
| 443 | + | // MaxExitedSessionAgeDays is the maximum age in days for exited sessions. | |
| 444 | + | // Sessions older than this will be automatically deleted on startup. | |
| 445 | + | // Default: 7 | |
| 446 | + | 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 | |
| 446 | 447 | ||
| 447 | 448 | // SessionMode defines modal behavior for session management (Ctrl+O). | |
| 448 | 449 | SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"` | |
| @@ -437,12 +437,13 @@ type SessionConfig struct { | |||||
| 437 | 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 | 437 | 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 | ||
| 438 | 438 | ||||
| 439 | // MaxExitedSessions is the maximum number of exited sessions to keep. | 439 | // MaxExitedSessions is the maximum number of exited sessions to keep. | ||
| 440 | - | // Default: 10 | 440 | + | // Default: 50 |
| 441 | 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 | 441 | 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 | ||
| 442 | 442 | ||||
| 443 | - | // MaxListedSessions is the maximum number of sessions to list in CLI/TUI. | 443 | + | // MaxExitedSessionAgeDays is the maximum age in days for exited sessions. |
| 444 | - | // Default: 50 | 444 | + | // Sessions older than this will be automatically deleted on startup. |
| 445 | - | MaxListedSessions int `mapstructure:"max_listed_sessions" yaml:"max_listed_sessions" toml:"max_listed_sessions" json:"max_listed_sessions"` //nolint:lll // struct tags must stay on one line | 445 | + | // Default: 7 |
| 446 | + | 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 | |||
| 446 | 447 | ||||
| 447 | // SessionMode defines modal behavior for session management (Ctrl+O). | 448 | // SessionMode defines modal behavior for session management (Ctrl+O). | ||
| 448 | SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"` | 449 | SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"` | ||
M internal/infrastructure/config/validation.go +15 -0
| @@ -24,6 +24,7 @@ func validateConfig(config *Config) error { | |||
| 24 | 24 | validationErrors = append(validationErrors, validateOmnibox(config)...) | |
| 25 | 25 | validationErrors = append(validationErrors, validateRendering(config)...) | |
| 26 | 26 | validationErrors = append(validationErrors, validateColorScheme(config)...) | |
| 27 | + | validationErrors = append(validationErrors, validateSession(config)...) | |
| 27 | 28 | ||
| 28 | 29 | // If there are validation errors, return them | |
| 29 | 30 | if len(validationErrors) > 0 { | |
| @@ -301,3 +302,17 @@ func validateColorScheme(config *Config) []string { | |||
| 301 | 302 | )} | |
| 302 | 303 | } | |
| 303 | 304 | } | |
| 305 | + | ||
| 306 | + | func validateSession(config *Config) []string { | |
| 307 | + | var validationErrors []string | |
| 308 | + | if config.Session.MaxExitedSessions < 0 { | |
| 309 | + | validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative") | |
| 310 | + | } | |
| 311 | + | if config.Session.MaxExitedSessionAgeDays < 0 { | |
| 312 | + | validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative") | |
| 313 | + | } | |
| 314 | + | if config.Session.SnapshotIntervalMs < 0 { | |
| 315 | + | validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative") | |
| 316 | + | } | |
| 317 | + | return validationErrors | |
| 318 | + | } | |
| @@ -24,6 +24,7 @@ func validateConfig(config *Config) error { | |||||
| 24 | validationErrors = append(validationErrors, validateOmnibox(config)...) | 24 | validationErrors = append(validationErrors, validateOmnibox(config)...) | ||
| 25 | validationErrors = append(validationErrors, validateRendering(config)...) | 25 | validationErrors = append(validationErrors, validateRendering(config)...) | ||
| 26 | validationErrors = append(validationErrors, validateColorScheme(config)...) | 26 | validationErrors = append(validationErrors, validateColorScheme(config)...) | ||
| 27 | + | validationErrors = append(validationErrors, validateSession(config)...) | |||
| 27 | 28 | ||||
| 28 | // If there are validation errors, return them | 29 | // If there are validation errors, return them | ||
| 29 | if len(validationErrors) > 0 { | 30 | if len(validationErrors) > 0 { | ||
| @@ -301,3 +302,17 @@ func validateColorScheme(config *Config) []string { | |||||
| 301 | )} | 302 | )} | ||
| 302 | } | 303 | } | ||
| 303 | } | 304 | } | ||
| 305 | + | ||||
| 306 | + | func validateSession(config *Config) []string { | |||
| 307 | + | var validationErrors []string | |||
| 308 | + | if config.Session.MaxExitedSessions < 0 { | |||
| 309 | + | validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative") | |||
| 310 | + | } | |||
| 311 | + | if config.Session.MaxExitedSessionAgeDays < 0 { | |||
| 312 | + | validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative") | |||
| 313 | + | } | |||
| 314 | + | if config.Session.SnapshotIntervalMs < 0 { | |||
| 315 | + | validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative") | |||
| 316 | + | } | |||
| 317 | + | return validationErrors | |||
| 318 | + | } | |||
M internal/infrastructure/persistence/sqlite/queries/sessions.sql +17 -0
| @@ -23,3 +23,20 @@ WHERE id = ?; | |||
| 23 | 23 | ||
| 24 | 24 | -- name: DeleteSession :exec | |
| 25 | 25 | DELETE FROM sessions WHERE id = ?; | |
| 26 | + | ||
| 27 | + | -- name: DeleteOldestExitedSessions :execrows | |
| 28 | + | -- Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |
| 29 | + | DELETE FROM sessions | |
| 30 | + | WHERE id IN ( | |
| 31 | + | SELECT id FROM sessions | |
| 32 | + | WHERE type = 'browser' AND ended_at IS NOT NULL | |
| 33 | + | ORDER BY ended_at DESC | |
| 34 | + | LIMIT -1 OFFSET ? | |
| 35 | + | ); | |
| 36 | + | ||
| 37 | + | -- name: DeleteExitedSessionsBefore :execrows | |
| 38 | + | -- Deletes exited browser sessions older than the given cutoff time. | |
| 39 | + | DELETE FROM sessions | |
| 40 | + | WHERE type = 'browser' | |
| 41 | + | AND ended_at IS NOT NULL | |
| 42 | + | AND ended_at < ?; | |
| @@ -23,3 +23,20 @@ WHERE id = ?; | |||||
| 23 | 23 | ||||
| 24 | -- name: DeleteSession :exec | 24 | -- name: DeleteSession :exec | ||
| 25 | DELETE FROM sessions WHERE id = ?; | 25 | DELETE FROM sessions WHERE id = ?; | ||
| 26 | + | ||||
| 27 | + | -- name: DeleteOldestExitedSessions :execrows | |||
| 28 | + | -- Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |||
| 29 | + | DELETE FROM sessions | |||
| 30 | + | WHERE id IN ( | |||
| 31 | + | SELECT id FROM sessions | |||
| 32 | + | WHERE type = 'browser' AND ended_at IS NOT NULL | |||
| 33 | + | ORDER BY ended_at DESC | |||
| 34 | + | LIMIT -1 OFFSET ? | |||
| 35 | + | ); | |||
| 36 | + | ||||
| 37 | + | -- name: DeleteExitedSessionsBefore :execrows | |||
| 38 | + | -- Deletes exited browser sessions older than the given cutoff time. | |||
| 39 | + | DELETE FROM sessions | |||
| 40 | + | WHERE type = 'browser' | |||
| 41 | + | AND ended_at IS NOT NULL | |||
| 42 | + | AND ended_at < ?; | |||
M internal/infrastructure/persistence/sqlite/session_repo.go +27 -0
| @@ -93,6 +93,33 @@ func (r *sessionRepo) Delete(ctx context.Context, id entity.SessionID) error { | |||
| 93 | 93 | return r.queries.DeleteSession(ctx, string(id)) | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | + | func (r *sessionRepo) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) { | |
| 97 | + | log := logging.FromContext(ctx) | |
| 98 | + | if keepCount < 0 { | |
| 99 | + | keepCount = 0 | |
| 100 | + | } | |
| 101 | + | deleted, err := r.queries.DeleteOldestExitedSessions(ctx, int64(keepCount)) | |
| 102 | + | if err != nil { | |
| 103 | + | return 0, err | |
| 104 | + | } | |
| 105 | + | if deleted > 0 { | |
| 106 | + | log.Debug().Int64("deleted", deleted).Int("keep_count", keepCount).Msg("deleted oldest exited sessions") | |
| 107 | + | } | |
| 108 | + | return deleted, nil | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (r *sessionRepo) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) { | |
| 112 | + | log := logging.FromContext(ctx) | |
| 113 | + | deleted, err := r.queries.DeleteExitedSessionsBefore(ctx, sql.NullTime{Time: cutoff.UTC(), Valid: true}) | |
| 114 | + | if err != nil { | |
| 115 | + | return 0, err | |
| 116 | + | } | |
| 117 | + | if deleted > 0 { | |
| 118 | + | log.Debug().Int64("deleted", deleted).Time("cutoff", cutoff).Msg("deleted exited sessions before cutoff") | |
| 119 | + | } | |
| 120 | + | return deleted, nil | |
| 121 | + | } | |
| 122 | + | ||
| 96 | 123 | func sessionFromRow(row sqlc.Session) *entity.Session { | |
| 97 | 124 | var endedAt *time.Time | |
| 98 | 125 | if row.EndedAt.Valid { | |
| @@ -93,6 +93,33 @@ func (r *sessionRepo) Delete(ctx context.Context, id entity.SessionID) error { | |||||
| 93 | return r.queries.DeleteSession(ctx, string(id)) | 93 | return r.queries.DeleteSession(ctx, string(id)) | ||
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | + | func (r *sessionRepo) DeleteOldestExited(ctx context.Context, keepCount int) (int64, error) { | |||
| 97 | + | log := logging.FromContext(ctx) | |||
| 98 | + | if keepCount < 0 { | |||
| 99 | + | keepCount = 0 | |||
| 100 | + | } | |||
| 101 | + | deleted, err := r.queries.DeleteOldestExitedSessions(ctx, int64(keepCount)) | |||
| 102 | + | if err != nil { | |||
| 103 | + | return 0, err | |||
| 104 | + | } | |||
| 105 | + | if deleted > 0 { | |||
| 106 | + | log.Debug().Int64("deleted", deleted).Int("keep_count", keepCount).Msg("deleted oldest exited sessions") | |||
| 107 | + | } | |||
| 108 | + | return deleted, nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (r *sessionRepo) DeleteExitedBefore(ctx context.Context, cutoff time.Time) (int64, error) { | |||
| 112 | + | log := logging.FromContext(ctx) | |||
| 113 | + | deleted, err := r.queries.DeleteExitedSessionsBefore(ctx, sql.NullTime{Time: cutoff.UTC(), Valid: true}) | |||
| 114 | + | if err != nil { | |||
| 115 | + | return 0, err | |||
| 116 | + | } | |||
| 117 | + | if deleted > 0 { | |||
| 118 | + | log.Debug().Int64("deleted", deleted).Time("cutoff", cutoff).Msg("deleted exited sessions before cutoff") | |||
| 119 | + | } | |||
| 120 | + | return deleted, nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 96 | func sessionFromRow(row sqlc.Session) *entity.Session { | 123 | func sessionFromRow(row sqlc.Session) *entity.Session { | ||
| 97 | var endedAt *time.Time | 124 | var endedAt *time.Time | ||
| 98 | if row.EndedAt.Valid { | 125 | if row.EndedAt.Valid { | ||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +4 -0
| @@ -16,11 +16,15 @@ type Querier interface { | |||
| 16 | 16 | CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error) | |
| 17 | 17 | CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error) | |
| 18 | 18 | DeleteAllHistory(ctx context.Context) error | |
| 19 | + | // Deletes exited browser sessions older than the given cutoff time. | |
| 20 | + | DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) | |
| 19 | 21 | DeleteFavorite(ctx context.Context, id int64) error | |
| 20 | 22 | DeleteFolder(ctx context.Context, id int64) error | |
| 21 | 23 | DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error | |
| 22 | 24 | DeleteHistoryByID(ctx context.Context, id int64) error | |
| 23 | 25 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | |
| 26 | + | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |
| 27 | + | DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) | |
| 24 | 28 | DeleteSession(ctx context.Context, id string) error | |
| 25 | 29 | DeleteSessionState(ctx context.Context, sessionID string) error | |
| 26 | 30 | DeleteTag(ctx context.Context, id int64) error | |
| @@ -16,11 +16,15 @@ type Querier interface { | |||||
| 16 | CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error) | 16 | CreateFolder(ctx context.Context, arg CreateFolderParams) (FavoriteFolder, error) | ||
| 17 | CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error) | 17 | CreateTag(ctx context.Context, arg CreateTagParams) (FavoriteTag, error) | ||
| 18 | DeleteAllHistory(ctx context.Context) error | 18 | DeleteAllHistory(ctx context.Context) error | ||
| 19 | + | // Deletes exited browser sessions older than the given cutoff time. | |||
| 20 | + | DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) | |||
| 19 | DeleteFavorite(ctx context.Context, id int64) error | 21 | DeleteFavorite(ctx context.Context, id int64) error | ||
| 20 | DeleteFolder(ctx context.Context, id int64) error | 22 | DeleteFolder(ctx context.Context, id int64) error | ||
| 21 | DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error | 23 | DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error | ||
| 22 | DeleteHistoryByID(ctx context.Context, id int64) error | 24 | DeleteHistoryByID(ctx context.Context, id int64) error | ||
| 23 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | 25 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | ||
| 26 | + | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |||
| 27 | + | DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) | |||
| 24 | DeleteSession(ctx context.Context, id string) error | 28 | DeleteSession(ctx context.Context, id string) error | ||
| 25 | DeleteSessionState(ctx context.Context, sessionID string) error | 29 | DeleteSessionState(ctx context.Context, sessionID string) error | ||
| 26 | DeleteTag(ctx context.Context, id int64) error | 30 | DeleteTag(ctx context.Context, id int64) error | ||
M internal/infrastructure/persistence/sqlite/sqlc/sessions.sql.go +35 -0
| @@ -11,6 +11,41 @@ import ( | |||
| 11 | 11 | "time" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | + | const DeleteExitedSessionsBefore = `-- name: DeleteExitedSessionsBefore :execrows | |
| 15 | + | DELETE FROM sessions | |
| 16 | + | WHERE type = 'browser' | |
| 17 | + | AND ended_at IS NOT NULL | |
| 18 | + | AND ended_at < ? | |
| 19 | + | ` | |
| 20 | + | ||
| 21 | + | // Deletes exited browser sessions older than the given cutoff time. | |
| 22 | + | func (q *Queries) DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) { | |
| 23 | + | result, err := q.db.ExecContext(ctx, DeleteExitedSessionsBefore, endedAt) | |
| 24 | + | if err != nil { | |
| 25 | + | return 0, err | |
| 26 | + | } | |
| 27 | + | return result.RowsAffected() | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | const DeleteOldestExitedSessions = `-- name: DeleteOldestExitedSessions :execrows | |
| 31 | + | DELETE FROM sessions | |
| 32 | + | WHERE id IN ( | |
| 33 | + | SELECT id FROM sessions | |
| 34 | + | WHERE type = 'browser' AND ended_at IS NOT NULL | |
| 35 | + | ORDER BY ended_at DESC | |
| 36 | + | LIMIT -1 OFFSET ? | |
| 37 | + | ) | |
| 38 | + | ` | |
| 39 | + | ||
| 40 | + | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |
| 41 | + | func (q *Queries) DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) { | |
| 42 | + | result, err := q.db.ExecContext(ctx, DeleteOldestExitedSessions, offset) | |
| 43 | + | if err != nil { | |
| 44 | + | return 0, err | |
| 45 | + | } | |
| 46 | + | return result.RowsAffected() | |
| 47 | + | } | |
| 48 | + | ||
| 14 | 49 | const DeleteSession = `-- name: DeleteSession :exec | |
| 15 | 50 | DELETE FROM sessions WHERE id = ? | |
| 16 | 51 | ` | |
| @@ -11,6 +11,41 @@ import ( | |||||
| 11 | "time" | 11 | "time" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | + | const DeleteExitedSessionsBefore = `-- name: DeleteExitedSessionsBefore :execrows | |||
| 15 | + | DELETE FROM sessions | |||
| 16 | + | WHERE type = 'browser' | |||
| 17 | + | AND ended_at IS NOT NULL | |||
| 18 | + | AND ended_at < ? | |||
| 19 | + | ` | |||
| 20 | + | ||||
| 21 | + | // Deletes exited browser sessions older than the given cutoff time. | |||
| 22 | + | func (q *Queries) DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) { | |||
| 23 | + | result, err := q.db.ExecContext(ctx, DeleteExitedSessionsBefore, endedAt) | |||
| 24 | + | if err != nil { | |||
| 25 | + | return 0, err | |||
| 26 | + | } | |||
| 27 | + | return result.RowsAffected() | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | const DeleteOldestExitedSessions = `-- name: DeleteOldestExitedSessions :execrows | |||
| 31 | + | DELETE FROM sessions | |||
| 32 | + | WHERE id IN ( | |||
| 33 | + | SELECT id FROM sessions | |||
| 34 | + | WHERE type = 'browser' AND ended_at IS NOT NULL | |||
| 35 | + | ORDER BY ended_at DESC | |||
| 36 | + | LIMIT -1 OFFSET ? | |||
| 37 | + | ) | |||
| 38 | + | ` | |||
| 39 | + | ||||
| 40 | + | // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones. | |||
| 41 | + | func (q *Queries) DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error) { | |||
| 42 | + | result, err := q.db.ExecContext(ctx, DeleteOldestExitedSessions, offset) | |||
| 43 | + | if err != nil { | |||
| 44 | + | return 0, err | |||
| 45 | + | } | |||
| 46 | + | return result.RowsAffected() | |||
| 47 | + | } | |||
| 48 | + | ||||
| 14 | const DeleteSession = `-- name: DeleteSession :exec | 49 | const DeleteSession = `-- name: DeleteSession :exec | ||
| 15 | DELETE FROM sessions WHERE id = ? | 50 | DELETE FROM sessions WHERE id = ? | ||
| 16 | ` | 51 | ` | ||
M internal/ui/app.go +33 -0
| @@ -796,6 +796,11 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 796 | 796 | a.navCoord.RecordHistory(ctx, paneID, url) | |
| 797 | 797 | }) | |
| 798 | 798 | ||
| 799 | + | // Wire window title updates when active pane's title changes | |
| 800 | + | a.contentCoord.SetOnWindowTitleChanged(func(title string) { | |
| 801 | + | a.updateWindowTitle(title) | |
| 802 | + | }) | |
| 803 | + | ||
| 799 | 804 | // Wire pane URI updates for session snapshots (searches all tabs) | |
| 800 | 805 | a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) { | |
| 801 | 806 | a.updatePaneURIInAllTabs(paneID, url) | |
| @@ -875,6 +880,31 @@ func RunWithArgs(ctx context.Context, deps *Dependencies) int { | |||
| 875 | 880 | return app.Run(ctx, os.Args) | |
| 876 | 881 | } | |
| 877 | 882 | ||
| 883 | + | // updateWindowTitle updates the window title with the given page title. | |
| 884 | + | // Format: "<Page Title> - Dumber" or just "Dumber" if title is empty. | |
| 885 | + | func (a *App) updateWindowTitle(pageTitle string) { | |
| 886 | + | if a.mainWindow == nil { | |
| 887 | + | return | |
| 888 | + | } | |
| 889 | + | ||
| 890 | + | title := "Dumber" | |
| 891 | + | if pageTitle != "" { | |
| 892 | + | title = pageTitle + " - Dumber" | |
| 893 | + | } | |
| 894 | + | a.mainWindow.SetTitle(title) | |
| 895 | + | } | |
| 896 | + | ||
| 897 | + | // updateWindowTitleFromActivePane updates the window title based on the current active pane. | |
| 898 | + | func (a *App) updateWindowTitleFromActivePane() { | |
| 899 | + | ws := a.activeWorkspace() | |
| 900 | + | if ws == nil || a.contentCoord == nil { | |
| 901 | + | a.updateWindowTitle("") | |
| 902 | + | return | |
| 903 | + | } | |
| 904 | + | title := a.contentCoord.GetTitle(ws.ActivePaneID) | |
| 905 | + | a.updateWindowTitle(title) | |
| 906 | + | } | |
| 907 | + | ||
| 878 | 908 | // handleModeChange is called when the input mode changes. | |
| 879 | 909 | func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |
| 880 | 910 | log := logging.FromContext(ctx) | |
| @@ -1043,6 +1073,9 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | |||
| 1043 | 1073 | a.mainWindow.SetContent(gtkWidget) | |
| 1044 | 1074 | } | |
| 1045 | 1075 | ||
| 1076 | + | // Update window title with the new active pane's title | |
| 1077 | + | a.updateWindowTitleFromActivePane() | |
| 1078 | + | ||
| 1046 | 1079 | log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched") | |
| 1047 | 1080 | } | |
| 1048 | 1081 | ||
| @@ -796,6 +796,11 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 796 | a.navCoord.RecordHistory(ctx, paneID, url) | 796 | a.navCoord.RecordHistory(ctx, paneID, url) | ||
| 797 | }) | 797 | }) | ||
| 798 | 798 | ||||
| 799 | + | // Wire window title updates when active pane's title changes | |||
| 800 | + | a.contentCoord.SetOnWindowTitleChanged(func(title string) { | |||
| 801 | + | a.updateWindowTitle(title) | |||
| 802 | + | }) | |||
| 803 | + | ||||
| 799 | // Wire pane URI updates for session snapshots (searches all tabs) | 804 | // Wire pane URI updates for session snapshots (searches all tabs) | ||
| 800 | a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) { | 805 | a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) { | ||
| 801 | a.updatePaneURIInAllTabs(paneID, url) | 806 | a.updatePaneURIInAllTabs(paneID, url) | ||
| @@ -875,6 +880,31 @@ func RunWithArgs(ctx context.Context, deps *Dependencies) int { | |||||
| 875 | return app.Run(ctx, os.Args) | 880 | return app.Run(ctx, os.Args) | ||
| 876 | } | 881 | } | ||
| 877 | 882 | ||||
| 883 | + | // updateWindowTitle updates the window title with the given page title. | |||
| 884 | + | // Format: "<Page Title> - Dumber" or just "Dumber" if title is empty. | |||
| 885 | + | func (a *App) updateWindowTitle(pageTitle string) { | |||
| 886 | + | if a.mainWindow == nil { | |||
| 887 | + | return | |||
| 888 | + | } | |||
| 889 | + | ||||
| 890 | + | title := "Dumber" | |||
| 891 | + | if pageTitle != "" { | |||
| 892 | + | title = pageTitle + " - Dumber" | |||
| 893 | + | } | |||
| 894 | + | a.mainWindow.SetTitle(title) | |||
| 895 | + | } | |||
| 896 | + | ||||
| 897 | + | // updateWindowTitleFromActivePane updates the window title based on the current active pane. | |||
| 898 | + | func (a *App) updateWindowTitleFromActivePane() { | |||
| 899 | + | ws := a.activeWorkspace() | |||
| 900 | + | if ws == nil || a.contentCoord == nil { | |||
| 901 | + | a.updateWindowTitle("") | |||
| 902 | + | return | |||
| 903 | + | } | |||
| 904 | + | title := a.contentCoord.GetTitle(ws.ActivePaneID) | |||
| 905 | + | a.updateWindowTitle(title) | |||
| 906 | + | } | |||
| 907 | + | ||||
| 878 | // handleModeChange is called when the input mode changes. | 908 | // handleModeChange is called when the input mode changes. | ||
| 879 | func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | 909 | func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | ||
| 880 | log := logging.FromContext(ctx) | 910 | log := logging.FromContext(ctx) | ||
| @@ -1043,6 +1073,9 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | |||||
| 1043 | a.mainWindow.SetContent(gtkWidget) | 1073 | a.mainWindow.SetContent(gtkWidget) | ||
| 1044 | } | 1074 | } | ||
| 1045 | 1075 | ||||
| 1076 | + | // Update window title with the new active pane's title | |||
| 1077 | + | a.updateWindowTitleFromActivePane() | |||
| 1078 | + | ||||
| 1046 | log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched") | 1079 | log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched") | ||
| 1047 | } | 1080 | } | ||
| 1048 | 1081 | ||||
M internal/ui/coordinator/content.go +19 -1
| @@ -49,6 +49,9 @@ type ContentCoordinator struct { | |||
| 49 | 49 | // Callback when pane URI changes (for session snapshots) | |
| 50 | 50 | onPaneURIUpdated func(paneID entity.PaneID, url string) | |
| 51 | 51 | ||
| 52 | + | // Callback when active pane title changes (for window title updates) | |
| 53 | + | onWindowTitleChanged func(title string) | |
| 54 | + | ||
| 52 | 55 | // Gesture action handler for mouse button navigation | |
| 53 | 56 | gestureActionHandler input.ActionHandler | |
| 54 | 57 | ||
| @@ -111,6 +114,11 @@ func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, u | |||
| 111 | 114 | c.onPaneURIUpdated = fn | |
| 112 | 115 | } | |
| 113 | 116 | ||
| 117 | + | // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates). | |
| 118 | + | func (c *ContentCoordinator) SetOnWindowTitleChanged(fn func(title string)) { | |
| 119 | + | c.onWindowTitleChanged = fn | |
| 120 | + | } | |
| 121 | + | ||
| 114 | 122 | // SetGestureActionHandler sets the callback for mouse button navigation gestures. | |
| 115 | 123 | func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) { | |
| 116 | 124 | c.gestureActionHandler = handler | |
| @@ -483,13 +491,18 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P | |||
| 483 | 491 | c.paneTitles[paneID] = title | |
| 484 | 492 | c.titleMu.Unlock() | |
| 485 | 493 | ||
| 486 | - | // Update domain model | |
| 494 | + | // Update domain model and check if this is the active pane | |
| 495 | + | isActivePaneTitle := false | |
| 487 | 496 | ws, wsView := c.getActiveWS() | |
| 488 | 497 | if ws != nil { | |
| 489 | 498 | paneNode := ws.FindPane(paneID) | |
| 490 | 499 | if paneNode != nil && paneNode.Pane != nil { | |
| 491 | 500 | paneNode.Pane.Title = title | |
| 492 | 501 | } | |
| 502 | + | // Check if this pane is the active one | |
| 503 | + | if ws.ActivePaneID == paneID { | |
| 504 | + | isActivePaneTitle = true | |
| 505 | + | } | |
| 493 | 506 | } | |
| 494 | 507 | ||
| 495 | 508 | // Update StackedView title bar if this pane is in a stack | |
| @@ -513,6 +526,11 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P | |||
| 513 | 526 | } | |
| 514 | 527 | } | |
| 515 | 528 | ||
| 529 | + | // Notify window title update if this is the active pane | |
| 530 | + | if isActivePaneTitle && c.onWindowTitleChanged != nil { | |
| 531 | + | c.onWindowTitleChanged(title) | |
| 532 | + | } | |
| 533 | + | ||
| 516 | 534 | log.Debug(). | |
| 517 | 535 | Str("pane_id", string(paneID)). | |
| 518 | 536 | Str("title", title). | |
| @@ -49,6 +49,9 @@ type ContentCoordinator struct { | |||||
| 49 | // Callback when pane URI changes (for session snapshots) | 49 | // Callback when pane URI changes (for session snapshots) | ||
| 50 | onPaneURIUpdated func(paneID entity.PaneID, url string) | 50 | onPaneURIUpdated func(paneID entity.PaneID, url string) | ||
| 51 | 51 | ||||
| 52 | + | // Callback when active pane title changes (for window title updates) | |||
| 53 | + | onWindowTitleChanged func(title string) | |||
| 54 | + | ||||
| 52 | // Gesture action handler for mouse button navigation | 55 | // Gesture action handler for mouse button navigation | ||
| 53 | gestureActionHandler input.ActionHandler | 56 | gestureActionHandler input.ActionHandler | ||
| 54 | 57 | ||||
| @@ -111,6 +114,11 @@ func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, u | |||||
| 111 | c.onPaneURIUpdated = fn | 114 | c.onPaneURIUpdated = fn | ||
| 112 | } | 115 | } | ||
| 113 | 116 | ||||
| 117 | + | // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates). | |||
| 118 | + | func (c *ContentCoordinator) SetOnWindowTitleChanged(fn func(title string)) { | |||
| 119 | + | c.onWindowTitleChanged = fn | |||
| 120 | + | } | |||
| 121 | + | ||||
| 114 | // SetGestureActionHandler sets the callback for mouse button navigation gestures. | 122 | // SetGestureActionHandler sets the callback for mouse button navigation gestures. | ||
| 115 | func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) { | 123 | func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) { | ||
| 116 | c.gestureActionHandler = handler | 124 | c.gestureActionHandler = handler | ||
| @@ -483,13 +491,18 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P | |||||
| 483 | c.paneTitles[paneID] = title | 491 | c.paneTitles[paneID] = title | ||
| 484 | c.titleMu.Unlock() | 492 | c.titleMu.Unlock() | ||
| 485 | 493 | ||||
| 486 | - | // Update domain model | 494 | + | // Update domain model and check if this is the active pane |
| 495 | + | isActivePaneTitle := false | |||
| 487 | ws, wsView := c.getActiveWS() | 496 | ws, wsView := c.getActiveWS() | ||
| 488 | if ws != nil { | 497 | if ws != nil { | ||
| 489 | paneNode := ws.FindPane(paneID) | 498 | paneNode := ws.FindPane(paneID) | ||
| 490 | if paneNode != nil && paneNode.Pane != nil { | 499 | if paneNode != nil && paneNode.Pane != nil { | ||
| 491 | paneNode.Pane.Title = title | 500 | paneNode.Pane.Title = title | ||
| 492 | } | 501 | } | ||
| 502 | + | // Check if this pane is the active one | |||
| 503 | + | if ws.ActivePaneID == paneID { | |||
| 504 | + | isActivePaneTitle = true | |||
| 505 | + | } | |||
| 493 | } | 506 | } | ||
| 494 | 507 | ||||
| 495 | // Update StackedView title bar if this pane is in a stack | 508 | // Update StackedView title bar if this pane is in a stack | ||
| @@ -513,6 +526,11 @@ func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.P | |||||
| 513 | } | 526 | } | ||
| 514 | } | 527 | } | ||
| 515 | 528 | ||||
| 529 | + | // Notify window title update if this is the active pane | |||
| 530 | + | if isActivePaneTitle && c.onWindowTitleChanged != nil { | |||
| 531 | + | c.onWindowTitleChanged(title) | |||
| 532 | + | } | |||
| 533 | + | ||||
| 516 | log.Debug(). | 534 | log.Debug(). | ||
| 517 | Str("pane_id", string(paneID)). | 535 | Str("pane_id", string(paneID)). | ||
| 518 | Str("title", title). | 536 | Str("title", title). | ||
M internal/ui/window/main_window.go +15 -0
| @@ -170,6 +170,21 @@ func (mw *MainWindow) Window() *gtk.ApplicationWindow { | |||
| 170 | 170 | return mw.window | |
| 171 | 171 | } | |
| 172 | 172 | ||
| 173 | + | // SetTitle updates the window title with proper formatting. | |
| 174 | + | // The title is capped at 255 characters for display. | |
| 175 | + | func (mw *MainWindow) SetTitle(title string) { | |
| 176 | + | if mw.window == nil { | |
| 177 | + | return | |
| 178 | + | } | |
| 179 | + | // Truncate title to 255 characters max (by runes, to avoid breaking UTF-8) | |
| 180 | + | const maxTitleLen = 255 | |
| 181 | + | runes := []rune(title) | |
| 182 | + | if len(runes) > maxTitleLen { | |
| 183 | + | title = string(runes[:maxTitleLen-3]) + "..." | |
| 184 | + | } | |
| 185 | + | mw.window.SetTitle(&title) | |
| 186 | + | } | |
| 187 | + | ||
| 173 | 188 | // AddOverlay adds a widget to the content overlay. | |
| 174 | 189 | // The widget will be displayed on top of the workspace content. | |
| 175 | 190 | func (mw *MainWindow) AddOverlay(widget *gtk.Widget) { | |
| @@ -170,6 +170,21 @@ func (mw *MainWindow) Window() *gtk.ApplicationWindow { | |||||
| 170 | return mw.window | 170 | return mw.window | ||
| 171 | } | 171 | } | ||
| 172 | 172 | ||||
| 173 | + | // SetTitle updates the window title with proper formatting. | |||
| 174 | + | // The title is capped at 255 characters for display. | |||
| 175 | + | func (mw *MainWindow) SetTitle(title string) { | |||
| 176 | + | if mw.window == nil { | |||
| 177 | + | return | |||
| 178 | + | } | |||
| 179 | + | // Truncate title to 255 characters max (by runes, to avoid breaking UTF-8) | |||
| 180 | + | const maxTitleLen = 255 | |||
| 181 | + | runes := []rune(title) | |||
| 182 | + | if len(runes) > maxTitleLen { | |||
| 183 | + | title = string(runes[:maxTitleLen-3]) + "..." | |||
| 184 | + | } | |||
| 185 | + | mw.window.SetTitle(&title) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 173 | // AddOverlay adds a widget to the content overlay. | 188 | // AddOverlay adds a widget to the content overlay. | ||
| 174 | // The widget will be displayed on top of the workspace content. | 189 | // The widget will be displayed on top of the workspace content. | ||
| 175 | func (mw *MainWindow) AddOverlay(widget *gtk.Widget) { | 190 | func (mw *MainWindow) AddOverlay(widget *gtk.Widget) { | ||