feat(cli): add CLI interface with session-based logging
- commit
- 99cf04e03a72ec7ffa980fae14dd3fb4727261a3
- author
- bnema · · 2025-12-17 20:45 UTC
- parent
- e4dcc49
- signature
- unsigned
21 files · +3915 -0
A internal/cli/app.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | // Package cli provides CLI commands using Bubble Tea TUI. | |
| 2 | + | package cli | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "database/sql" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 12 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 13 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 14 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 15 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 16 | + | "github.com/bnema/dumber/internal/logging" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // App holds CLI dependencies. | |
| 20 | + | type App struct { | |
| 21 | + | Config *config.Config | |
| 22 | + | Theme *styles.Theme | |
| 23 | + | db *sql.DB | |
| 24 | + | History repository.HistoryRepository | |
| 25 | + | ||
| 26 | + | // Use cases | |
| 27 | + | SearchHistoryUC *usecase.SearchHistoryUseCase | |
| 28 | + | ||
| 29 | + | // Context with logger | |
| 30 | + | ctx context.Context | |
| 31 | + | logCleanup func() | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // NewApp creates a new CLI application with all dependencies. | |
| 35 | + | func NewApp() (*App, error) { | |
| 36 | + | // Load config | |
| 37 | + | cfg, err := loadConfig() | |
| 38 | + | if err != nil { | |
| 39 | + | return nil, fmt.Errorf("load config: %w", err) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Create theme from config | |
| 43 | + | theme := styles.NewTheme(cfg) | |
| 44 | + | ||
| 45 | + | // Initialize logger with file output for debugging | |
| 46 | + | // Logs can be viewed with: tail -f ~/.local/state/dumber/logs/session_*.log | |
| 47 | + | logCfg := logging.DefaultConfig() | |
| 48 | + | if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" { | |
| 49 | + | logCfg.Level = logging.ParseLevel(level) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Create session log file | |
| 53 | + | home, _ := os.UserHomeDir() | |
| 54 | + | sessionID := logging.GenerateSessionID() | |
| 55 | + | logDir := filepath.Join(home, ".local", "state", "dumber", "logs") | |
| 56 | + | ||
| 57 | + | logger, logCleanup, err := logging.NewWithFile(logCfg, logging.FileConfig{ | |
| 58 | + | Enabled: true, | |
| 59 | + | LogDir: logDir, | |
| 60 | + | SessionID: sessionID, | |
| 61 | + | }) | |
| 62 | + | if err != nil { | |
| 63 | + | // Fallback to no-file logging | |
| 64 | + | logger = logging.NewFromEnv() | |
| 65 | + | logCleanup = func() {} | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | ctx := logging.WithContext(context.Background(), logger) | |
| 69 | + | logger.Info().Str("command", "cli").Msg("CLI session started") | |
| 70 | + | ||
| 71 | + | // Determine database path - use share directory per XDG spec (user data) | |
| 72 | + | dbFile := cfg.Database.Path | |
| 73 | + | if dbFile == "" { | |
| 74 | + | dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db") | |
| 75 | + | } | |
| 76 | + | dbPath := filepath.Dir(dbFile) | |
| 77 | + | ||
| 78 | + | // Ensure data directory exists | |
| 79 | + | if err := os.MkdirAll(dbPath, 0755); err != nil { | |
| 80 | + | return nil, fmt.Errorf("create data dir: %w", err) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Open database connection | |
| 84 | + | db, err := sqlite.NewConnection(ctx, dbFile) | |
| 85 | + | if err != nil { | |
| 86 | + | return nil, fmt.Errorf("open database: %w", err) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | logger.Debug().Str("db_path", dbFile).Msg("database connected") | |
| 90 | + | ||
| 91 | + | // Create repositories | |
| 92 | + | historyRepo := sqlite.NewHistoryRepository(db) | |
| 93 | + | ||
| 94 | + | // Create use cases | |
| 95 | + | searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 96 | + | ||
| 97 | + | return &App{ | |
| 98 | + | Config: cfg, | |
| 99 | + | Theme: theme, | |
| 100 | + | db: db, | |
| 101 | + | History: historyRepo, | |
| 102 | + | SearchHistoryUC: searchHistoryUC, | |
| 103 | + | ctx: ctx, | |
| 104 | + | logCleanup: logCleanup, | |
| 105 | + | }, nil | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // Close releases all resources. | |
| 109 | + | func (a *App) Close() error { | |
| 110 | + | if a.logCleanup != nil { | |
| 111 | + | a.logCleanup() | |
| 112 | + | } | |
| 113 | + | if a.db != nil { | |
| 114 | + | return a.db.Close() | |
| 115 | + | } | |
| 116 | + | return nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Ctx returns the application context with logger. | |
| 120 | + | func (a *App) Ctx() context.Context { | |
| 121 | + | return a.ctx | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // loadConfig loads configuration from standard locations. | |
| 125 | + | func loadConfig() (*config.Config, error) { | |
| 126 | + | mgr, err := config.NewManager() | |
| 127 | + | if err != nil { | |
| 128 | + | // Return default config if manager fails | |
| 129 | + | return config.DefaultConfig(), nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | if err := mgr.Load(); err != nil { | |
| 133 | + | // Return default config if loading fails | |
| 134 | + | return config.DefaultConfig(), nil | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | return mgr.Get(), nil | |
| 138 | + | } | |
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | // Package cli provides CLI commands using Bubble Tea TUI. | |||
| 2 | + | package cli | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "database/sql" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 12 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 13 | + | "github.com/bnema/dumber/internal/domain/repository" | |||
| 14 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 15 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |||
| 16 | + | "github.com/bnema/dumber/internal/logging" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // App holds CLI dependencies. | |||
| 20 | + | type App struct { | |||
| 21 | + | Config *config.Config | |||
| 22 | + | Theme *styles.Theme | |||
| 23 | + | db *sql.DB | |||
| 24 | + | History repository.HistoryRepository | |||
| 25 | + | ||||
| 26 | + | // Use cases | |||
| 27 | + | SearchHistoryUC *usecase.SearchHistoryUseCase | |||
| 28 | + | ||||
| 29 | + | // Context with logger | |||
| 30 | + | ctx context.Context | |||
| 31 | + | logCleanup func() | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // NewApp creates a new CLI application with all dependencies. | |||
| 35 | + | func NewApp() (*App, error) { | |||
| 36 | + | // Load config | |||
| 37 | + | cfg, err := loadConfig() | |||
| 38 | + | if err != nil { | |||
| 39 | + | return nil, fmt.Errorf("load config: %w", err) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Create theme from config | |||
| 43 | + | theme := styles.NewTheme(cfg) | |||
| 44 | + | ||||
| 45 | + | // Initialize logger with file output for debugging | |||
| 46 | + | // Logs can be viewed with: tail -f ~/.local/state/dumber/logs/session_*.log | |||
| 47 | + | logCfg := logging.DefaultConfig() | |||
| 48 | + | if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" { | |||
| 49 | + | logCfg.Level = logging.ParseLevel(level) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // Create session log file | |||
| 53 | + | home, _ := os.UserHomeDir() | |||
| 54 | + | sessionID := logging.GenerateSessionID() | |||
| 55 | + | logDir := filepath.Join(home, ".local", "state", "dumber", "logs") | |||
| 56 | + | ||||
| 57 | + | logger, logCleanup, err := logging.NewWithFile(logCfg, logging.FileConfig{ | |||
| 58 | + | Enabled: true, | |||
| 59 | + | LogDir: logDir, | |||
| 60 | + | SessionID: sessionID, | |||
| 61 | + | }) | |||
| 62 | + | if err != nil { | |||
| 63 | + | // Fallback to no-file logging | |||
| 64 | + | logger = logging.NewFromEnv() | |||
| 65 | + | logCleanup = func() {} | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | ctx := logging.WithContext(context.Background(), logger) | |||
| 69 | + | logger.Info().Str("command", "cli").Msg("CLI session started") | |||
| 70 | + | ||||
| 71 | + | // Determine database path - use share directory per XDG spec (user data) | |||
| 72 | + | dbFile := cfg.Database.Path | |||
| 73 | + | if dbFile == "" { | |||
| 74 | + | dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db") | |||
| 75 | + | } | |||
| 76 | + | dbPath := filepath.Dir(dbFile) | |||
| 77 | + | ||||
| 78 | + | // Ensure data directory exists | |||
| 79 | + | if err := os.MkdirAll(dbPath, 0755); err != nil { | |||
| 80 | + | return nil, fmt.Errorf("create data dir: %w", err) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Open database connection | |||
| 84 | + | db, err := sqlite.NewConnection(ctx, dbFile) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return nil, fmt.Errorf("open database: %w", err) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | logger.Debug().Str("db_path", dbFile).Msg("database connected") | |||
| 90 | + | ||||
| 91 | + | // Create repositories | |||
| 92 | + | historyRepo := sqlite.NewHistoryRepository(db) | |||
| 93 | + | ||||
| 94 | + | // Create use cases | |||
| 95 | + | searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 96 | + | ||||
| 97 | + | return &App{ | |||
| 98 | + | Config: cfg, | |||
| 99 | + | Theme: theme, | |||
| 100 | + | db: db, | |||
| 101 | + | History: historyRepo, | |||
| 102 | + | SearchHistoryUC: searchHistoryUC, | |||
| 103 | + | ctx: ctx, | |||
| 104 | + | logCleanup: logCleanup, | |||
| 105 | + | }, nil | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // Close releases all resources. | |||
| 109 | + | func (a *App) Close() error { | |||
| 110 | + | if a.logCleanup != nil { | |||
| 111 | + | a.logCleanup() | |||
| 112 | + | } | |||
| 113 | + | if a.db != nil { | |||
| 114 | + | return a.db.Close() | |||
| 115 | + | } | |||
| 116 | + | return nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Ctx returns the application context with logger. | |||
| 120 | + | func (a *App) Ctx() context.Context { | |||
| 121 | + | return a.ctx | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // loadConfig loads configuration from standard locations. | |||
| 125 | + | func loadConfig() (*config.Config, error) { | |||
| 126 | + | mgr, err := config.NewManager() | |||
| 127 | + | if err != nil { | |||
| 128 | + | // Return default config if manager fails | |||
| 129 | + | return config.DefaultConfig(), nil | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | if err := mgr.Load(); err != nil { | |||
| 133 | + | // Return default config if loading fails | |||
| 134 | + | return config.DefaultConfig(), nil | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | return mgr.Get(), nil | |||
| 138 | + | } | |||
A internal/cli/cmd/dmenu.go +164 -0
| @@ -0,0 +1,164 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | tea "github.com/charmbracelet/bubbletea" | |
| 12 | + | "github.com/spf13/cobra" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/dumber/internal/cli/model" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | var ( | |
| 18 | + | dmenuInteractive bool | |
| 19 | + | dmenuSelect bool | |
| 20 | + | dmenuMax int | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | var dmenuCmd = &cobra.Command{ | |
| 24 | + | Use: "dmenu", | |
| 25 | + | Short: "Launcher integration for rofi/fuzzel", | |
| 26 | + | Long: `Output history for use with rofi, fuzzel, or other launchers. | |
| 27 | + | ||
| 28 | + | Default mode outputs entries for piping: | |
| 29 | + | dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select | |
| 30 | + | ||
| 31 | + | Interactive mode provides a built-in TUI fuzzy finder: | |
| 32 | + | dumber dmenu --interactive`, | |
| 33 | + | RunE: runDmenu, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func init() { | |
| 37 | + | rootCmd.AddCommand(dmenuCmd) | |
| 38 | + | ||
| 39 | + | dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode") | |
| 40 | + | dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin") | |
| 41 | + | dmenuCmd.Flags().IntVar(&dmenuMax, "max", 100, "maximum entries to output") | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func runDmenu(cmd *cobra.Command, args []string) error { | |
| 45 | + | app := GetApp() | |
| 46 | + | if app == nil { | |
| 47 | + | return fmt.Errorf("app not initialized") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Selection mode: read URL from stdin and open it | |
| 51 | + | if dmenuSelect { | |
| 52 | + | return runDmenuSelect() | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Interactive TUI mode | |
| 56 | + | if dmenuInteractive { | |
| 57 | + | return runDmenuInteractive() | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Pipe mode: output entries for rofi/fuzzel | |
| 61 | + | return runDmenuPipe() | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // runDmenuPipe outputs history entries for launcher consumption. | |
| 65 | + | func runDmenuPipe() error { | |
| 66 | + | app := GetApp() | |
| 67 | + | ctx := context.Background() | |
| 68 | + | ||
| 69 | + | entries, err := app.SearchHistoryUC.GetRecent(ctx, dmenuMax, 0) | |
| 70 | + | if err != nil { | |
| 71 | + | return fmt.Errorf("get history: %w", err) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Output in rofi-compatible format with icon hints | |
| 75 | + | // Format: text\0icon\x1ficonpath | |
| 76 | + | for _, entry := range entries { | |
| 77 | + | title := entry.Title | |
| 78 | + | if title == "" { | |
| 79 | + | title = entry.URL | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Truncate long titles | |
| 83 | + | if len(title) > 80 { | |
| 84 | + | title = title[:77] + "..." | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Output with URL as the actual value (for selection) | |
| 88 | + | // Using tab separator so rofi can use -d '\t' | |
| 89 | + | fmt.Printf("%s\t%s\n", title, entry.URL) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | return nil | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // runDmenuSelect processes a selection from stdin. | |
| 96 | + | func runDmenuSelect() error { | |
| 97 | + | reader := bufio.NewReader(os.Stdin) | |
| 98 | + | line, err := reader.ReadString('\n') | |
| 99 | + | if err != nil { | |
| 100 | + | return fmt.Errorf("read selection: %w", err) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | line = strings.TrimSpace(line) | |
| 104 | + | if line == "" { | |
| 105 | + | return nil // No selection | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // If the line contains a tab, the URL is after it | |
| 109 | + | // Otherwise, try to extract URL or use the whole line | |
| 110 | + | url := line | |
| 111 | + | if idx := strings.Index(line, "\t"); idx != -1 { | |
| 112 | + | url = strings.TrimSpace(line[idx+1:]) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Try to open the URL | |
| 116 | + | if url != "" { | |
| 117 | + | return openInBrowser(url) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | return nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // runDmenuInteractive runs the interactive TUI dmenu. | |
| 124 | + | func runDmenuInteractive() error { | |
| 125 | + | app := GetApp() | |
| 126 | + | ||
| 127 | + | m := model.NewDmenuModel(app.Theme, app.SearchHistoryUC) | |
| 128 | + | ||
| 129 | + | p := tea.NewProgram(m, tea.WithAltScreen()) | |
| 130 | + | finalModel, err := p.Run() | |
| 131 | + | if err != nil { | |
| 132 | + | return err | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // If user selected an entry, open it | |
| 136 | + | dm, ok := finalModel.(model.DmenuModel) | |
| 137 | + | if ok && dm.SelectedURL() != "" { | |
| 138 | + | return openInBrowser(dm.SelectedURL()) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | return nil | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // openInBrowser opens a URL in the default browser. | |
| 145 | + | func openInBrowser(url string) error { | |
| 146 | + | // Try xdg-open first (Linux) | |
| 147 | + | cmd := newCommand("xdg-open", url) | |
| 148 | + | return cmd.Start() | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // newCommand creates a command (abstracted for testing). | |
| 152 | + | var newCommand = func(name string, args ...string) interface{ Start() error } { | |
| 153 | + | return &commandWrapper{name: name, args: args} | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | type commandWrapper struct { | |
| 157 | + | name string | |
| 158 | + | args []string | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (c *commandWrapper) Start() error { | |
| 162 | + | cmd := exec.Command(c.name, c.args...) | |
| 163 | + | return cmd.Start() | |
| 164 | + | } | |
| @@ -0,0 +1,164 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "strings" | |||
| 10 | + | ||||
| 11 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 12 | + | "github.com/spf13/cobra" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/dumber/internal/cli/model" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | var ( | |||
| 18 | + | dmenuInteractive bool | |||
| 19 | + | dmenuSelect bool | |||
| 20 | + | dmenuMax int | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | var dmenuCmd = &cobra.Command{ | |||
| 24 | + | Use: "dmenu", | |||
| 25 | + | Short: "Launcher integration for rofi/fuzzel", | |||
| 26 | + | Long: `Output history for use with rofi, fuzzel, or other launchers. | |||
| 27 | + | ||||
| 28 | + | Default mode outputs entries for piping: | |||
| 29 | + | dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select | |||
| 30 | + | ||||
| 31 | + | Interactive mode provides a built-in TUI fuzzy finder: | |||
| 32 | + | dumber dmenu --interactive`, | |||
| 33 | + | RunE: runDmenu, | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func init() { | |||
| 37 | + | rootCmd.AddCommand(dmenuCmd) | |||
| 38 | + | ||||
| 39 | + | dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode") | |||
| 40 | + | dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin") | |||
| 41 | + | dmenuCmd.Flags().IntVar(&dmenuMax, "max", 100, "maximum entries to output") | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func runDmenu(cmd *cobra.Command, args []string) error { | |||
| 45 | + | app := GetApp() | |||
| 46 | + | if app == nil { | |||
| 47 | + | return fmt.Errorf("app not initialized") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Selection mode: read URL from stdin and open it | |||
| 51 | + | if dmenuSelect { | |||
| 52 | + | return runDmenuSelect() | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Interactive TUI mode | |||
| 56 | + | if dmenuInteractive { | |||
| 57 | + | return runDmenuInteractive() | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Pipe mode: output entries for rofi/fuzzel | |||
| 61 | + | return runDmenuPipe() | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // runDmenuPipe outputs history entries for launcher consumption. | |||
| 65 | + | func runDmenuPipe() error { | |||
| 66 | + | app := GetApp() | |||
| 67 | + | ctx := context.Background() | |||
| 68 | + | ||||
| 69 | + | entries, err := app.SearchHistoryUC.GetRecent(ctx, dmenuMax, 0) | |||
| 70 | + | if err != nil { | |||
| 71 | + | return fmt.Errorf("get history: %w", err) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Output in rofi-compatible format with icon hints | |||
| 75 | + | // Format: text\0icon\x1ficonpath | |||
| 76 | + | for _, entry := range entries { | |||
| 77 | + | title := entry.Title | |||
| 78 | + | if title == "" { | |||
| 79 | + | title = entry.URL | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Truncate long titles | |||
| 83 | + | if len(title) > 80 { | |||
| 84 | + | title = title[:77] + "..." | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // Output with URL as the actual value (for selection) | |||
| 88 | + | // Using tab separator so rofi can use -d '\t' | |||
| 89 | + | fmt.Printf("%s\t%s\n", title, entry.URL) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | return nil | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // runDmenuSelect processes a selection from stdin. | |||
| 96 | + | func runDmenuSelect() error { | |||
| 97 | + | reader := bufio.NewReader(os.Stdin) | |||
| 98 | + | line, err := reader.ReadString('\n') | |||
| 99 | + | if err != nil { | |||
| 100 | + | return fmt.Errorf("read selection: %w", err) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | line = strings.TrimSpace(line) | |||
| 104 | + | if line == "" { | |||
| 105 | + | return nil // No selection | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // If the line contains a tab, the URL is after it | |||
| 109 | + | // Otherwise, try to extract URL or use the whole line | |||
| 110 | + | url := line | |||
| 111 | + | if idx := strings.Index(line, "\t"); idx != -1 { | |||
| 112 | + | url = strings.TrimSpace(line[idx+1:]) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // Try to open the URL | |||
| 116 | + | if url != "" { | |||
| 117 | + | return openInBrowser(url) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | return nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // runDmenuInteractive runs the interactive TUI dmenu. | |||
| 124 | + | func runDmenuInteractive() error { | |||
| 125 | + | app := GetApp() | |||
| 126 | + | ||||
| 127 | + | m := model.NewDmenuModel(app.Theme, app.SearchHistoryUC) | |||
| 128 | + | ||||
| 129 | + | p := tea.NewProgram(m, tea.WithAltScreen()) | |||
| 130 | + | finalModel, err := p.Run() | |||
| 131 | + | if err != nil { | |||
| 132 | + | return err | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // If user selected an entry, open it | |||
| 136 | + | dm, ok := finalModel.(model.DmenuModel) | |||
| 137 | + | if ok && dm.SelectedURL() != "" { | |||
| 138 | + | return openInBrowser(dm.SelectedURL()) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | return nil | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // openInBrowser opens a URL in the default browser. | |||
| 145 | + | func openInBrowser(url string) error { | |||
| 146 | + | // Try xdg-open first (Linux) | |||
| 147 | + | cmd := newCommand("xdg-open", url) | |||
| 148 | + | return cmd.Start() | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // newCommand creates a command (abstracted for testing). | |||
| 152 | + | var newCommand = func(name string, args ...string) interface{ Start() error } { | |||
| 153 | + | return &commandWrapper{name: name, args: args} | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | type commandWrapper struct { | |||
| 157 | + | name string | |||
| 158 | + | args []string | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (c *commandWrapper) Start() error { | |||
| 162 | + | cmd := exec.Command(c.name, c.args...) | |||
| 163 | + | return cmd.Start() | |||
| 164 | + | } | |||
A internal/cli/cmd/history.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | ||
| 8 | + | tea "github.com/charmbracelet/bubbletea" | |
| 9 | + | "github.com/spf13/cobra" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/cli/model" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | var ( | |
| 15 | + | historyJSON bool | |
| 16 | + | historyMax int | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | var historyCmd = &cobra.Command{ | |
| 20 | + | Use: "history", | |
| 21 | + | Short: "Browse and manage history", | |
| 22 | + | Long: `Interactive history browser with timeline tabs, fuzzy search, and cleanup.`, | |
| 23 | + | RunE: runHistory, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func init() { | |
| 27 | + | rootCmd.AddCommand(historyCmd) | |
| 28 | + | ||
| 29 | + | historyCmd.Flags().BoolVar(&historyJSON, "json", false, "output as JSON") | |
| 30 | + | historyCmd.Flags().IntVar(&historyMax, "max", 50, "maximum entries to show (for --json)") | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func runHistory(cmd *cobra.Command, args []string) error { | |
| 34 | + | app := GetApp() | |
| 35 | + | if app == nil { | |
| 36 | + | return fmt.Errorf("app not initialized") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // JSON output mode (non-interactive) | |
| 40 | + | if historyJSON { | |
| 41 | + | return runHistoryJSON() | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Interactive TUI mode | |
| 45 | + | return runHistoryTUI() | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // runHistoryTUI runs the interactive history browser. | |
| 49 | + | func runHistoryTUI() error { | |
| 50 | + | app := GetApp() | |
| 51 | + | ||
| 52 | + | m := model.NewHistoryModel(app.Ctx(), app.Theme, app.SearchHistoryUC) | |
| 53 | + | ||
| 54 | + | p := tea.NewProgram(m, tea.WithAltScreen()) | |
| 55 | + | _, err := p.Run() | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // runHistoryJSON outputs history as JSON. | |
| 60 | + | func runHistoryJSON() error { | |
| 61 | + | app := GetApp() | |
| 62 | + | ||
| 63 | + | m := model.NewHistoryListModel(app.Ctx(), app.SearchHistoryUC, historyMax) | |
| 64 | + | ||
| 65 | + | // Run briefly to load data | |
| 66 | + | p := tea.NewProgram(m) | |
| 67 | + | finalModel, err := p.Run() | |
| 68 | + | if err != nil { | |
| 69 | + | return fmt.Errorf("run model: %w", err) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Extract results | |
| 73 | + | listModel, ok := finalModel.(model.HistoryListModel) | |
| 74 | + | if !ok { | |
| 75 | + | return fmt.Errorf("unexpected model type") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | if listModel.Error() != nil { | |
| 79 | + | return listModel.Error() | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Output as JSON | |
| 83 | + | entries := listModel.Entries() | |
| 84 | + | enc := json.NewEncoder(os.Stdout) | |
| 85 | + | enc.SetIndent("", " ") | |
| 86 | + | return enc.Encode(entries) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // statsCmd shows history statistics. | |
| 90 | + | var statsCmd = &cobra.Command{ | |
| 91 | + | Use: "stats", | |
| 92 | + | Short: "Show history statistics", | |
| 93 | + | Long: `Display analytics about your browsing history.`, | |
| 94 | + | RunE: runStats, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func init() { | |
| 98 | + | historyCmd.AddCommand(statsCmd) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func runStats(cmd *cobra.Command, args []string) error { | |
| 102 | + | app := GetApp() | |
| 103 | + | if app == nil { | |
| 104 | + | return fmt.Errorf("app not initialized") | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Run stats model | |
| 108 | + | m := model.NewStatsModel(app.Theme, app.SearchHistoryUC) | |
| 109 | + | ||
| 110 | + | p := tea.NewProgram(m) | |
| 111 | + | _, err := p.Run() | |
| 112 | + | return err | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // clearCmd clears history. | |
| 116 | + | var clearCmd = &cobra.Command{ | |
| 117 | + | Use: "clear", | |
| 118 | + | Short: "Clear history", | |
| 119 | + | Long: `Interactively select time range to clear history.`, | |
| 120 | + | RunE: runClear, | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func init() { | |
| 124 | + | historyCmd.AddCommand(clearCmd) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func runClear(cmd *cobra.Command, args []string) error { | |
| 128 | + | app := GetApp() | |
| 129 | + | if app == nil { | |
| 130 | + | return fmt.Errorf("app not initialized") | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Run cleanup modal directly | |
| 134 | + | m := model.NewCleanupModel(app.Theme, app.SearchHistoryUC) | |
| 135 | + | ||
| 136 | + | p := tea.NewProgram(m) | |
| 137 | + | _, err := p.Run() | |
| 138 | + | return err | |
| 139 | + | } | |
| @@ -0,0 +1,139 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | ||||
| 8 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 9 | + | "github.com/spf13/cobra" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/cli/model" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | var ( | |||
| 15 | + | historyJSON bool | |||
| 16 | + | historyMax int | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | var historyCmd = &cobra.Command{ | |||
| 20 | + | Use: "history", | |||
| 21 | + | Short: "Browse and manage history", | |||
| 22 | + | Long: `Interactive history browser with timeline tabs, fuzzy search, and cleanup.`, | |||
| 23 | + | RunE: runHistory, | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func init() { | |||
| 27 | + | rootCmd.AddCommand(historyCmd) | |||
| 28 | + | ||||
| 29 | + | historyCmd.Flags().BoolVar(&historyJSON, "json", false, "output as JSON") | |||
| 30 | + | historyCmd.Flags().IntVar(&historyMax, "max", 50, "maximum entries to show (for --json)") | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func runHistory(cmd *cobra.Command, args []string) error { | |||
| 34 | + | app := GetApp() | |||
| 35 | + | if app == nil { | |||
| 36 | + | return fmt.Errorf("app not initialized") | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // JSON output mode (non-interactive) | |||
| 40 | + | if historyJSON { | |||
| 41 | + | return runHistoryJSON() | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // Interactive TUI mode | |||
| 45 | + | return runHistoryTUI() | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // runHistoryTUI runs the interactive history browser. | |||
| 49 | + | func runHistoryTUI() error { | |||
| 50 | + | app := GetApp() | |||
| 51 | + | ||||
| 52 | + | m := model.NewHistoryModel(app.Ctx(), app.Theme, app.SearchHistoryUC) | |||
| 53 | + | ||||
| 54 | + | p := tea.NewProgram(m, tea.WithAltScreen()) | |||
| 55 | + | _, err := p.Run() | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // runHistoryJSON outputs history as JSON. | |||
| 60 | + | func runHistoryJSON() error { | |||
| 61 | + | app := GetApp() | |||
| 62 | + | ||||
| 63 | + | m := model.NewHistoryListModel(app.Ctx(), app.SearchHistoryUC, historyMax) | |||
| 64 | + | ||||
| 65 | + | // Run briefly to load data | |||
| 66 | + | p := tea.NewProgram(m) | |||
| 67 | + | finalModel, err := p.Run() | |||
| 68 | + | if err != nil { | |||
| 69 | + | return fmt.Errorf("run model: %w", err) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Extract results | |||
| 73 | + | listModel, ok := finalModel.(model.HistoryListModel) | |||
| 74 | + | if !ok { | |||
| 75 | + | return fmt.Errorf("unexpected model type") | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | if listModel.Error() != nil { | |||
| 79 | + | return listModel.Error() | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Output as JSON | |||
| 83 | + | entries := listModel.Entries() | |||
| 84 | + | enc := json.NewEncoder(os.Stdout) | |||
| 85 | + | enc.SetIndent("", " ") | |||
| 86 | + | return enc.Encode(entries) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // statsCmd shows history statistics. | |||
| 90 | + | var statsCmd = &cobra.Command{ | |||
| 91 | + | Use: "stats", | |||
| 92 | + | Short: "Show history statistics", | |||
| 93 | + | Long: `Display analytics about your browsing history.`, | |||
| 94 | + | RunE: runStats, | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func init() { | |||
| 98 | + | historyCmd.AddCommand(statsCmd) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func runStats(cmd *cobra.Command, args []string) error { | |||
| 102 | + | app := GetApp() | |||
| 103 | + | if app == nil { | |||
| 104 | + | return fmt.Errorf("app not initialized") | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Run stats model | |||
| 108 | + | m := model.NewStatsModel(app.Theme, app.SearchHistoryUC) | |||
| 109 | + | ||||
| 110 | + | p := tea.NewProgram(m) | |||
| 111 | + | _, err := p.Run() | |||
| 112 | + | return err | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // clearCmd clears history. | |||
| 116 | + | var clearCmd = &cobra.Command{ | |||
| 117 | + | Use: "clear", | |||
| 118 | + | Short: "Clear history", | |||
| 119 | + | Long: `Interactively select time range to clear history.`, | |||
| 120 | + | RunE: runClear, | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func init() { | |||
| 124 | + | historyCmd.AddCommand(clearCmd) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func runClear(cmd *cobra.Command, args []string) error { | |||
| 128 | + | app := GetApp() | |||
| 129 | + | if app == nil { | |||
| 130 | + | return fmt.Errorf("app not initialized") | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Run cleanup modal directly | |||
| 134 | + | m := model.NewCleanupModel(app.Theme, app.SearchHistoryUC) | |||
| 135 | + | ||||
| 136 | + | p := tea.NewProgram(m) | |||
| 137 | + | _, err := p.Run() | |||
| 138 | + | return err | |||
| 139 | + | } | |||
A internal/cli/cmd/logs.go +425 -0
| @@ -0,0 +1,425 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "sort" | |
| 11 | + | "strings" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/spf13/cobra" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 17 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 18 | + | "github.com/bnema/dumber/internal/logging" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | var ( | |
| 22 | + | logsFollow bool | |
| 23 | + | logsLines int | |
| 24 | + | logsClearAll bool | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | var logsCmd = &cobra.Command{ | |
| 28 | + | Use: "logs [session]", | |
| 29 | + | Short: "View application logs", | |
| 30 | + | Long: `View dumber application logs by session. | |
| 31 | + | ||
| 32 | + | Without arguments, lists all available sessions. | |
| 33 | + | With a session ID (or partial match), shows logs for that session. | |
| 34 | + | ||
| 35 | + | Examples: | |
| 36 | + | dumber logs # List all sessions | |
| 37 | + | dumber logs a7b3 # View logs for session ending in 'a7b3' | |
| 38 | + | dumber logs -f a7b3 # Follow logs in real-time | |
| 39 | + | dumber logs -n 100 a7b3 # Show last 100 lines`, | |
| 40 | + | RunE: runLogs, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func init() { | |
| 44 | + | rootCmd.AddCommand(logsCmd) | |
| 45 | + | ||
| 46 | + | logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "follow log output in real-time") | |
| 47 | + | logsCmd.Flags().IntVarP(&logsLines, "lines", "n", 50, "number of lines to show") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // SessionInfo holds metadata about a log session. | |
| 51 | + | type SessionInfo struct { | |
| 52 | + | SessionID string | |
| 53 | + | ShortID string | |
| 54 | + | Filename string | |
| 55 | + | Path string | |
| 56 | + | Size int64 | |
| 57 | + | ModTime time.Time | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func runLogs(cmd *cobra.Command, args []string) error { | |
| 61 | + | app := GetApp() | |
| 62 | + | if app == nil { | |
| 63 | + | return fmt.Errorf("app not initialized") | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | logDir := getLogDir() | |
| 67 | + | ||
| 68 | + | // List sessions if no argument provided | |
| 69 | + | if len(args) == 0 { | |
| 70 | + | return listSessions(logDir, app.Theme) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Find session by partial match | |
| 74 | + | sessionQuery := args[0] | |
| 75 | + | session, err := findSession(logDir, sessionQuery) | |
| 76 | + | if err != nil { | |
| 77 | + | return err | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | if logsFollow { | |
| 81 | + | return tailSession(session.Path, app.Theme) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | return showSession(session.Path, logsLines, app.Theme) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // getLogDir returns the log directory path. | |
| 88 | + | func getLogDir() string { | |
| 89 | + | logDir, err := config.GetLogDir() | |
| 90 | + | if err != nil { | |
| 91 | + | // Fallback to XDG default | |
| 92 | + | stateDir := os.Getenv("XDG_STATE_HOME") | |
| 93 | + | if stateDir == "" { | |
| 94 | + | home, _ := os.UserHomeDir() | |
| 95 | + | stateDir = filepath.Join(home, ".local", "state") | |
| 96 | + | } | |
| 97 | + | return filepath.Join(stateDir, "dumber", "logs") | |
| 98 | + | } | |
| 99 | + | return logDir | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // listSessions displays all available log sessions. | |
| 103 | + | func listSessions(logDir string, theme *styles.Theme) error { | |
| 104 | + | sessions, err := getSessions(logDir) | |
| 105 | + | if err != nil { | |
| 106 | + | return err | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | if len(sessions) == 0 { | |
| 110 | + | fmt.Println(theme.Subtle.Render("No sessions found. Run 'dumber browse' to create logs.")) | |
| 111 | + | return nil | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Header | |
| 115 | + | fmt.Println(theme.Title.Render("Sessions (newest first):")) | |
| 116 | + | fmt.Println() | |
| 117 | + | ||
| 118 | + | // Table format: ShortID | DateTime | Size | |
| 119 | + | for _, s := range sessions { | |
| 120 | + | dateStr := s.ModTime.Format("2006-01-02 15:04:05") | |
| 121 | + | sizeStr := formatSize(s.Size) | |
| 122 | + | ||
| 123 | + | line := fmt.Sprintf(" %s %s %s", | |
| 124 | + | theme.Highlight.Render(s.ShortID), | |
| 125 | + | theme.Subtle.Render(dateStr), | |
| 126 | + | theme.Subtle.Render(fmt.Sprintf("(%s)", sizeStr)), | |
| 127 | + | ) | |
| 128 | + | fmt.Println(line) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | fmt.Println() | |
| 132 | + | fmt.Println(theme.Subtle.Render("Use 'dumber logs <id>' to view a session")) | |
| 133 | + | return nil | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // getSessions returns all session log files, sorted by modification time (newest first). | |
| 137 | + | func getSessions(logDir string) ([]SessionInfo, error) { | |
| 138 | + | entries, err := os.ReadDir(logDir) | |
| 139 | + | if err != nil { | |
| 140 | + | if os.IsNotExist(err) { | |
| 141 | + | return nil, nil | |
| 142 | + | } | |
| 143 | + | return nil, fmt.Errorf("read log directory: %w", err) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | var sessions []SessionInfo | |
| 147 | + | for _, entry := range entries { | |
| 148 | + | if entry.IsDir() { | |
| 149 | + | continue | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | sessionID, ok := logging.ParseSessionFilename(entry.Name()) | |
| 153 | + | if !ok { | |
| 154 | + | continue | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | info, err := entry.Info() | |
| 158 | + | if err != nil { | |
| 159 | + | continue | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | sessions = append(sessions, SessionInfo{ | |
| 163 | + | SessionID: sessionID, | |
| 164 | + | ShortID: logging.ShortSessionID(sessionID), | |
| 165 | + | Filename: entry.Name(), | |
| 166 | + | Path: filepath.Join(logDir, entry.Name()), | |
| 167 | + | Size: info.Size(), | |
| 168 | + | ModTime: info.ModTime(), | |
| 169 | + | }) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // Sort by modification time (newest first) | |
| 173 | + | sort.Slice(sessions, func(i, j int) bool { | |
| 174 | + | return sessions[i].ModTime.After(sessions[j].ModTime) | |
| 175 | + | }) | |
| 176 | + | ||
| 177 | + | return sessions, nil | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // findSession finds a session by partial ID match. | |
| 181 | + | func findSession(logDir, query string) (*SessionInfo, error) { | |
| 182 | + | sessions, err := getSessions(logDir) | |
| 183 | + | if err != nil { | |
| 184 | + | return nil, err | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | if len(sessions) == 0 { | |
| 188 | + | return nil, fmt.Errorf("no sessions found") | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | query = strings.ToLower(query) | |
| 192 | + | ||
| 193 | + | // Try exact short ID match first | |
| 194 | + | for _, s := range sessions { | |
| 195 | + | if strings.ToLower(s.ShortID) == query { | |
| 196 | + | return &s, nil | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Try partial match on full session ID | |
| 201 | + | var matches []SessionInfo | |
| 202 | + | for _, s := range sessions { | |
| 203 | + | if strings.Contains(strings.ToLower(s.SessionID), query) { | |
| 204 | + | matches = append(matches, s) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | switch len(matches) { | |
| 209 | + | case 0: | |
| 210 | + | return nil, fmt.Errorf("no session matching '%s' found", query) | |
| 211 | + | case 1: | |
| 212 | + | return &matches[0], nil | |
| 213 | + | default: | |
| 214 | + | // Multiple matches - show them and ask user to be more specific | |
| 215 | + | var ids []string | |
| 216 | + | for _, m := range matches { | |
| 217 | + | ids = append(ids, m.ShortID) | |
| 218 | + | } | |
| 219 | + | return nil, fmt.Errorf("multiple sessions match '%s': %s", query, strings.Join(ids, ", ")) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // showSession displays the last N lines of a session log. | |
| 224 | + | func showSession(logPath string, lines int, theme *styles.Theme) error { | |
| 225 | + | file, err := os.Open(logPath) | |
| 226 | + | if err != nil { | |
| 227 | + | return fmt.Errorf("open log file: %w", err) | |
| 228 | + | } | |
| 229 | + | defer file.Close() | |
| 230 | + | ||
| 231 | + | // Read all lines | |
| 232 | + | var allLines []string | |
| 233 | + | scanner := bufio.NewScanner(file) | |
| 234 | + | for scanner.Scan() { | |
| 235 | + | allLines = append(allLines, scanner.Text()) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | if err := scanner.Err(); err != nil { | |
| 239 | + | return fmt.Errorf("read log file: %w", err) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // Get last N lines | |
| 243 | + | start := 0 | |
| 244 | + | if len(allLines) > lines { | |
| 245 | + | start = len(allLines) - lines | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | for _, line := range allLines[start:] { | |
| 249 | + | fmt.Println(colorizeLogLine(line, theme)) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | return nil | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // tailSession follows a session log in real-time. | |
| 256 | + | func tailSession(logPath string, theme *styles.Theme) error { | |
| 257 | + | file, err := os.Open(logPath) | |
| 258 | + | if err != nil { | |
| 259 | + | return fmt.Errorf("open log file: %w", err) | |
| 260 | + | } | |
| 261 | + | defer file.Close() | |
| 262 | + | ||
| 263 | + | // Seek to end | |
| 264 | + | _, _ = file.Seek(0, io.SeekEnd) | |
| 265 | + | ||
| 266 | + | fmt.Println(theme.Subtle.Render("Following logs... (Ctrl+C to stop)")) | |
| 267 | + | fmt.Println() | |
| 268 | + | ||
| 269 | + | reader := bufio.NewReader(file) | |
| 270 | + | for { | |
| 271 | + | line, err := reader.ReadString('\n') | |
| 272 | + | if err != nil { | |
| 273 | + | // No new data, wait a bit | |
| 274 | + | time.Sleep(100 * time.Millisecond) | |
| 275 | + | continue | |
| 276 | + | } | |
| 277 | + | fmt.Print(colorizeLogLine(line, theme)) | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // logEntry represents a parsed JSON log entry. | |
| 282 | + | type logEntry struct { | |
| 283 | + | Level string `json:"level"` | |
| 284 | + | Time string `json:"time"` | |
| 285 | + | Message string `json:"message"` | |
| 286 | + | Session string `json:"session"` | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // colorizeLogLine adds color based on log level. | |
| 290 | + | func colorizeLogLine(line string, theme *styles.Theme) string { | |
| 291 | + | // Try to parse as JSON | |
| 292 | + | var entry logEntry | |
| 293 | + | if err := json.Unmarshal([]byte(line), &entry); err == nil { | |
| 294 | + | return formatJSONLogLine(entry, theme) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // Fallback to pattern matching for non-JSON logs | |
| 298 | + | switch { | |
| 299 | + | case containsAny(line, "ERR", "ERROR", "error"): | |
| 300 | + | return theme.ErrorStyle.Render(line) | |
| 301 | + | case containsAny(line, "WRN", "WARN", "warn"): | |
| 302 | + | return theme.WarningStyle.Render(line) | |
| 303 | + | case containsAny(line, "DBG", "DEBUG", "debug"): | |
| 304 | + | return theme.Subtle.Render(line) | |
| 305 | + | default: | |
| 306 | + | return line | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // formatJSONLogLine formats a parsed JSON log entry with colors. | |
| 311 | + | func formatJSONLogLine(entry logEntry, theme *styles.Theme) string { | |
| 312 | + | // Parse time if present | |
| 313 | + | timeStr := "" | |
| 314 | + | if entry.Time != "" { | |
| 315 | + | if t, err := time.Parse(time.RFC3339, entry.Time); err == nil { | |
| 316 | + | timeStr = t.Format("15:04:05") | |
| 317 | + | } else { | |
| 318 | + | timeStr = entry.Time | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // Level styling | |
| 323 | + | var levelStr string | |
| 324 | + | switch entry.Level { | |
| 325 | + | case "error": | |
| 326 | + | levelStr = theme.ErrorStyle.Render("ERR") | |
| 327 | + | case "warn": | |
| 328 | + | levelStr = theme.WarningStyle.Render("WRN") | |
| 329 | + | case "info": | |
| 330 | + | levelStr = theme.Highlight.Render("INF") | |
| 331 | + | case "debug": | |
| 332 | + | levelStr = theme.Subtle.Render("DBG") | |
| 333 | + | case "trace": | |
| 334 | + | levelStr = theme.Subtle.Render("TRC") | |
| 335 | + | default: | |
| 336 | + | levelStr = entry.Level | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | return fmt.Sprintf("%s %s %s", theme.Subtle.Render(timeStr), levelStr, entry.Message) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | // containsAny checks if s contains any of the substrings. | |
| 343 | + | func containsAny(s string, substrs ...string) bool { | |
| 344 | + | sLower := strings.ToLower(s) | |
| 345 | + | for _, substr := range substrs { | |
| 346 | + | if strings.Contains(sLower, strings.ToLower(substr)) { | |
| 347 | + | return true | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | return false | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | // logsClearCmd clears old session logs. | |
| 354 | + | var logsClearCmd = &cobra.Command{ | |
| 355 | + | Use: "clear", | |
| 356 | + | Short: "Clear old log files", | |
| 357 | + | Long: `Remove old session log files. | |
| 358 | + | ||
| 359 | + | By default, removes sessions older than the configured MaxAge (default 7 days). | |
| 360 | + | Use --all to remove all sessions.`, | |
| 361 | + | RunE: runLogsClear, | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | func init() { | |
| 365 | + | logsCmd.AddCommand(logsClearCmd) | |
| 366 | + | logsClearCmd.Flags().BoolVar(&logsClearAll, "all", false, "remove all session logs") | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | func runLogsClear(cmd *cobra.Command, args []string) error { | |
| 370 | + | app := GetApp() | |
| 371 | + | if app == nil { | |
| 372 | + | return fmt.Errorf("app not initialized") | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | logDir := getLogDir() | |
| 376 | + | sessions, err := getSessions(logDir) | |
| 377 | + | if err != nil { | |
| 378 | + | return err | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | if len(sessions) == 0 { | |
| 382 | + | fmt.Println(app.Theme.Subtle.Render("No logs to clear")) | |
| 383 | + | return nil | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // Get MaxAge from config (default 7 days) | |
| 387 | + | maxAge := 7 | |
| 388 | + | if app.Config != nil && app.Config.Logging.MaxAge > 0 { | |
| 389 | + | maxAge = app.Config.Logging.MaxAge | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | cutoff := time.Now().AddDate(0, 0, -maxAge) | |
| 393 | + | var removed int | |
| 394 | + | ||
| 395 | + | for _, s := range sessions { | |
| 396 | + | shouldRemove := logsClearAll || s.ModTime.Before(cutoff) | |
| 397 | + | if !shouldRemove { | |
| 398 | + | continue | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | if err := os.Remove(s.Path); err != nil { | |
| 402 | + | fmt.Printf("%s %s: %v\n", | |
| 403 | + | app.Theme.ErrorStyle.Render("\u2717"), | |
| 404 | + | s.ShortID, | |
| 405 | + | err, | |
| 406 | + | ) | |
| 407 | + | continue | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | fmt.Printf("%s %s (%s)\n", | |
| 411 | + | app.Theme.SuccessStyle.Render("\u2713"), | |
| 412 | + | s.ShortID, | |
| 413 | + | formatSize(s.Size), | |
| 414 | + | ) | |
| 415 | + | removed++ | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | if removed == 0 { | |
| 419 | + | fmt.Println(app.Theme.Subtle.Render("No sessions older than " + fmt.Sprintf("%d days", maxAge))) | |
| 420 | + | } else { | |
| 421 | + | fmt.Printf("\n%s\n", app.Theme.SuccessStyle.Render(fmt.Sprintf("Cleared %d session(s)", removed))) | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | return nil | |
| 425 | + | } | |
| @@ -0,0 +1,425 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "sort" | |||
| 11 | + | "strings" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/spf13/cobra" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 17 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 18 | + | "github.com/bnema/dumber/internal/logging" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | var ( | |||
| 22 | + | logsFollow bool | |||
| 23 | + | logsLines int | |||
| 24 | + | logsClearAll bool | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | var logsCmd = &cobra.Command{ | |||
| 28 | + | Use: "logs [session]", | |||
| 29 | + | Short: "View application logs", | |||
| 30 | + | Long: `View dumber application logs by session. | |||
| 31 | + | ||||
| 32 | + | Without arguments, lists all available sessions. | |||
| 33 | + | With a session ID (or partial match), shows logs for that session. | |||
| 34 | + | ||||
| 35 | + | Examples: | |||
| 36 | + | dumber logs # List all sessions | |||
| 37 | + | dumber logs a7b3 # View logs for session ending in 'a7b3' | |||
| 38 | + | dumber logs -f a7b3 # Follow logs in real-time | |||
| 39 | + | dumber logs -n 100 a7b3 # Show last 100 lines`, | |||
| 40 | + | RunE: runLogs, | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func init() { | |||
| 44 | + | rootCmd.AddCommand(logsCmd) | |||
| 45 | + | ||||
| 46 | + | logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "follow log output in real-time") | |||
| 47 | + | logsCmd.Flags().IntVarP(&logsLines, "lines", "n", 50, "number of lines to show") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // SessionInfo holds metadata about a log session. | |||
| 51 | + | type SessionInfo struct { | |||
| 52 | + | SessionID string | |||
| 53 | + | ShortID string | |||
| 54 | + | Filename string | |||
| 55 | + | Path string | |||
| 56 | + | Size int64 | |||
| 57 | + | ModTime time.Time | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func runLogs(cmd *cobra.Command, args []string) error { | |||
| 61 | + | app := GetApp() | |||
| 62 | + | if app == nil { | |||
| 63 | + | return fmt.Errorf("app not initialized") | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | logDir := getLogDir() | |||
| 67 | + | ||||
| 68 | + | // List sessions if no argument provided | |||
| 69 | + | if len(args) == 0 { | |||
| 70 | + | return listSessions(logDir, app.Theme) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Find session by partial match | |||
| 74 | + | sessionQuery := args[0] | |||
| 75 | + | session, err := findSession(logDir, sessionQuery) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return err | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | if logsFollow { | |||
| 81 | + | return tailSession(session.Path, app.Theme) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | return showSession(session.Path, logsLines, app.Theme) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // getLogDir returns the log directory path. | |||
| 88 | + | func getLogDir() string { | |||
| 89 | + | logDir, err := config.GetLogDir() | |||
| 90 | + | if err != nil { | |||
| 91 | + | // Fallback to XDG default | |||
| 92 | + | stateDir := os.Getenv("XDG_STATE_HOME") | |||
| 93 | + | if stateDir == "" { | |||
| 94 | + | home, _ := os.UserHomeDir() | |||
| 95 | + | stateDir = filepath.Join(home, ".local", "state") | |||
| 96 | + | } | |||
| 97 | + | return filepath.Join(stateDir, "dumber", "logs") | |||
| 98 | + | } | |||
| 99 | + | return logDir | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // listSessions displays all available log sessions. | |||
| 103 | + | func listSessions(logDir string, theme *styles.Theme) error { | |||
| 104 | + | sessions, err := getSessions(logDir) | |||
| 105 | + | if err != nil { | |||
| 106 | + | return err | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | if len(sessions) == 0 { | |||
| 110 | + | fmt.Println(theme.Subtle.Render("No sessions found. Run 'dumber browse' to create logs.")) | |||
| 111 | + | return nil | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // Header | |||
| 115 | + | fmt.Println(theme.Title.Render("Sessions (newest first):")) | |||
| 116 | + | fmt.Println() | |||
| 117 | + | ||||
| 118 | + | // Table format: ShortID | DateTime | Size | |||
| 119 | + | for _, s := range sessions { | |||
| 120 | + | dateStr := s.ModTime.Format("2006-01-02 15:04:05") | |||
| 121 | + | sizeStr := formatSize(s.Size) | |||
| 122 | + | ||||
| 123 | + | line := fmt.Sprintf(" %s %s %s", | |||
| 124 | + | theme.Highlight.Render(s.ShortID), | |||
| 125 | + | theme.Subtle.Render(dateStr), | |||
| 126 | + | theme.Subtle.Render(fmt.Sprintf("(%s)", sizeStr)), | |||
| 127 | + | ) | |||
| 128 | + | fmt.Println(line) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | fmt.Println() | |||
| 132 | + | fmt.Println(theme.Subtle.Render("Use 'dumber logs <id>' to view a session")) | |||
| 133 | + | return nil | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // getSessions returns all session log files, sorted by modification time (newest first). | |||
| 137 | + | func getSessions(logDir string) ([]SessionInfo, error) { | |||
| 138 | + | entries, err := os.ReadDir(logDir) | |||
| 139 | + | if err != nil { | |||
| 140 | + | if os.IsNotExist(err) { | |||
| 141 | + | return nil, nil | |||
| 142 | + | } | |||
| 143 | + | return nil, fmt.Errorf("read log directory: %w", err) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | var sessions []SessionInfo | |||
| 147 | + | for _, entry := range entries { | |||
| 148 | + | if entry.IsDir() { | |||
| 149 | + | continue | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | sessionID, ok := logging.ParseSessionFilename(entry.Name()) | |||
| 153 | + | if !ok { | |||
| 154 | + | continue | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | info, err := entry.Info() | |||
| 158 | + | if err != nil { | |||
| 159 | + | continue | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | sessions = append(sessions, SessionInfo{ | |||
| 163 | + | SessionID: sessionID, | |||
| 164 | + | ShortID: logging.ShortSessionID(sessionID), | |||
| 165 | + | Filename: entry.Name(), | |||
| 166 | + | Path: filepath.Join(logDir, entry.Name()), | |||
| 167 | + | Size: info.Size(), | |||
| 168 | + | ModTime: info.ModTime(), | |||
| 169 | + | }) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // Sort by modification time (newest first) | |||
| 173 | + | sort.Slice(sessions, func(i, j int) bool { | |||
| 174 | + | return sessions[i].ModTime.After(sessions[j].ModTime) | |||
| 175 | + | }) | |||
| 176 | + | ||||
| 177 | + | return sessions, nil | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // findSession finds a session by partial ID match. | |||
| 181 | + | func findSession(logDir, query string) (*SessionInfo, error) { | |||
| 182 | + | sessions, err := getSessions(logDir) | |||
| 183 | + | if err != nil { | |||
| 184 | + | return nil, err | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | if len(sessions) == 0 { | |||
| 188 | + | return nil, fmt.Errorf("no sessions found") | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | query = strings.ToLower(query) | |||
| 192 | + | ||||
| 193 | + | // Try exact short ID match first | |||
| 194 | + | for _, s := range sessions { | |||
| 195 | + | if strings.ToLower(s.ShortID) == query { | |||
| 196 | + | return &s, nil | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // Try partial match on full session ID | |||
| 201 | + | var matches []SessionInfo | |||
| 202 | + | for _, s := range sessions { | |||
| 203 | + | if strings.Contains(strings.ToLower(s.SessionID), query) { | |||
| 204 | + | matches = append(matches, s) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | switch len(matches) { | |||
| 209 | + | case 0: | |||
| 210 | + | return nil, fmt.Errorf("no session matching '%s' found", query) | |||
| 211 | + | case 1: | |||
| 212 | + | return &matches[0], nil | |||
| 213 | + | default: | |||
| 214 | + | // Multiple matches - show them and ask user to be more specific | |||
| 215 | + | var ids []string | |||
| 216 | + | for _, m := range matches { | |||
| 217 | + | ids = append(ids, m.ShortID) | |||
| 218 | + | } | |||
| 219 | + | return nil, fmt.Errorf("multiple sessions match '%s': %s", query, strings.Join(ids, ", ")) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // showSession displays the last N lines of a session log. | |||
| 224 | + | func showSession(logPath string, lines int, theme *styles.Theme) error { | |||
| 225 | + | file, err := os.Open(logPath) | |||
| 226 | + | if err != nil { | |||
| 227 | + | return fmt.Errorf("open log file: %w", err) | |||
| 228 | + | } | |||
| 229 | + | defer file.Close() | |||
| 230 | + | ||||
| 231 | + | // Read all lines | |||
| 232 | + | var allLines []string | |||
| 233 | + | scanner := bufio.NewScanner(file) | |||
| 234 | + | for scanner.Scan() { | |||
| 235 | + | allLines = append(allLines, scanner.Text()) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | if err := scanner.Err(); err != nil { | |||
| 239 | + | return fmt.Errorf("read log file: %w", err) | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // Get last N lines | |||
| 243 | + | start := 0 | |||
| 244 | + | if len(allLines) > lines { | |||
| 245 | + | start = len(allLines) - lines | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | for _, line := range allLines[start:] { | |||
| 249 | + | fmt.Println(colorizeLogLine(line, theme)) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | return nil | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // tailSession follows a session log in real-time. | |||
| 256 | + | func tailSession(logPath string, theme *styles.Theme) error { | |||
| 257 | + | file, err := os.Open(logPath) | |||
| 258 | + | if err != nil { | |||
| 259 | + | return fmt.Errorf("open log file: %w", err) | |||
| 260 | + | } | |||
| 261 | + | defer file.Close() | |||
| 262 | + | ||||
| 263 | + | // Seek to end | |||
| 264 | + | _, _ = file.Seek(0, io.SeekEnd) | |||
| 265 | + | ||||
| 266 | + | fmt.Println(theme.Subtle.Render("Following logs... (Ctrl+C to stop)")) | |||
| 267 | + | fmt.Println() | |||
| 268 | + | ||||
| 269 | + | reader := bufio.NewReader(file) | |||
| 270 | + | for { | |||
| 271 | + | line, err := reader.ReadString('\n') | |||
| 272 | + | if err != nil { | |||
| 273 | + | // No new data, wait a bit | |||
| 274 | + | time.Sleep(100 * time.Millisecond) | |||
| 275 | + | continue | |||
| 276 | + | } | |||
| 277 | + | fmt.Print(colorizeLogLine(line, theme)) | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // logEntry represents a parsed JSON log entry. | |||
| 282 | + | type logEntry struct { | |||
| 283 | + | Level string `json:"level"` | |||
| 284 | + | Time string `json:"time"` | |||
| 285 | + | Message string `json:"message"` | |||
| 286 | + | Session string `json:"session"` | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | // colorizeLogLine adds color based on log level. | |||
| 290 | + | func colorizeLogLine(line string, theme *styles.Theme) string { | |||
| 291 | + | // Try to parse as JSON | |||
| 292 | + | var entry logEntry | |||
| 293 | + | if err := json.Unmarshal([]byte(line), &entry); err == nil { | |||
| 294 | + | return formatJSONLogLine(entry, theme) | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | // Fallback to pattern matching for non-JSON logs | |||
| 298 | + | switch { | |||
| 299 | + | case containsAny(line, "ERR", "ERROR", "error"): | |||
| 300 | + | return theme.ErrorStyle.Render(line) | |||
| 301 | + | case containsAny(line, "WRN", "WARN", "warn"): | |||
| 302 | + | return theme.WarningStyle.Render(line) | |||
| 303 | + | case containsAny(line, "DBG", "DEBUG", "debug"): | |||
| 304 | + | return theme.Subtle.Render(line) | |||
| 305 | + | default: | |||
| 306 | + | return line | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | // formatJSONLogLine formats a parsed JSON log entry with colors. | |||
| 311 | + | func formatJSONLogLine(entry logEntry, theme *styles.Theme) string { | |||
| 312 | + | // Parse time if present | |||
| 313 | + | timeStr := "" | |||
| 314 | + | if entry.Time != "" { | |||
| 315 | + | if t, err := time.Parse(time.RFC3339, entry.Time); err == nil { | |||
| 316 | + | timeStr = t.Format("15:04:05") | |||
| 317 | + | } else { | |||
| 318 | + | timeStr = entry.Time | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | // Level styling | |||
| 323 | + | var levelStr string | |||
| 324 | + | switch entry.Level { | |||
| 325 | + | case "error": | |||
| 326 | + | levelStr = theme.ErrorStyle.Render("ERR") | |||
| 327 | + | case "warn": | |||
| 328 | + | levelStr = theme.WarningStyle.Render("WRN") | |||
| 329 | + | case "info": | |||
| 330 | + | levelStr = theme.Highlight.Render("INF") | |||
| 331 | + | case "debug": | |||
| 332 | + | levelStr = theme.Subtle.Render("DBG") | |||
| 333 | + | case "trace": | |||
| 334 | + | levelStr = theme.Subtle.Render("TRC") | |||
| 335 | + | default: | |||
| 336 | + | levelStr = entry.Level | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | return fmt.Sprintf("%s %s %s", theme.Subtle.Render(timeStr), levelStr, entry.Message) | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | // containsAny checks if s contains any of the substrings. | |||
| 343 | + | func containsAny(s string, substrs ...string) bool { | |||
| 344 | + | sLower := strings.ToLower(s) | |||
| 345 | + | for _, substr := range substrs { | |||
| 346 | + | if strings.Contains(sLower, strings.ToLower(substr)) { | |||
| 347 | + | return true | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | return false | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | // logsClearCmd clears old session logs. | |||
| 354 | + | var logsClearCmd = &cobra.Command{ | |||
| 355 | + | Use: "clear", | |||
| 356 | + | Short: "Clear old log files", | |||
| 357 | + | Long: `Remove old session log files. | |||
| 358 | + | ||||
| 359 | + | By default, removes sessions older than the configured MaxAge (default 7 days). | |||
| 360 | + | Use --all to remove all sessions.`, | |||
| 361 | + | RunE: runLogsClear, | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | func init() { | |||
| 365 | + | logsCmd.AddCommand(logsClearCmd) | |||
| 366 | + | logsClearCmd.Flags().BoolVar(&logsClearAll, "all", false, "remove all session logs") | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | func runLogsClear(cmd *cobra.Command, args []string) error { | |||
| 370 | + | app := GetApp() | |||
| 371 | + | if app == nil { | |||
| 372 | + | return fmt.Errorf("app not initialized") | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | logDir := getLogDir() | |||
| 376 | + | sessions, err := getSessions(logDir) | |||
| 377 | + | if err != nil { | |||
| 378 | + | return err | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | if len(sessions) == 0 { | |||
| 382 | + | fmt.Println(app.Theme.Subtle.Render("No logs to clear")) | |||
| 383 | + | return nil | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | // Get MaxAge from config (default 7 days) | |||
| 387 | + | maxAge := 7 | |||
| 388 | + | if app.Config != nil && app.Config.Logging.MaxAge > 0 { | |||
| 389 | + | maxAge = app.Config.Logging.MaxAge | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | cutoff := time.Now().AddDate(0, 0, -maxAge) | |||
| 393 | + | var removed int | |||
| 394 | + | ||||
| 395 | + | for _, s := range sessions { | |||
| 396 | + | shouldRemove := logsClearAll || s.ModTime.Before(cutoff) | |||
| 397 | + | if !shouldRemove { | |||
| 398 | + | continue | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | if err := os.Remove(s.Path); err != nil { | |||
| 402 | + | fmt.Printf("%s %s: %v\n", | |||
| 403 | + | app.Theme.ErrorStyle.Render("\u2717"), | |||
| 404 | + | s.ShortID, | |||
| 405 | + | err, | |||
| 406 | + | ) | |||
| 407 | + | continue | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | fmt.Printf("%s %s (%s)\n", | |||
| 411 | + | app.Theme.SuccessStyle.Render("\u2713"), | |||
| 412 | + | s.ShortID, | |||
| 413 | + | formatSize(s.Size), | |||
| 414 | + | ) | |||
| 415 | + | removed++ | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | if removed == 0 { | |||
| 419 | + | fmt.Println(app.Theme.Subtle.Render("No sessions older than " + fmt.Sprintf("%d days", maxAge))) | |||
| 420 | + | } else { | |||
| 421 | + | fmt.Printf("\n%s\n", app.Theme.SuccessStyle.Render(fmt.Sprintf("Cleared %d session(s)", removed))) | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | return nil | |||
| 425 | + | } | |||
A internal/cli/cmd/purge.go +160 -0
| @@ -0,0 +1,160 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/spf13/cobra" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | var ( | |
| 14 | + | purgeForce bool | |
| 15 | + | purgeAll bool | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | var purgeCmd = &cobra.Command{ | |
| 19 | + | Use: "purge", | |
| 20 | + | Short: "Remove all dumber data and configuration", | |
| 21 | + | Long: `Remove all dumber XDG directories (config, data, state, cache). | |
| 22 | + | ||
| 23 | + | This will delete: | |
| 24 | + | - ~/.config/dumber/ (configuration) | |
| 25 | + | - ~/.local/share/dumber/ (data, database) | |
| 26 | + | - ~/.local/state/dumber/ (state, logs) | |
| 27 | + | - ~/.cache/dumber/ (cache) | |
| 28 | + | ||
| 29 | + | Use --force to skip confirmation prompt.`, | |
| 30 | + | RunE: runPurge, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func init() { | |
| 34 | + | rootCmd.AddCommand(purgeCmd) | |
| 35 | + | purgeCmd.Flags().BoolVarP(&purgeForce, "force", "f", false, "skip confirmation prompt") | |
| 36 | + | purgeCmd.Flags().BoolVar(&purgeAll, "all", true, "remove all directories (default)") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func runPurge(cmd *cobra.Command, args []string) error { | |
| 40 | + | home, err := os.UserHomeDir() | |
| 41 | + | if err != nil { | |
| 42 | + | return fmt.Errorf("get home dir: %w", err) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Build list of directories to remove | |
| 46 | + | dirs := []struct { | |
| 47 | + | path string | |
| 48 | + | desc string | |
| 49 | + | }{ | |
| 50 | + | {getXDGPath("XDG_CONFIG_HOME", home, ".config", "dumber"), "config"}, | |
| 51 | + | {getXDGPath("XDG_DATA_HOME", home, ".local/share", "dumber"), "data"}, | |
| 52 | + | {getXDGPath("XDG_STATE_HOME", home, ".local/state", "dumber"), "state"}, | |
| 53 | + | {getXDGPath("XDG_CACHE_HOME", home, ".cache", "dumber"), "cache"}, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Check which directories exist | |
| 57 | + | var existing []struct { | |
| 58 | + | path string | |
| 59 | + | desc string | |
| 60 | + | size int64 | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | for _, d := range dirs { | |
| 64 | + | if info, err := os.Stat(d.path); err == nil && info.IsDir() { | |
| 65 | + | size := getDirSize(d.path) | |
| 66 | + | existing = append(existing, struct { | |
| 67 | + | path string | |
| 68 | + | desc string | |
| 69 | + | size int64 | |
| 70 | + | }{d.path, d.desc, size}) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | if len(existing) == 0 { | |
| 75 | + | fmt.Println("No dumber directories found.") | |
| 76 | + | return nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // Show what will be deleted | |
| 80 | + | fmt.Println("The following directories will be removed:") | |
| 81 | + | fmt.Println() | |
| 82 | + | for _, d := range existing { | |
| 83 | + | fmt.Printf(" \u2022 %s (%s, %s)\n", d.path, d.desc, formatSize(d.size)) | |
| 84 | + | } | |
| 85 | + | fmt.Println() | |
| 86 | + | ||
| 87 | + | // Confirm unless --force | |
| 88 | + | if !purgeForce { | |
| 89 | + | fmt.Print("Are you sure? [y/N]: ") | |
| 90 | + | reader := bufio.NewReader(os.Stdin) | |
| 91 | + | response, _ := reader.ReadString('\n') | |
| 92 | + | response = strings.TrimSpace(strings.ToLower(response)) | |
| 93 | + | ||
| 94 | + | if response != "y" && response != "yes" { | |
| 95 | + | fmt.Println("Aborted.") | |
| 96 | + | return nil | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Remove directories | |
| 101 | + | var errors []string | |
| 102 | + | for _, d := range existing { | |
| 103 | + | if err := os.RemoveAll(d.path); err != nil { | |
| 104 | + | errors = append(errors, fmt.Sprintf("%s: %v", d.path, err)) | |
| 105 | + | } else { | |
| 106 | + | fmt.Printf("\u2713 Removed %s\n", d.path) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | if len(errors) > 0 { | |
| 111 | + | fmt.Println() | |
| 112 | + | fmt.Println("Errors:") | |
| 113 | + | for _, e := range errors { | |
| 114 | + | fmt.Printf(" \u2717 %s\n", e) | |
| 115 | + | } | |
| 116 | + | return fmt.Errorf("failed to remove some directories") | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | fmt.Println() | |
| 120 | + | fmt.Println("Purge complete.") | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // getXDGPath returns the XDG path for dumber. | |
| 125 | + | func getXDGPath(envVar, home, defaultSuffix, appName string) string { | |
| 126 | + | base := os.Getenv(envVar) | |
| 127 | + | if base == "" { | |
| 128 | + | base = filepath.Join(home, defaultSuffix) | |
| 129 | + | } | |
| 130 | + | return filepath.Join(base, appName) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // getDirSize calculates the total size of a directory. | |
| 134 | + | func getDirSize(path string) int64 { | |
| 135 | + | var size int64 | |
| 136 | + | _ = filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { | |
| 137 | + | if err != nil { | |
| 138 | + | return nil | |
| 139 | + | } | |
| 140 | + | if !info.IsDir() { | |
| 141 | + | size += info.Size() | |
| 142 | + | } | |
| 143 | + | return nil | |
| 144 | + | }) | |
| 145 | + | return size | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // formatSize formats bytes as human-readable size. | |
| 149 | + | func formatSize(bytes int64) string { | |
| 150 | + | const unit = 1024 | |
| 151 | + | if bytes < unit { | |
| 152 | + | return fmt.Sprintf("%d B", bytes) | |
| 153 | + | } | |
| 154 | + | div, exp := int64(unit), 0 | |
| 155 | + | for n := bytes / unit; n >= unit; n /= unit { | |
| 156 | + | div *= unit | |
| 157 | + | exp++ | |
| 158 | + | } | |
| 159 | + | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) | |
| 160 | + | } | |
| @@ -0,0 +1,160 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/spf13/cobra" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | var ( | |||
| 14 | + | purgeForce bool | |||
| 15 | + | purgeAll bool | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | var purgeCmd = &cobra.Command{ | |||
| 19 | + | Use: "purge", | |||
| 20 | + | Short: "Remove all dumber data and configuration", | |||
| 21 | + | Long: `Remove all dumber XDG directories (config, data, state, cache). | |||
| 22 | + | ||||
| 23 | + | This will delete: | |||
| 24 | + | - ~/.config/dumber/ (configuration) | |||
| 25 | + | - ~/.local/share/dumber/ (data, database) | |||
| 26 | + | - ~/.local/state/dumber/ (state, logs) | |||
| 27 | + | - ~/.cache/dumber/ (cache) | |||
| 28 | + | ||||
| 29 | + | Use --force to skip confirmation prompt.`, | |||
| 30 | + | RunE: runPurge, | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func init() { | |||
| 34 | + | rootCmd.AddCommand(purgeCmd) | |||
| 35 | + | purgeCmd.Flags().BoolVarP(&purgeForce, "force", "f", false, "skip confirmation prompt") | |||
| 36 | + | purgeCmd.Flags().BoolVar(&purgeAll, "all", true, "remove all directories (default)") | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func runPurge(cmd *cobra.Command, args []string) error { | |||
| 40 | + | home, err := os.UserHomeDir() | |||
| 41 | + | if err != nil { | |||
| 42 | + | return fmt.Errorf("get home dir: %w", err) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Build list of directories to remove | |||
| 46 | + | dirs := []struct { | |||
| 47 | + | path string | |||
| 48 | + | desc string | |||
| 49 | + | }{ | |||
| 50 | + | {getXDGPath("XDG_CONFIG_HOME", home, ".config", "dumber"), "config"}, | |||
| 51 | + | {getXDGPath("XDG_DATA_HOME", home, ".local/share", "dumber"), "data"}, | |||
| 52 | + | {getXDGPath("XDG_STATE_HOME", home, ".local/state", "dumber"), "state"}, | |||
| 53 | + | {getXDGPath("XDG_CACHE_HOME", home, ".cache", "dumber"), "cache"}, | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // Check which directories exist | |||
| 57 | + | var existing []struct { | |||
| 58 | + | path string | |||
| 59 | + | desc string | |||
| 60 | + | size int64 | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | for _, d := range dirs { | |||
| 64 | + | if info, err := os.Stat(d.path); err == nil && info.IsDir() { | |||
| 65 | + | size := getDirSize(d.path) | |||
| 66 | + | existing = append(existing, struct { | |||
| 67 | + | path string | |||
| 68 | + | desc string | |||
| 69 | + | size int64 | |||
| 70 | + | }{d.path, d.desc, size}) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | if len(existing) == 0 { | |||
| 75 | + | fmt.Println("No dumber directories found.") | |||
| 76 | + | return nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // Show what will be deleted | |||
| 80 | + | fmt.Println("The following directories will be removed:") | |||
| 81 | + | fmt.Println() | |||
| 82 | + | for _, d := range existing { | |||
| 83 | + | fmt.Printf(" \u2022 %s (%s, %s)\n", d.path, d.desc, formatSize(d.size)) | |||
| 84 | + | } | |||
| 85 | + | fmt.Println() | |||
| 86 | + | ||||
| 87 | + | // Confirm unless --force | |||
| 88 | + | if !purgeForce { | |||
| 89 | + | fmt.Print("Are you sure? [y/N]: ") | |||
| 90 | + | reader := bufio.NewReader(os.Stdin) | |||
| 91 | + | response, _ := reader.ReadString('\n') | |||
| 92 | + | response = strings.TrimSpace(strings.ToLower(response)) | |||
| 93 | + | ||||
| 94 | + | if response != "y" && response != "yes" { | |||
| 95 | + | fmt.Println("Aborted.") | |||
| 96 | + | return nil | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Remove directories | |||
| 101 | + | var errors []string | |||
| 102 | + | for _, d := range existing { | |||
| 103 | + | if err := os.RemoveAll(d.path); err != nil { | |||
| 104 | + | errors = append(errors, fmt.Sprintf("%s: %v", d.path, err)) | |||
| 105 | + | } else { | |||
| 106 | + | fmt.Printf("\u2713 Removed %s\n", d.path) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | if len(errors) > 0 { | |||
| 111 | + | fmt.Println() | |||
| 112 | + | fmt.Println("Errors:") | |||
| 113 | + | for _, e := range errors { | |||
| 114 | + | fmt.Printf(" \u2717 %s\n", e) | |||
| 115 | + | } | |||
| 116 | + | return fmt.Errorf("failed to remove some directories") | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | fmt.Println() | |||
| 120 | + | fmt.Println("Purge complete.") | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // getXDGPath returns the XDG path for dumber. | |||
| 125 | + | func getXDGPath(envVar, home, defaultSuffix, appName string) string { | |||
| 126 | + | base := os.Getenv(envVar) | |||
| 127 | + | if base == "" { | |||
| 128 | + | base = filepath.Join(home, defaultSuffix) | |||
| 129 | + | } | |||
| 130 | + | return filepath.Join(base, appName) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // getDirSize calculates the total size of a directory. | |||
| 134 | + | func getDirSize(path string) int64 { | |||
| 135 | + | var size int64 | |||
| 136 | + | _ = filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { | |||
| 137 | + | if err != nil { | |||
| 138 | + | return nil | |||
| 139 | + | } | |||
| 140 | + | if !info.IsDir() { | |||
| 141 | + | size += info.Size() | |||
| 142 | + | } | |||
| 143 | + | return nil | |||
| 144 | + | }) | |||
| 145 | + | return size | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // formatSize formats bytes as human-readable size. | |||
| 149 | + | func formatSize(bytes int64) string { | |||
| 150 | + | const unit = 1024 | |||
| 151 | + | if bytes < unit { | |||
| 152 | + | return fmt.Sprintf("%d B", bytes) | |||
| 153 | + | } | |||
| 154 | + | div, exp := int64(unit), 0 | |||
| 155 | + | for n := bytes / unit; n >= unit; n /= unit { | |||
| 156 | + | div *= unit | |||
| 157 | + | exp++ | |||
| 158 | + | } | |||
| 159 | + | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) | |||
| 160 | + | } | |||
A internal/cli/cmd/root.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | // Package cmd provides Cobra CLI commands for dumber. | |
| 2 | + | package cmd | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | ||
| 8 | + | "github.com/spf13/cobra" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/cli" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | var ( | |
| 14 | + | app *cli.App | |
| 15 | + | rootCmd = &cobra.Command{ | |
| 16 | + | Use: "dumber", | |
| 17 | + | Short: "A minimal web browser", | |
| 18 | + | Long: `Dumber is a minimal, keyboard-driven web browser built with GTK4 and WebKitGTK.`, | |
| 19 | + | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { | |
| 20 | + | // Skip initialization for help commands | |
| 21 | + | if cmd.Name() == "help" || cmd.Name() == "completion" { | |
| 22 | + | return nil | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | var err error | |
| 26 | + | app, err = cli.NewApp() | |
| 27 | + | if err != nil { | |
| 28 | + | return fmt.Errorf("initialize app: %w", err) | |
| 29 | + | } | |
| 30 | + | return nil | |
| 31 | + | }, | |
| 32 | + | PersistentPostRun: func(cmd *cobra.Command, args []string) { | |
| 33 | + | if app != nil { | |
| 34 | + | _ = app.Close() | |
| 35 | + | } | |
| 36 | + | }, | |
| 37 | + | } | |
| 38 | + | ) | |
| 39 | + | ||
| 40 | + | // Execute runs the root command. | |
| 41 | + | func Execute() { | |
| 42 | + | if err := rootCmd.Execute(); err != nil { | |
| 43 | + | fmt.Fprintln(os.Stderr, err) | |
| 44 | + | os.Exit(1) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // GetApp returns the initialized app (for use by subcommands). | |
| 49 | + | func GetApp() *cli.App { | |
| 50 | + | return app | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // browseCmd is a placeholder for help - actual execution is in main.go | |
| 54 | + | var browseCmd = &cobra.Command{ | |
| 55 | + | Use: "browse [url]", | |
| 56 | + | Short: "Launch the graphical browser", | |
| 57 | + | Long: `Launch the GTK4 graphical browser. | |
| 58 | + | ||
| 59 | + | If a URL is provided, navigate to it. Otherwise, open the homepage. | |
| 60 | + | ||
| 61 | + | Examples: | |
| 62 | + | dumber browse # Open browser to homepage | |
| 63 | + | dumber browse example.com # Open browser to URL`, | |
| 64 | + | Run: func(cmd *cobra.Command, args []string) { | |
| 65 | + | // This is handled by main.go before cobra runs | |
| 66 | + | }, | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func init() { | |
| 70 | + | rootCmd.AddCommand(browseCmd) | |
| 71 | + | } | |
| @@ -0,0 +1,71 @@ | |||||
| 1 | + | // Package cmd provides Cobra CLI commands for dumber. | |||
| 2 | + | package cmd | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | ||||
| 8 | + | "github.com/spf13/cobra" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/cli" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | var ( | |||
| 14 | + | app *cli.App | |||
| 15 | + | rootCmd = &cobra.Command{ | |||
| 16 | + | Use: "dumber", | |||
| 17 | + | Short: "A minimal web browser", | |||
| 18 | + | Long: `Dumber is a minimal, keyboard-driven web browser built with GTK4 and WebKitGTK.`, | |||
| 19 | + | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { | |||
| 20 | + | // Skip initialization for help commands | |||
| 21 | + | if cmd.Name() == "help" || cmd.Name() == "completion" { | |||
| 22 | + | return nil | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | var err error | |||
| 26 | + | app, err = cli.NewApp() | |||
| 27 | + | if err != nil { | |||
| 28 | + | return fmt.Errorf("initialize app: %w", err) | |||
| 29 | + | } | |||
| 30 | + | return nil | |||
| 31 | + | }, | |||
| 32 | + | PersistentPostRun: func(cmd *cobra.Command, args []string) { | |||
| 33 | + | if app != nil { | |||
| 34 | + | _ = app.Close() | |||
| 35 | + | } | |||
| 36 | + | }, | |||
| 37 | + | } | |||
| 38 | + | ) | |||
| 39 | + | ||||
| 40 | + | // Execute runs the root command. | |||
| 41 | + | func Execute() { | |||
| 42 | + | if err := rootCmd.Execute(); err != nil { | |||
| 43 | + | fmt.Fprintln(os.Stderr, err) | |||
| 44 | + | os.Exit(1) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // GetApp returns the initialized app (for use by subcommands). | |||
| 49 | + | func GetApp() *cli.App { | |||
| 50 | + | return app | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // browseCmd is a placeholder for help - actual execution is in main.go | |||
| 54 | + | var browseCmd = &cobra.Command{ | |||
| 55 | + | Use: "browse [url]", | |||
| 56 | + | Short: "Launch the graphical browser", | |||
| 57 | + | Long: `Launch the GTK4 graphical browser. | |||
| 58 | + | ||||
| 59 | + | If a URL is provided, navigate to it. Otherwise, open the homepage. | |||
| 60 | + | ||||
| 61 | + | Examples: | |||
| 62 | + | dumber browse # Open browser to homepage | |||
| 63 | + | dumber browse example.com # Open browser to URL`, | |||
| 64 | + | Run: func(cmd *cobra.Command, args []string) { | |||
| 65 | + | // This is handled by main.go before cobra runs | |||
| 66 | + | }, | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func init() { | |||
| 70 | + | rootCmd.AddCommand(browseCmd) | |||
| 71 | + | } | |||
A internal/cli/model/cleanup.go +153 -0
| @@ -0,0 +1,153 @@ | |||
| 1 | + | package model | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | tea "github.com/charmbracelet/bubbletea" | |
| 8 | + | "github.com/charmbracelet/lipgloss" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // CleanupModel wraps styles.CleanupModel for standalone use. | |
| 15 | + | type CleanupModel struct { | |
| 16 | + | cleanup styles.CleanupModel | |
| 17 | + | cleaned bool | |
| 18 | + | cleaning bool | |
| 19 | + | err error | |
| 20 | + | ||
| 21 | + | historyUC *usecase.SearchHistoryUseCase | |
| 22 | + | theme *styles.Theme | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // NewCleanupModel creates a new cleanup command model. | |
| 26 | + | func NewCleanupModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) CleanupModel { | |
| 27 | + | return CleanupModel{ | |
| 28 | + | cleanup: styles.NewCleanup(theme), | |
| 29 | + | historyUC: historyUC, | |
| 30 | + | theme: theme, | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // cleanupCompleteMsg is sent when cleanup is done. | |
| 35 | + | type cleanupCompleteMsg struct { | |
| 36 | + | err error | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Init implements tea.Model. | |
| 40 | + | func (m CleanupModel) Init() tea.Cmd { | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Update implements tea.Model. | |
| 45 | + | func (m CleanupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 46 | + | switch msg := msg.(type) { | |
| 47 | + | case cleanupCompleteMsg: | |
| 48 | + | m.cleaning = false | |
| 49 | + | if msg.err != nil { | |
| 50 | + | m.err = msg.err | |
| 51 | + | } else { | |
| 52 | + | m.cleaned = true | |
| 53 | + | } | |
| 54 | + | return m, nil | |
| 55 | + | ||
| 56 | + | case tea.KeyMsg: | |
| 57 | + | // If cleaned, any key exits | |
| 58 | + | if m.cleaned || m.err != nil { | |
| 59 | + | return m, tea.Quit | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // If cleaning, ignore input | |
| 63 | + | if m.cleaning { | |
| 64 | + | return m, nil | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Update inner cleanup model | |
| 69 | + | cleanup, cmd := m.cleanup.Update(msg) | |
| 70 | + | m.cleanup = cleanup | |
| 71 | + | ||
| 72 | + | if m.cleanup.Done() { | |
| 73 | + | if m.cleanup.Cancelled { | |
| 74 | + | return m, tea.Quit | |
| 75 | + | } | |
| 76 | + | if m.cleanup.Confirmed { | |
| 77 | + | m.cleaning = true | |
| 78 | + | return m, m.performCleanup(m.cleanup.SelectedRange()) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | return m, cmd | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // performCleanup executes the cleanup operation. | |
| 86 | + | func (m CleanupModel) performCleanup(cleanupRange styles.CleanupRange) tea.Cmd { | |
| 87 | + | return func() tea.Msg { | |
| 88 | + | ctx := context.Background() | |
| 89 | + | cutoff := cleanupRange.CutoffTime() | |
| 90 | + | ||
| 91 | + | var err error | |
| 92 | + | if cutoff.IsZero() { | |
| 93 | + | err = m.historyUC.ClearAll(ctx) | |
| 94 | + | } else { | |
| 95 | + | err = m.historyUC.ClearOlderThan(ctx, cutoff) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | return cleanupCompleteMsg{err: err} | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // View implements tea.Model. | |
| 103 | + | func (m CleanupModel) View() string { | |
| 104 | + | t := m.theme | |
| 105 | + | ||
| 106 | + | if m.cleaning { | |
| 107 | + | spinner := styles.NewLoading(t, "Cleaning up...") | |
| 108 | + | return t.Box.Render(spinner.View()) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | if m.err != nil { | |
| 112 | + | content := lipgloss.JoinVertical( | |
| 113 | + | lipgloss.Left, | |
| 114 | + | t.ErrorStyle.Render("Error: "+m.err.Error()), | |
| 115 | + | "", | |
| 116 | + | t.Subtle.Render("Press any key to exit"), | |
| 117 | + | ) | |
| 118 | + | return t.Box.Render(content) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | if m.cleaned { | |
| 122 | + | content := lipgloss.JoinVertical( | |
| 123 | + | lipgloss.Left, | |
| 124 | + | t.SuccessStyle.Render("History cleaned successfully!"), | |
| 125 | + | "", | |
| 126 | + | t.Subtle.Render("Press any key to exit"), | |
| 127 | + | ) | |
| 128 | + | return t.Box.Render(content) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | return m.cleanup.View() | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Ensure interface compliance. | |
| 135 | + | var _ tea.Model = (*CleanupModel)(nil) | |
| 136 | + | ||
| 137 | + | // rangeLabelFor returns a human-readable label for cleanup range. | |
| 138 | + | func rangeLabelFor(r styles.CleanupRange) string { | |
| 139 | + | switch r { | |
| 140 | + | case styles.CleanupRangeLastHour: | |
| 141 | + | return "last hour" | |
| 142 | + | case styles.CleanupRangeLast24Hours: | |
| 143 | + | return "last 24 hours" | |
| 144 | + | case styles.CleanupRangeLast7Days: | |
| 145 | + | return "last 7 days" | |
| 146 | + | case styles.CleanupRangeLast30Days: | |
| 147 | + | return "last 30 days" | |
| 148 | + | case styles.CleanupRangeAll: | |
| 149 | + | return "all time" | |
| 150 | + | default: | |
| 151 | + | return fmt.Sprintf("range %d", r) | |
| 152 | + | } | |
| 153 | + | } | |
| @@ -0,0 +1,153 @@ | |||||
| 1 | + | package model | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 8 | + | "github.com/charmbracelet/lipgloss" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 11 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // CleanupModel wraps styles.CleanupModel for standalone use. | |||
| 15 | + | type CleanupModel struct { | |||
| 16 | + | cleanup styles.CleanupModel | |||
| 17 | + | cleaned bool | |||
| 18 | + | cleaning bool | |||
| 19 | + | err error | |||
| 20 | + | ||||
| 21 | + | historyUC *usecase.SearchHistoryUseCase | |||
| 22 | + | theme *styles.Theme | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // NewCleanupModel creates a new cleanup command model. | |||
| 26 | + | func NewCleanupModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) CleanupModel { | |||
| 27 | + | return CleanupModel{ | |||
| 28 | + | cleanup: styles.NewCleanup(theme), | |||
| 29 | + | historyUC: historyUC, | |||
| 30 | + | theme: theme, | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // cleanupCompleteMsg is sent when cleanup is done. | |||
| 35 | + | type cleanupCompleteMsg struct { | |||
| 36 | + | err error | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Init implements tea.Model. | |||
| 40 | + | func (m CleanupModel) Init() tea.Cmd { | |||
| 41 | + | return nil | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // Update implements tea.Model. | |||
| 45 | + | func (m CleanupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 46 | + | switch msg := msg.(type) { | |||
| 47 | + | case cleanupCompleteMsg: | |||
| 48 | + | m.cleaning = false | |||
| 49 | + | if msg.err != nil { | |||
| 50 | + | m.err = msg.err | |||
| 51 | + | } else { | |||
| 52 | + | m.cleaned = true | |||
| 53 | + | } | |||
| 54 | + | return m, nil | |||
| 55 | + | ||||
| 56 | + | case tea.KeyMsg: | |||
| 57 | + | // If cleaned, any key exits | |||
| 58 | + | if m.cleaned || m.err != nil { | |||
| 59 | + | return m, tea.Quit | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // If cleaning, ignore input | |||
| 63 | + | if m.cleaning { | |||
| 64 | + | return m, nil | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // Update inner cleanup model | |||
| 69 | + | cleanup, cmd := m.cleanup.Update(msg) | |||
| 70 | + | m.cleanup = cleanup | |||
| 71 | + | ||||
| 72 | + | if m.cleanup.Done() { | |||
| 73 | + | if m.cleanup.Cancelled { | |||
| 74 | + | return m, tea.Quit | |||
| 75 | + | } | |||
| 76 | + | if m.cleanup.Confirmed { | |||
| 77 | + | m.cleaning = true | |||
| 78 | + | return m, m.performCleanup(m.cleanup.SelectedRange()) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | return m, cmd | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // performCleanup executes the cleanup operation. | |||
| 86 | + | func (m CleanupModel) performCleanup(cleanupRange styles.CleanupRange) tea.Cmd { | |||
| 87 | + | return func() tea.Msg { | |||
| 88 | + | ctx := context.Background() | |||
| 89 | + | cutoff := cleanupRange.CutoffTime() | |||
| 90 | + | ||||
| 91 | + | var err error | |||
| 92 | + | if cutoff.IsZero() { | |||
| 93 | + | err = m.historyUC.ClearAll(ctx) | |||
| 94 | + | } else { | |||
| 95 | + | err = m.historyUC.ClearOlderThan(ctx, cutoff) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | return cleanupCompleteMsg{err: err} | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // View implements tea.Model. | |||
| 103 | + | func (m CleanupModel) View() string { | |||
| 104 | + | t := m.theme | |||
| 105 | + | ||||
| 106 | + | if m.cleaning { | |||
| 107 | + | spinner := styles.NewLoading(t, "Cleaning up...") | |||
| 108 | + | return t.Box.Render(spinner.View()) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | if m.err != nil { | |||
| 112 | + | content := lipgloss.JoinVertical( | |||
| 113 | + | lipgloss.Left, | |||
| 114 | + | t.ErrorStyle.Render("Error: "+m.err.Error()), | |||
| 115 | + | "", | |||
| 116 | + | t.Subtle.Render("Press any key to exit"), | |||
| 117 | + | ) | |||
| 118 | + | return t.Box.Render(content) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | if m.cleaned { | |||
| 122 | + | content := lipgloss.JoinVertical( | |||
| 123 | + | lipgloss.Left, | |||
| 124 | + | t.SuccessStyle.Render("History cleaned successfully!"), | |||
| 125 | + | "", | |||
| 126 | + | t.Subtle.Render("Press any key to exit"), | |||
| 127 | + | ) | |||
| 128 | + | return t.Box.Render(content) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | return m.cleanup.View() | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // Ensure interface compliance. | |||
| 135 | + | var _ tea.Model = (*CleanupModel)(nil) | |||
| 136 | + | ||||
| 137 | + | // rangeLabelFor returns a human-readable label for cleanup range. | |||
| 138 | + | func rangeLabelFor(r styles.CleanupRange) string { | |||
| 139 | + | switch r { | |||
| 140 | + | case styles.CleanupRangeLastHour: | |||
| 141 | + | return "last hour" | |||
| 142 | + | case styles.CleanupRangeLast24Hours: | |||
| 143 | + | return "last 24 hours" | |||
| 144 | + | case styles.CleanupRangeLast7Days: | |||
| 145 | + | return "last 7 days" | |||
| 146 | + | case styles.CleanupRangeLast30Days: | |||
| 147 | + | return "last 30 days" | |||
| 148 | + | case styles.CleanupRangeAll: | |||
| 149 | + | return "all time" | |||
| 150 | + | default: | |||
| 151 | + | return fmt.Sprintf("range %d", r) | |||
| 152 | + | } | |||
| 153 | + | } | |||
A internal/cli/model/dmenu.go +241 -0
| @@ -0,0 +1,241 @@ | |||
| 1 | + | package model | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/charmbracelet/bubbles/help" | |
| 7 | + | "github.com/charmbracelet/bubbles/key" | |
| 8 | + | "github.com/charmbracelet/bubbles/list" | |
| 9 | + | "github.com/charmbracelet/bubbles/textinput" | |
| 10 | + | tea "github.com/charmbracelet/bubbletea" | |
| 11 | + | "github.com/charmbracelet/lipgloss" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 14 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // DmenuModel is the Bubble Tea model for interactive dmenu. | |
| 18 | + | type DmenuModel struct { | |
| 19 | + | // UI components | |
| 20 | + | list list.Model | |
| 21 | + | search textinput.Model | |
| 22 | + | help help.Model | |
| 23 | + | keys styles.DmenuKeyMap | |
| 24 | + | ||
| 25 | + | // State | |
| 26 | + | allItems []styles.HistoryItem | |
| 27 | + | selected string // Selected URL | |
| 28 | + | searchQuery string | |
| 29 | + | width int | |
| 30 | + | height int | |
| 31 | + | err error | |
| 32 | + | ||
| 33 | + | // Dependencies | |
| 34 | + | historyUC *usecase.SearchHistoryUseCase | |
| 35 | + | theme *styles.Theme | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // NewDmenuModel creates a new dmenu model. | |
| 39 | + | func NewDmenuModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) DmenuModel { | |
| 40 | + | // Create search input | |
| 41 | + | search := styles.NewSearchInput(theme) | |
| 42 | + | search.Focus() | |
| 43 | + | ||
| 44 | + | // Create help | |
| 45 | + | helpModel := styles.NewStyledHelp(theme) | |
| 46 | + | ||
| 47 | + | // Create keybindings | |
| 48 | + | keys := styles.DefaultDmenuKeyMap() | |
| 49 | + | ||
| 50 | + | return DmenuModel{ | |
| 51 | + | search: search, | |
| 52 | + | help: helpModel, | |
| 53 | + | keys: keys, | |
| 54 | + | historyUC: historyUC, | |
| 55 | + | theme: theme, | |
| 56 | + | width: 80, | |
| 57 | + | height: 24, | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // dmenuLoadedMsg is sent when entries are loaded. | |
| 62 | + | type dmenuLoadedMsg struct { | |
| 63 | + | items []styles.HistoryItem | |
| 64 | + | err error | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Init implements tea.Model. | |
| 68 | + | func (m DmenuModel) Init() tea.Cmd { | |
| 69 | + | return tea.Batch( | |
| 70 | + | textinput.Blink, | |
| 71 | + | m.loadEntries, | |
| 72 | + | ) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // loadEntries loads history entries. | |
| 76 | + | func (m DmenuModel) loadEntries() tea.Msg { | |
| 77 | + | ctx := context.Background() | |
| 78 | + | ||
| 79 | + | entries, err := m.historyUC.GetRecent(ctx, 500, 0) | |
| 80 | + | if err != nil { | |
| 81 | + | return dmenuLoadedMsg{err: err} | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | items := make([]styles.HistoryItem, len(entries)) | |
| 85 | + | for i, e := range entries { | |
| 86 | + | items[i] = styles.HistoryItem{ | |
| 87 | + | ID: e.ID, | |
| 88 | + | URL: e.URL, | |
| 89 | + | Title: e.Title, | |
| 90 | + | Domain: getDomain(e.URL), | |
| 91 | + | VisitCount: int(e.VisitCount), | |
| 92 | + | LastVisited: e.LastVisited, | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | return dmenuLoadedMsg{items: items} | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // Update implements tea.Model. | |
| 100 | + | func (m DmenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 101 | + | var cmds []tea.Cmd | |
| 102 | + | ||
| 103 | + | switch msg := msg.(type) { | |
| 104 | + | case tea.WindowSizeMsg: | |
| 105 | + | m.width = msg.Width | |
| 106 | + | m.height = msg.Height | |
| 107 | + | m.help.Width = msg.Width | |
| 108 | + | m.updateList() | |
| 109 | + | ||
| 110 | + | case tea.KeyMsg: | |
| 111 | + | switch { | |
| 112 | + | case key.Matches(msg, m.keys.Cancel): | |
| 113 | + | return m, tea.Quit | |
| 114 | + | ||
| 115 | + | case key.Matches(msg, m.keys.Open): | |
| 116 | + | // Select current item and quit | |
| 117 | + | if item := m.list.SelectedItem(); item != nil { | |
| 118 | + | if hi, ok := item.(styles.HistoryItem); ok { | |
| 119 | + | m.selected = hi.URL | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | return m, tea.Quit | |
| 123 | + | ||
| 124 | + | case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down): | |
| 125 | + | // Pass to list | |
| 126 | + | var cmd tea.Cmd | |
| 127 | + | m.list, cmd = m.list.Update(msg) | |
| 128 | + | cmds = append(cmds, cmd) | |
| 129 | + | ||
| 130 | + | default: | |
| 131 | + | // Update search input | |
| 132 | + | var cmd tea.Cmd | |
| 133 | + | m.search, cmd = m.search.Update(msg) | |
| 134 | + | cmds = append(cmds, cmd) | |
| 135 | + | ||
| 136 | + | // Filter list based on search | |
| 137 | + | if m.search.Value() != m.searchQuery { | |
| 138 | + | m.searchQuery = m.search.Value() | |
| 139 | + | m.updateList() | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | case dmenuLoadedMsg: | |
| 144 | + | if msg.err != nil { | |
| 145 | + | m.err = msg.err | |
| 146 | + | } else { | |
| 147 | + | m.allItems = msg.items | |
| 148 | + | m.updateList() | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | return m, tea.Batch(cmds...) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // updateList updates the list with filtered items. | |
| 156 | + | func (m *DmenuModel) updateList() { | |
| 157 | + | items := m.allItems | |
| 158 | + | ||
| 159 | + | // Apply search filter | |
| 160 | + | if m.searchQuery != "" { | |
| 161 | + | query := m.searchQuery | |
| 162 | + | filtered := make([]styles.HistoryItem, 0) | |
| 163 | + | for _, item := range items { | |
| 164 | + | // Simple case-insensitive contains matching | |
| 165 | + | if containsIgnoreCase(item.Title, query) || containsIgnoreCase(item.URL, query) { | |
| 166 | + | filtered = append(filtered, item) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | items = filtered | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // Create list | |
| 173 | + | listHeight := m.height - 6 // Account for search, help | |
| 174 | + | if listHeight < 5 { | |
| 175 | + | listHeight = 5 | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | m.list = styles.NewHistoryList(m.theme, items, m.width, listHeight) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // containsIgnoreCase checks if s contains substr (case-insensitive). | |
| 182 | + | func containsIgnoreCase(s, substr string) bool { | |
| 183 | + | sLower := toLower(s) | |
| 184 | + | substrLower := toLower(substr) | |
| 185 | + | ||
| 186 | + | for i := 0; i <= len(sLower)-len(substrLower); i++ { | |
| 187 | + | if sLower[i:i+len(substrLower)] == substrLower { | |
| 188 | + | return true | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | return false | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // toLower converts string to lowercase without importing strings. | |
| 195 | + | func toLower(s string) string { | |
| 196 | + | b := make([]byte, len(s)) | |
| 197 | + | for i := 0; i < len(s); i++ { | |
| 198 | + | c := s[i] | |
| 199 | + | if c >= 'A' && c <= 'Z' { | |
| 200 | + | c += 'a' - 'A' | |
| 201 | + | } | |
| 202 | + | b[i] = c | |
| 203 | + | } | |
| 204 | + | return string(b) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // View implements tea.Model. | |
| 208 | + | func (m DmenuModel) View() string { | |
| 209 | + | t := m.theme | |
| 210 | + | ||
| 211 | + | // Search bar | |
| 212 | + | searchBar := t.InputFocused.Render(m.search.View()) | |
| 213 | + | ||
| 214 | + | // List | |
| 215 | + | listView := m.list.View() | |
| 216 | + | ||
| 217 | + | if m.err != nil { | |
| 218 | + | listView = t.ErrorStyle.Render("Error: " + m.err.Error()) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // Help | |
| 222 | + | helpView := m.help.View(m.keys) | |
| 223 | + | ||
| 224 | + | // Combine | |
| 225 | + | return lipgloss.JoinVertical( | |
| 226 | + | lipgloss.Left, | |
| 227 | + | searchBar, | |
| 228 | + | "", | |
| 229 | + | listView, | |
| 230 | + | "", | |
| 231 | + | helpView, | |
| 232 | + | ) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // SelectedURL returns the URL selected by the user. | |
| 236 | + | func (m DmenuModel) SelectedURL() string { | |
| 237 | + | return m.selected | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // Ensure interface compliance. | |
| 241 | + | var _ tea.Model = (*DmenuModel)(nil) | |
| @@ -0,0 +1,241 @@ | |||||
| 1 | + | package model | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/charmbracelet/bubbles/help" | |||
| 7 | + | "github.com/charmbracelet/bubbles/key" | |||
| 8 | + | "github.com/charmbracelet/bubbles/list" | |||
| 9 | + | "github.com/charmbracelet/bubbles/textinput" | |||
| 10 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 11 | + | "github.com/charmbracelet/lipgloss" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 14 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // DmenuModel is the Bubble Tea model for interactive dmenu. | |||
| 18 | + | type DmenuModel struct { | |||
| 19 | + | // UI components | |||
| 20 | + | list list.Model | |||
| 21 | + | search textinput.Model | |||
| 22 | + | help help.Model | |||
| 23 | + | keys styles.DmenuKeyMap | |||
| 24 | + | ||||
| 25 | + | // State | |||
| 26 | + | allItems []styles.HistoryItem | |||
| 27 | + | selected string // Selected URL | |||
| 28 | + | searchQuery string | |||
| 29 | + | width int | |||
| 30 | + | height int | |||
| 31 | + | err error | |||
| 32 | + | ||||
| 33 | + | // Dependencies | |||
| 34 | + | historyUC *usecase.SearchHistoryUseCase | |||
| 35 | + | theme *styles.Theme | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // NewDmenuModel creates a new dmenu model. | |||
| 39 | + | func NewDmenuModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) DmenuModel { | |||
| 40 | + | // Create search input | |||
| 41 | + | search := styles.NewSearchInput(theme) | |||
| 42 | + | search.Focus() | |||
| 43 | + | ||||
| 44 | + | // Create help | |||
| 45 | + | helpModel := styles.NewStyledHelp(theme) | |||
| 46 | + | ||||
| 47 | + | // Create keybindings | |||
| 48 | + | keys := styles.DefaultDmenuKeyMap() | |||
| 49 | + | ||||
| 50 | + | return DmenuModel{ | |||
| 51 | + | search: search, | |||
| 52 | + | help: helpModel, | |||
| 53 | + | keys: keys, | |||
| 54 | + | historyUC: historyUC, | |||
| 55 | + | theme: theme, | |||
| 56 | + | width: 80, | |||
| 57 | + | height: 24, | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // dmenuLoadedMsg is sent when entries are loaded. | |||
| 62 | + | type dmenuLoadedMsg struct { | |||
| 63 | + | items []styles.HistoryItem | |||
| 64 | + | err error | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Init implements tea.Model. | |||
| 68 | + | func (m DmenuModel) Init() tea.Cmd { | |||
| 69 | + | return tea.Batch( | |||
| 70 | + | textinput.Blink, | |||
| 71 | + | m.loadEntries, | |||
| 72 | + | ) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // loadEntries loads history entries. | |||
| 76 | + | func (m DmenuModel) loadEntries() tea.Msg { | |||
| 77 | + | ctx := context.Background() | |||
| 78 | + | ||||
| 79 | + | entries, err := m.historyUC.GetRecent(ctx, 500, 0) | |||
| 80 | + | if err != nil { | |||
| 81 | + | return dmenuLoadedMsg{err: err} | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | items := make([]styles.HistoryItem, len(entries)) | |||
| 85 | + | for i, e := range entries { | |||
| 86 | + | items[i] = styles.HistoryItem{ | |||
| 87 | + | ID: e.ID, | |||
| 88 | + | URL: e.URL, | |||
| 89 | + | Title: e.Title, | |||
| 90 | + | Domain: getDomain(e.URL), | |||
| 91 | + | VisitCount: int(e.VisitCount), | |||
| 92 | + | LastVisited: e.LastVisited, | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | return dmenuLoadedMsg{items: items} | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // Update implements tea.Model. | |||
| 100 | + | func (m DmenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 101 | + | var cmds []tea.Cmd | |||
| 102 | + | ||||
| 103 | + | switch msg := msg.(type) { | |||
| 104 | + | case tea.WindowSizeMsg: | |||
| 105 | + | m.width = msg.Width | |||
| 106 | + | m.height = msg.Height | |||
| 107 | + | m.help.Width = msg.Width | |||
| 108 | + | m.updateList() | |||
| 109 | + | ||||
| 110 | + | case tea.KeyMsg: | |||
| 111 | + | switch { | |||
| 112 | + | case key.Matches(msg, m.keys.Cancel): | |||
| 113 | + | return m, tea.Quit | |||
| 114 | + | ||||
| 115 | + | case key.Matches(msg, m.keys.Open): | |||
| 116 | + | // Select current item and quit | |||
| 117 | + | if item := m.list.SelectedItem(); item != nil { | |||
| 118 | + | if hi, ok := item.(styles.HistoryItem); ok { | |||
| 119 | + | m.selected = hi.URL | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | return m, tea.Quit | |||
| 123 | + | ||||
| 124 | + | case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down): | |||
| 125 | + | // Pass to list | |||
| 126 | + | var cmd tea.Cmd | |||
| 127 | + | m.list, cmd = m.list.Update(msg) | |||
| 128 | + | cmds = append(cmds, cmd) | |||
| 129 | + | ||||
| 130 | + | default: | |||
| 131 | + | // Update search input | |||
| 132 | + | var cmd tea.Cmd | |||
| 133 | + | m.search, cmd = m.search.Update(msg) | |||
| 134 | + | cmds = append(cmds, cmd) | |||
| 135 | + | ||||
| 136 | + | // Filter list based on search | |||
| 137 | + | if m.search.Value() != m.searchQuery { | |||
| 138 | + | m.searchQuery = m.search.Value() | |||
| 139 | + | m.updateList() | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | case dmenuLoadedMsg: | |||
| 144 | + | if msg.err != nil { | |||
| 145 | + | m.err = msg.err | |||
| 146 | + | } else { | |||
| 147 | + | m.allItems = msg.items | |||
| 148 | + | m.updateList() | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | return m, tea.Batch(cmds...) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // updateList updates the list with filtered items. | |||
| 156 | + | func (m *DmenuModel) updateList() { | |||
| 157 | + | items := m.allItems | |||
| 158 | + | ||||
| 159 | + | // Apply search filter | |||
| 160 | + | if m.searchQuery != "" { | |||
| 161 | + | query := m.searchQuery | |||
| 162 | + | filtered := make([]styles.HistoryItem, 0) | |||
| 163 | + | for _, item := range items { | |||
| 164 | + | // Simple case-insensitive contains matching | |||
| 165 | + | if containsIgnoreCase(item.Title, query) || containsIgnoreCase(item.URL, query) { | |||
| 166 | + | filtered = append(filtered, item) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | items = filtered | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // Create list | |||
| 173 | + | listHeight := m.height - 6 // Account for search, help | |||
| 174 | + | if listHeight < 5 { | |||
| 175 | + | listHeight = 5 | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | m.list = styles.NewHistoryList(m.theme, items, m.width, listHeight) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // containsIgnoreCase checks if s contains substr (case-insensitive). | |||
| 182 | + | func containsIgnoreCase(s, substr string) bool { | |||
| 183 | + | sLower := toLower(s) | |||
| 184 | + | substrLower := toLower(substr) | |||
| 185 | + | ||||
| 186 | + | for i := 0; i <= len(sLower)-len(substrLower); i++ { | |||
| 187 | + | if sLower[i:i+len(substrLower)] == substrLower { | |||
| 188 | + | return true | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | return false | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // toLower converts string to lowercase without importing strings. | |||
| 195 | + | func toLower(s string) string { | |||
| 196 | + | b := make([]byte, len(s)) | |||
| 197 | + | for i := 0; i < len(s); i++ { | |||
| 198 | + | c := s[i] | |||
| 199 | + | if c >= 'A' && c <= 'Z' { | |||
| 200 | + | c += 'a' - 'A' | |||
| 201 | + | } | |||
| 202 | + | b[i] = c | |||
| 203 | + | } | |||
| 204 | + | return string(b) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // View implements tea.Model. | |||
| 208 | + | func (m DmenuModel) View() string { | |||
| 209 | + | t := m.theme | |||
| 210 | + | ||||
| 211 | + | // Search bar | |||
| 212 | + | searchBar := t.InputFocused.Render(m.search.View()) | |||
| 213 | + | ||||
| 214 | + | // List | |||
| 215 | + | listView := m.list.View() | |||
| 216 | + | ||||
| 217 | + | if m.err != nil { | |||
| 218 | + | listView = t.ErrorStyle.Render("Error: " + m.err.Error()) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // Help | |||
| 222 | + | helpView := m.help.View(m.keys) | |||
| 223 | + | ||||
| 224 | + | // Combine | |||
| 225 | + | return lipgloss.JoinVertical( | |||
| 226 | + | lipgloss.Left, | |||
| 227 | + | searchBar, | |||
| 228 | + | "", | |||
| 229 | + | listView, | |||
| 230 | + | "", | |||
| 231 | + | helpView, | |||
| 232 | + | ) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // SelectedURL returns the URL selected by the user. | |||
| 236 | + | func (m DmenuModel) SelectedURL() string { | |||
| 237 | + | return m.selected | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // Ensure interface compliance. | |||
| 241 | + | var _ tea.Model = (*DmenuModel)(nil) | |||
A internal/cli/model/history.go +635 -0
| @@ -0,0 +1,635 @@ | |||
| 1 | + | // Package model provides Bubble Tea models for CLI commands. | |
| 2 | + | package model | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "net/url" | |
| 7 | + | "os/exec" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/charmbracelet/bubbles/help" | |
| 11 | + | "github.com/charmbracelet/bubbles/key" | |
| 12 | + | "github.com/charmbracelet/bubbles/list" | |
| 13 | + | "github.com/charmbracelet/bubbles/textinput" | |
| 14 | + | tea "github.com/charmbracelet/bubbletea" | |
| 15 | + | "github.com/charmbracelet/lipgloss" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 18 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 19 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 20 | + | "github.com/bnema/dumber/internal/logging" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // HistoryModel is the Bubble Tea model for interactive history browser. | |
| 24 | + | type HistoryModel struct { | |
| 25 | + | // UI components | |
| 26 | + | tabs styles.TabsModel | |
| 27 | + | list list.Model | |
| 28 | + | search textinput.Model | |
| 29 | + | help help.Model | |
| 30 | + | keys styles.HistoryKeyMap | |
| 31 | + | cleanup *styles.CleanupModel | |
| 32 | + | confirm *styles.ConfirmModel | |
| 33 | + | ||
| 34 | + | // State | |
| 35 | + | entries [][]*entity.HistoryEntry // Entries grouped by tab (Today/Yesterday/Week/Older) | |
| 36 | + | topDomains []*entity.DomainStat | |
| 37 | + | domainFilter string | |
| 38 | + | searchMode bool | |
| 39 | + | showHelp bool | |
| 40 | + | width int | |
| 41 | + | height int | |
| 42 | + | err error | |
| 43 | + | ||
| 44 | + | // Dependencies | |
| 45 | + | ctx context.Context | |
| 46 | + | historyUC *usecase.SearchHistoryUseCase | |
| 47 | + | theme *styles.Theme | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // NewHistoryModel creates a new history browser model. | |
| 51 | + | func NewHistoryModel(ctx context.Context, theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) HistoryModel { | |
| 52 | + | log := logging.FromContext(ctx) | |
| 53 | + | log.Debug().Msg("creating history model") | |
| 54 | + | ||
| 55 | + | // Create tabs for timeline grouping | |
| 56 | + | tabs := styles.TimelineTabs(theme) | |
| 57 | + | ||
| 58 | + | // Create search input | |
| 59 | + | search := styles.NewSearchInput(theme) | |
| 60 | + | ||
| 61 | + | // Create help | |
| 62 | + | helpModel := styles.NewStyledHelp(theme) | |
| 63 | + | ||
| 64 | + | // Create keybindings | |
| 65 | + | keys := styles.DefaultHistoryKeyMap() | |
| 66 | + | ||
| 67 | + | return HistoryModel{ | |
| 68 | + | tabs: tabs, | |
| 69 | + | search: search, | |
| 70 | + | help: helpModel, | |
| 71 | + | keys: keys, | |
| 72 | + | entries: make([][]*entity.HistoryEntry, 4), // Today, Yesterday, This Week, Older | |
| 73 | + | ctx: ctx, | |
| 74 | + | historyUC: historyUC, | |
| 75 | + | theme: theme, | |
| 76 | + | width: 80, | |
| 77 | + | height: 24, | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Init implements tea.Model. | |
| 82 | + | func (m HistoryModel) Init() tea.Cmd { | |
| 83 | + | return tea.Batch( | |
| 84 | + | m.loadHistory, | |
| 85 | + | m.loadDomainStats, | |
| 86 | + | ) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // historyLoadedMsg is sent when history entries are loaded. | |
| 90 | + | type historyLoadedMsg struct { | |
| 91 | + | entries [][]*entity.HistoryEntry | |
| 92 | + | err error | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // domainStatsMsg is sent when domain stats are loaded. | |
| 96 | + | type domainStatsMsg struct { | |
| 97 | + | domains []*entity.DomainStat | |
| 98 | + | err error | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // historyDeletedMsg is sent when an entry is deleted. | |
| 102 | + | type historyDeletedMsg struct { | |
| 103 | + | err error | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // historyCleanedMsg is sent when history is cleaned up. | |
| 107 | + | type historyCleanedMsg struct { | |
| 108 | + | err error | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // loadHistory loads history entries grouped by time category. | |
| 112 | + | func (m HistoryModel) loadHistory() tea.Msg { | |
| 113 | + | log := logging.FromContext(m.ctx) | |
| 114 | + | log.Debug().Msg("loading history entries") | |
| 115 | + | ||
| 116 | + | // Get recent entries (all) | |
| 117 | + | entries, err := m.historyUC.GetRecent(m.ctx, 1000, 0) | |
| 118 | + | if err != nil { | |
| 119 | + | log.Error().Err(err).Msg("failed to load history") | |
| 120 | + | return historyLoadedMsg{err: err} | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | log.Debug().Int("total_count", len(entries)).Msg("loaded history entries from database") | |
| 124 | + | ||
| 125 | + | // Group by time category | |
| 126 | + | grouped := make([][]*entity.HistoryEntry, 4) | |
| 127 | + | for i := range grouped { | |
| 128 | + | grouped[i] = make([]*entity.HistoryEntry, 0) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | for _, entry := range entries { | |
| 132 | + | cat := styles.GetTimeCategory(entry.LastVisited) | |
| 133 | + | switch cat { | |
| 134 | + | case styles.TimeCategoryToday: | |
| 135 | + | grouped[0] = append(grouped[0], entry) | |
| 136 | + | case styles.TimeCategoryYesterday: | |
| 137 | + | grouped[1] = append(grouped[1], entry) | |
| 138 | + | case styles.TimeCategoryThisWeek: | |
| 139 | + | grouped[2] = append(grouped[2], entry) | |
| 140 | + | case styles.TimeCategoryOlder: | |
| 141 | + | grouped[3] = append(grouped[3], entry) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | log.Debug(). | |
| 146 | + | Int("today", len(grouped[0])). | |
| 147 | + | Int("yesterday", len(grouped[1])). | |
| 148 | + | Int("this_week", len(grouped[2])). | |
| 149 | + | Int("older", len(grouped[3])). | |
| 150 | + | Msg("grouped history entries by time category") | |
| 151 | + | ||
| 152 | + | return historyLoadedMsg{entries: grouped} | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // loadDomainStats loads top domain statistics. | |
| 156 | + | func (m HistoryModel) loadDomainStats() tea.Msg { | |
| 157 | + | log := logging.FromContext(m.ctx) | |
| 158 | + | domains, err := m.historyUC.GetDomainStats(m.ctx, 10) | |
| 159 | + | if err != nil { | |
| 160 | + | log.Error().Err(err).Msg("failed to load domain stats") | |
| 161 | + | return domainStatsMsg{err: err} | |
| 162 | + | } | |
| 163 | + | log.Debug().Int("domain_count", len(domains)).Msg("loaded domain statistics") | |
| 164 | + | return domainStatsMsg{domains: domains} | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // Update implements tea.Model. | |
| 168 | + | func (m HistoryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 169 | + | var cmds []tea.Cmd | |
| 170 | + | ||
| 171 | + | // Handle cleanup modal | |
| 172 | + | if m.cleanup != nil { | |
| 173 | + | cleanup, cmd := m.cleanup.Update(msg) | |
| 174 | + | m.cleanup = &cleanup | |
| 175 | + | if m.cleanup.Done() { | |
| 176 | + | if m.cleanup.Confirmed { | |
| 177 | + | // Perform cleanup | |
| 178 | + | cmds = append(cmds, m.performCleanup(m.cleanup.SelectedRange())) | |
| 179 | + | } | |
| 180 | + | m.cleanup = nil | |
| 181 | + | } | |
| 182 | + | return m, cmd | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // Handle confirm dialog | |
| 186 | + | if m.confirm != nil { | |
| 187 | + | confirm, cmd := m.confirm.Update(msg) | |
| 188 | + | m.confirm = &confirm | |
| 189 | + | if m.confirm.Done() { | |
| 190 | + | if m.confirm.Result() { | |
| 191 | + | // Perform the confirmed action (delete current entry) | |
| 192 | + | cmds = append(cmds, m.deleteCurrentEntry()) | |
| 193 | + | } | |
| 194 | + | m.confirm = nil | |
| 195 | + | } | |
| 196 | + | return m, cmd | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | switch msg := msg.(type) { | |
| 200 | + | case tea.WindowSizeMsg: | |
| 201 | + | m.width = msg.Width | |
| 202 | + | m.height = msg.Height | |
| 203 | + | m.help.Width = msg.Width | |
| 204 | + | m.updateList() | |
| 205 | + | ||
| 206 | + | case tea.KeyMsg: | |
| 207 | + | // Search mode handling | |
| 208 | + | if m.searchMode { | |
| 209 | + | switch msg.String() { | |
| 210 | + | case "esc": | |
| 211 | + | m.searchMode = false | |
| 212 | + | m.search.Blur() | |
| 213 | + | return m, nil | |
| 214 | + | case "enter": | |
| 215 | + | m.searchMode = false | |
| 216 | + | m.search.Blur() | |
| 217 | + | // Perform search | |
| 218 | + | return m, m.performSearch(m.search.Value()) | |
| 219 | + | default: | |
| 220 | + | var cmd tea.Cmd | |
| 221 | + | m.search, cmd = m.search.Update(msg) | |
| 222 | + | return m, cmd | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // Normal mode handling | |
| 227 | + | switch { | |
| 228 | + | case key.Matches(msg, m.keys.Quit): | |
| 229 | + | return m, tea.Quit | |
| 230 | + | ||
| 231 | + | case key.Matches(msg, m.keys.Help): | |
| 232 | + | m.showHelp = !m.showHelp | |
| 233 | + | ||
| 234 | + | case key.Matches(msg, m.keys.Search): | |
| 235 | + | m.searchMode = true | |
| 236 | + | m.search.Focus() | |
| 237 | + | return m, textinput.Blink | |
| 238 | + | ||
| 239 | + | case key.Matches(msg, m.keys.Tab1): | |
| 240 | + | m.tabs.SetActive(0) | |
| 241 | + | m.updateList() | |
| 242 | + | ||
| 243 | + | case key.Matches(msg, m.keys.Tab2): | |
| 244 | + | m.tabs.SetActive(1) | |
| 245 | + | m.updateList() | |
| 246 | + | ||
| 247 | + | case key.Matches(msg, m.keys.Tab3): | |
| 248 | + | m.tabs.SetActive(2) | |
| 249 | + | m.updateList() | |
| 250 | + | ||
| 251 | + | case key.Matches(msg, m.keys.Tab4): | |
| 252 | + | m.tabs.SetActive(3) | |
| 253 | + | m.updateList() | |
| 254 | + | ||
| 255 | + | case key.Matches(msg, m.keys.NextTab): | |
| 256 | + | // Cycle to next tab (wraps around) | |
| 257 | + | next := (m.tabs.Active + 1) % 4 | |
| 258 | + | m.tabs.SetActive(next) | |
| 259 | + | m.updateList() | |
| 260 | + | ||
| 261 | + | case key.Matches(msg, m.keys.PrevTab): | |
| 262 | + | // Cycle to previous tab (wraps around) | |
| 263 | + | prev := (m.tabs.Active + 3) % 4 // +3 is same as -1 mod 4 | |
| 264 | + | m.tabs.SetActive(prev) | |
| 265 | + | m.updateList() | |
| 266 | + | ||
| 267 | + | case key.Matches(msg, m.keys.Open): | |
| 268 | + | // Open selected URL in browser | |
| 269 | + | if item := m.list.SelectedItem(); item != nil { | |
| 270 | + | if hi, ok := item.(styles.HistoryItem); ok { | |
| 271 | + | cmds = append(cmds, m.openURL(hi.URL)) | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | case key.Matches(msg, m.keys.Delete): | |
| 276 | + | // Show delete confirmation | |
| 277 | + | if item := m.list.SelectedItem(); item != nil { | |
| 278 | + | confirm := styles.NewConfirm(m.theme, "Delete this entry?") | |
| 279 | + | m.confirm = &confirm | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | case key.Matches(msg, m.keys.DeleteDomain): | |
| 283 | + | // Delete all from domain | |
| 284 | + | if item := m.list.SelectedItem(); item != nil { | |
| 285 | + | if hi, ok := item.(styles.HistoryItem); ok { | |
| 286 | + | confirm := styles.NewConfirm(m.theme, "Delete all from "+hi.Domain+"?") | |
| 287 | + | m.confirm = &confirm | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | case key.Matches(msg, m.keys.Cleanup): | |
| 292 | + | // Show cleanup modal | |
| 293 | + | cleanup := styles.NewCleanup(m.theme) | |
| 294 | + | m.cleanup = &cleanup | |
| 295 | + | ||
| 296 | + | case key.Matches(msg, m.keys.Filter): | |
| 297 | + | // Cycle through domain filters | |
| 298 | + | m.cycleDomainFilter() | |
| 299 | + | m.updateList() | |
| 300 | + | ||
| 301 | + | default: | |
| 302 | + | // Pass to list | |
| 303 | + | var cmd tea.Cmd | |
| 304 | + | m.list, cmd = m.list.Update(msg) | |
| 305 | + | cmds = append(cmds, cmd) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | case historyLoadedMsg: | |
| 309 | + | if msg.err != nil { | |
| 310 | + | m.err = msg.err | |
| 311 | + | } else { | |
| 312 | + | m.entries = msg.entries | |
| 313 | + | m.updateList() | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | case domainStatsMsg: | |
| 317 | + | if msg.err != nil { | |
| 318 | + | m.err = msg.err | |
| 319 | + | } else { | |
| 320 | + | m.topDomains = msg.domains | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | case historyDeletedMsg: | |
| 324 | + | if msg.err != nil { | |
| 325 | + | m.err = msg.err | |
| 326 | + | } else { | |
| 327 | + | // Reload history | |
| 328 | + | cmds = append(cmds, m.loadHistory) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | case historyCleanedMsg: | |
| 332 | + | if msg.err != nil { | |
| 333 | + | m.err = msg.err | |
| 334 | + | } else { | |
| 335 | + | // Reload history | |
| 336 | + | cmds = append(cmds, m.loadHistory) | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | return m, tea.Batch(cmds...) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // updateList updates the list with entries for the current tab. | |
| 344 | + | func (m *HistoryModel) updateList() { | |
| 345 | + | activeTab := m.tabs.Active | |
| 346 | + | if activeTab < 0 || activeTab >= len(m.entries) { | |
| 347 | + | return | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | entries := m.entries[activeTab] | |
| 351 | + | ||
| 352 | + | // Apply domain filter if set | |
| 353 | + | if m.domainFilter != "" { | |
| 354 | + | filtered := make([]*entity.HistoryEntry, 0) | |
| 355 | + | for _, e := range entries { | |
| 356 | + | if getDomain(e.URL) == m.domainFilter { | |
| 357 | + | filtered = append(filtered, e) | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | entries = filtered | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // Convert to list items | |
| 364 | + | items := make([]styles.HistoryItem, len(entries)) | |
| 365 | + | for i, e := range entries { | |
| 366 | + | items[i] = styles.HistoryItem{ | |
| 367 | + | ID: e.ID, | |
| 368 | + | URL: e.URL, | |
| 369 | + | Title: e.Title, | |
| 370 | + | Domain: getDomain(e.URL), | |
| 371 | + | VisitCount: int(e.VisitCount), | |
| 372 | + | LastVisited: e.LastVisited, | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // Create new list | |
| 377 | + | listHeight := m.height - 8 // Account for tabs, search, help | |
| 378 | + | if listHeight < 5 { | |
| 379 | + | listHeight = 5 | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | m.list = styles.NewHistoryList(m.theme, items, m.width, listHeight) | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // cycleDomainFilter cycles through domain filters. | |
| 386 | + | func (m *HistoryModel) cycleDomainFilter() { | |
| 387 | + | if len(m.topDomains) == 0 { | |
| 388 | + | m.domainFilter = "" | |
| 389 | + | return | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | // Find current filter in list | |
| 393 | + | currentIdx := -1 | |
| 394 | + | for i, d := range m.topDomains { | |
| 395 | + | if d.Domain == m.domainFilter { | |
| 396 | + | currentIdx = i | |
| 397 | + | break | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | // Move to next, or clear filter | |
| 402 | + | if currentIdx == len(m.topDomains)-1 { | |
| 403 | + | m.domainFilter = "" // Clear filter | |
| 404 | + | } else { | |
| 405 | + | m.domainFilter = m.topDomains[currentIdx+1].Domain | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | // performSearch executes a fuzzy search. | |
| 410 | + | func (m HistoryModel) performSearch(query string) tea.Cmd { | |
| 411 | + | return func() tea.Msg { | |
| 412 | + | log := logging.FromContext(m.ctx) | |
| 413 | + | log.Debug().Str("query", query).Msg("performing search") | |
| 414 | + | ||
| 415 | + | result, err := m.historyUC.Search(m.ctx, usecase.SearchInput{ | |
| 416 | + | Query: query, | |
| 417 | + | Limit: 100, | |
| 418 | + | }) | |
| 419 | + | if err != nil { | |
| 420 | + | log.Error().Err(err).Msg("search failed") | |
| 421 | + | return historyLoadedMsg{err: err} | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | log.Debug().Int("matches", len(result.Matches)).Msg("search completed") | |
| 425 | + | ||
| 426 | + | // Convert matches to single list (all in "Today" tab for search results) | |
| 427 | + | entries := make([]*entity.HistoryEntry, len(result.Matches)) | |
| 428 | + | for i, match := range result.Matches { | |
| 429 | + | entries[i] = match.Entry | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | grouped := make([][]*entity.HistoryEntry, 4) | |
| 433 | + | grouped[0] = entries // Put all results in first tab | |
| 434 | + | for i := 1; i < 4; i++ { | |
| 435 | + | grouped[i] = make([]*entity.HistoryEntry, 0) | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | return historyLoadedMsg{entries: grouped} | |
| 439 | + | } | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // deleteCurrentEntry deletes the currently selected entry. | |
| 443 | + | func (m HistoryModel) deleteCurrentEntry() tea.Cmd { | |
| 444 | + | return func() tea.Msg { | |
| 445 | + | if item := m.list.SelectedItem(); item != nil { | |
| 446 | + | if hi, ok := item.(styles.HistoryItem); ok { | |
| 447 | + | log := logging.FromContext(m.ctx) | |
| 448 | + | log.Debug().Int64("id", hi.ID).Str("url", hi.URL).Msg("deleting history entry") | |
| 449 | + | err := m.historyUC.Delete(m.ctx, hi.ID) | |
| 450 | + | if err != nil { | |
| 451 | + | log.Error().Err(err).Msg("failed to delete entry") | |
| 452 | + | } | |
| 453 | + | return historyDeletedMsg{err: err} | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | return historyDeletedMsg{} | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | // performCleanup cleans up history based on selected range. | |
| 461 | + | func (m HistoryModel) performCleanup(cleanupRange styles.CleanupRange) tea.Cmd { | |
| 462 | + | return func() tea.Msg { | |
| 463 | + | log := logging.FromContext(m.ctx) | |
| 464 | + | cutoff := cleanupRange.CutoffTime() | |
| 465 | + | ||
| 466 | + | var err error | |
| 467 | + | if cutoff.IsZero() { | |
| 468 | + | log.Info().Msg("clearing all history") | |
| 469 | + | err = m.historyUC.ClearAll(m.ctx) | |
| 470 | + | } else { | |
| 471 | + | log.Info().Time("cutoff", cutoff).Msg("clearing history older than cutoff") | |
| 472 | + | err = m.historyUC.ClearOlderThan(m.ctx, cutoff) | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | if err != nil { | |
| 476 | + | log.Error().Err(err).Msg("failed to cleanup history") | |
| 477 | + | } | |
| 478 | + | return historyCleanedMsg{err: err} | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | // openURL opens a URL in the default browser. | |
| 483 | + | func (m HistoryModel) openURL(urlStr string) tea.Cmd { | |
| 484 | + | return func() tea.Msg { | |
| 485 | + | // Use xdg-open on Linux | |
| 486 | + | _ = exec.Command("xdg-open", urlStr).Start() | |
| 487 | + | return nil | |
| 488 | + | } | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // View implements tea.Model. | |
| 492 | + | func (m HistoryModel) View() string { | |
| 493 | + | // Handle modals | |
| 494 | + | if m.cleanup != nil { | |
| 495 | + | return m.cleanup.View() | |
| 496 | + | } | |
| 497 | + | if m.confirm != nil { | |
| 498 | + | return m.confirm.View() | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | t := m.theme | |
| 502 | + | ||
| 503 | + | // Build tab counts | |
| 504 | + | counts := make([]int, 4) | |
| 505 | + | for i, entries := range m.entries { | |
| 506 | + | counts[i] = len(entries) | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | // Header with tabs | |
| 510 | + | header := m.tabs.ViewWithCounts(counts) | |
| 511 | + | ||
| 512 | + | // Search bar | |
| 513 | + | var searchBar string | |
| 514 | + | if m.searchMode { | |
| 515 | + | searchBar = t.InputFocused.Render(m.search.View()) | |
| 516 | + | } else { | |
| 517 | + | if m.domainFilter != "" { | |
| 518 | + | searchBar = t.Subtle.Render("Filter: ") + t.Badge.Render(m.domainFilter) + t.Subtle.Render(" (f to cycle)") | |
| 519 | + | } else { | |
| 520 | + | searchBar = t.Subtle.Render("Press / to search, f to filter by domain, Tab/Shift+Tab to switch tabs") | |
| 521 | + | } | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | // List | |
| 525 | + | listView := m.list.View() | |
| 526 | + | ||
| 527 | + | // Error display | |
| 528 | + | if m.err != nil { | |
| 529 | + | listView = t.ErrorStyle.Render("Error: " + m.err.Error()) | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | // Help | |
| 533 | + | var helpView string | |
| 534 | + | if m.showHelp { | |
| 535 | + | helpView = m.help.View(m.keys) | |
| 536 | + | } else { | |
| 537 | + | helpView = t.Subtle.Render("? for help • q to quit") | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | // Combine all parts | |
| 541 | + | return lipgloss.JoinVertical( | |
| 542 | + | lipgloss.Left, | |
| 543 | + | header, | |
| 544 | + | "", | |
| 545 | + | searchBar, | |
| 546 | + | "", | |
| 547 | + | listView, | |
| 548 | + | "", | |
| 549 | + | helpView, | |
| 550 | + | ) | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | // getDomain extracts domain from URL. | |
| 554 | + | func getDomain(urlStr string) string { | |
| 555 | + | u, err := url.Parse(urlStr) | |
| 556 | + | if err != nil { | |
| 557 | + | return "" | |
| 558 | + | } | |
| 559 | + | return u.Host | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | // HistoryListModel is a simpler non-interactive model for JSON output. | |
| 563 | + | type HistoryListModel struct { | |
| 564 | + | entries []*entity.HistoryEntry | |
| 565 | + | max int | |
| 566 | + | err error | |
| 567 | + | ||
| 568 | + | ctx context.Context | |
| 569 | + | historyUC *usecase.SearchHistoryUseCase | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | // NewHistoryListModel creates a model for list output. | |
| 573 | + | func NewHistoryListModel(ctx context.Context, historyUC *usecase.SearchHistoryUseCase, maxEntries int) HistoryListModel { | |
| 574 | + | return HistoryListModel{ | |
| 575 | + | ctx: ctx, | |
| 576 | + | historyUC: historyUC, | |
| 577 | + | max: maxEntries, | |
| 578 | + | } | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | // Init implements tea.Model. | |
| 582 | + | func (m HistoryListModel) Init() tea.Cmd { | |
| 583 | + | return func() tea.Msg { | |
| 584 | + | log := logging.FromContext(m.ctx) | |
| 585 | + | log.Debug().Int("max", m.max).Msg("loading history for JSON output") | |
| 586 | + | ||
| 587 | + | entries, err := m.historyUC.GetRecent(m.ctx, m.max, 0) | |
| 588 | + | if err != nil { | |
| 589 | + | log.Error().Err(err).Msg("failed to load history") | |
| 590 | + | return historyLoadedMsg{err: err} | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | log.Debug().Int("count", len(entries)).Msg("loaded history entries") | |
| 594 | + | ||
| 595 | + | grouped := make([][]*entity.HistoryEntry, 1) | |
| 596 | + | grouped[0] = entries | |
| 597 | + | return historyLoadedMsg{entries: grouped} | |
| 598 | + | } | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | // Update implements tea.Model. | |
| 602 | + | func (m HistoryListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 603 | + | switch msg := msg.(type) { | |
| 604 | + | case historyLoadedMsg: | |
| 605 | + | if msg.err != nil { | |
| 606 | + | m.err = msg.err | |
| 607 | + | } else if len(msg.entries) > 0 { | |
| 608 | + | m.entries = msg.entries[0] | |
| 609 | + | } | |
| 610 | + | return m, tea.Quit | |
| 611 | + | } | |
| 612 | + | return m, nil | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | // View implements tea.Model. | |
| 616 | + | func (m HistoryListModel) View() string { | |
| 617 | + | return "" // Output handled externally | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | // Entries returns the loaded entries. | |
| 621 | + | func (m HistoryListModel) Entries() []*entity.HistoryEntry { | |
| 622 | + | return m.entries | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | // Error returns any error that occurred. | |
| 626 | + | func (m HistoryListModel) Error() error { | |
| 627 | + | return m.err | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | // Ensure interface compliance at compile time. | |
| 631 | + | var _ tea.Model = (*HistoryModel)(nil) | |
| 632 | + | var _ tea.Model = (*HistoryListModel)(nil) | |
| 633 | + | ||
| 634 | + | // Unexported helper to avoid exposing time package in imports when not needed. | |
| 635 | + | var _ = time.Now | |
| @@ -0,0 +1,635 @@ | |||||
| 1 | + | // Package model provides Bubble Tea models for CLI commands. | |||
| 2 | + | package model | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "net/url" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/charmbracelet/bubbles/help" | |||
| 11 | + | "github.com/charmbracelet/bubbles/key" | |||
| 12 | + | "github.com/charmbracelet/bubbles/list" | |||
| 13 | + | "github.com/charmbracelet/bubbles/textinput" | |||
| 14 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 15 | + | "github.com/charmbracelet/lipgloss" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 18 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 19 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 20 | + | "github.com/bnema/dumber/internal/logging" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // HistoryModel is the Bubble Tea model for interactive history browser. | |||
| 24 | + | type HistoryModel struct { | |||
| 25 | + | // UI components | |||
| 26 | + | tabs styles.TabsModel | |||
| 27 | + | list list.Model | |||
| 28 | + | search textinput.Model | |||
| 29 | + | help help.Model | |||
| 30 | + | keys styles.HistoryKeyMap | |||
| 31 | + | cleanup *styles.CleanupModel | |||
| 32 | + | confirm *styles.ConfirmModel | |||
| 33 | + | ||||
| 34 | + | // State | |||
| 35 | + | entries [][]*entity.HistoryEntry // Entries grouped by tab (Today/Yesterday/Week/Older) | |||
| 36 | + | topDomains []*entity.DomainStat | |||
| 37 | + | domainFilter string | |||
| 38 | + | searchMode bool | |||
| 39 | + | showHelp bool | |||
| 40 | + | width int | |||
| 41 | + | height int | |||
| 42 | + | err error | |||
| 43 | + | ||||
| 44 | + | // Dependencies | |||
| 45 | + | ctx context.Context | |||
| 46 | + | historyUC *usecase.SearchHistoryUseCase | |||
| 47 | + | theme *styles.Theme | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // NewHistoryModel creates a new history browser model. | |||
| 51 | + | func NewHistoryModel(ctx context.Context, theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) HistoryModel { | |||
| 52 | + | log := logging.FromContext(ctx) | |||
| 53 | + | log.Debug().Msg("creating history model") | |||
| 54 | + | ||||
| 55 | + | // Create tabs for timeline grouping | |||
| 56 | + | tabs := styles.TimelineTabs(theme) | |||
| 57 | + | ||||
| 58 | + | // Create search input | |||
| 59 | + | search := styles.NewSearchInput(theme) | |||
| 60 | + | ||||
| 61 | + | // Create help | |||
| 62 | + | helpModel := styles.NewStyledHelp(theme) | |||
| 63 | + | ||||
| 64 | + | // Create keybindings | |||
| 65 | + | keys := styles.DefaultHistoryKeyMap() | |||
| 66 | + | ||||
| 67 | + | return HistoryModel{ | |||
| 68 | + | tabs: tabs, | |||
| 69 | + | search: search, | |||
| 70 | + | help: helpModel, | |||
| 71 | + | keys: keys, | |||
| 72 | + | entries: make([][]*entity.HistoryEntry, 4), // Today, Yesterday, This Week, Older | |||
| 73 | + | ctx: ctx, | |||
| 74 | + | historyUC: historyUC, | |||
| 75 | + | theme: theme, | |||
| 76 | + | width: 80, | |||
| 77 | + | height: 24, | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Init implements tea.Model. | |||
| 82 | + | func (m HistoryModel) Init() tea.Cmd { | |||
| 83 | + | return tea.Batch( | |||
| 84 | + | m.loadHistory, | |||
| 85 | + | m.loadDomainStats, | |||
| 86 | + | ) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // historyLoadedMsg is sent when history entries are loaded. | |||
| 90 | + | type historyLoadedMsg struct { | |||
| 91 | + | entries [][]*entity.HistoryEntry | |||
| 92 | + | err error | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // domainStatsMsg is sent when domain stats are loaded. | |||
| 96 | + | type domainStatsMsg struct { | |||
| 97 | + | domains []*entity.DomainStat | |||
| 98 | + | err error | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // historyDeletedMsg is sent when an entry is deleted. | |||
| 102 | + | type historyDeletedMsg struct { | |||
| 103 | + | err error | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // historyCleanedMsg is sent when history is cleaned up. | |||
| 107 | + | type historyCleanedMsg struct { | |||
| 108 | + | err error | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // loadHistory loads history entries grouped by time category. | |||
| 112 | + | func (m HistoryModel) loadHistory() tea.Msg { | |||
| 113 | + | log := logging.FromContext(m.ctx) | |||
| 114 | + | log.Debug().Msg("loading history entries") | |||
| 115 | + | ||||
| 116 | + | // Get recent entries (all) | |||
| 117 | + | entries, err := m.historyUC.GetRecent(m.ctx, 1000, 0) | |||
| 118 | + | if err != nil { | |||
| 119 | + | log.Error().Err(err).Msg("failed to load history") | |||
| 120 | + | return historyLoadedMsg{err: err} | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | log.Debug().Int("total_count", len(entries)).Msg("loaded history entries from database") | |||
| 124 | + | ||||
| 125 | + | // Group by time category | |||
| 126 | + | grouped := make([][]*entity.HistoryEntry, 4) | |||
| 127 | + | for i := range grouped { | |||
| 128 | + | grouped[i] = make([]*entity.HistoryEntry, 0) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | for _, entry := range entries { | |||
| 132 | + | cat := styles.GetTimeCategory(entry.LastVisited) | |||
| 133 | + | switch cat { | |||
| 134 | + | case styles.TimeCategoryToday: | |||
| 135 | + | grouped[0] = append(grouped[0], entry) | |||
| 136 | + | case styles.TimeCategoryYesterday: | |||
| 137 | + | grouped[1] = append(grouped[1], entry) | |||
| 138 | + | case styles.TimeCategoryThisWeek: | |||
| 139 | + | grouped[2] = append(grouped[2], entry) | |||
| 140 | + | case styles.TimeCategoryOlder: | |||
| 141 | + | grouped[3] = append(grouped[3], entry) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | log.Debug(). | |||
| 146 | + | Int("today", len(grouped[0])). | |||
| 147 | + | Int("yesterday", len(grouped[1])). | |||
| 148 | + | Int("this_week", len(grouped[2])). | |||
| 149 | + | Int("older", len(grouped[3])). | |||
| 150 | + | Msg("grouped history entries by time category") | |||
| 151 | + | ||||
| 152 | + | return historyLoadedMsg{entries: grouped} | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // loadDomainStats loads top domain statistics. | |||
| 156 | + | func (m HistoryModel) loadDomainStats() tea.Msg { | |||
| 157 | + | log := logging.FromContext(m.ctx) | |||
| 158 | + | domains, err := m.historyUC.GetDomainStats(m.ctx, 10) | |||
| 159 | + | if err != nil { | |||
| 160 | + | log.Error().Err(err).Msg("failed to load domain stats") | |||
| 161 | + | return domainStatsMsg{err: err} | |||
| 162 | + | } | |||
| 163 | + | log.Debug().Int("domain_count", len(domains)).Msg("loaded domain statistics") | |||
| 164 | + | return domainStatsMsg{domains: domains} | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // Update implements tea.Model. | |||
| 168 | + | func (m HistoryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 169 | + | var cmds []tea.Cmd | |||
| 170 | + | ||||
| 171 | + | // Handle cleanup modal | |||
| 172 | + | if m.cleanup != nil { | |||
| 173 | + | cleanup, cmd := m.cleanup.Update(msg) | |||
| 174 | + | m.cleanup = &cleanup | |||
| 175 | + | if m.cleanup.Done() { | |||
| 176 | + | if m.cleanup.Confirmed { | |||
| 177 | + | // Perform cleanup | |||
| 178 | + | cmds = append(cmds, m.performCleanup(m.cleanup.SelectedRange())) | |||
| 179 | + | } | |||
| 180 | + | m.cleanup = nil | |||
| 181 | + | } | |||
| 182 | + | return m, cmd | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // Handle confirm dialog | |||
| 186 | + | if m.confirm != nil { | |||
| 187 | + | confirm, cmd := m.confirm.Update(msg) | |||
| 188 | + | m.confirm = &confirm | |||
| 189 | + | if m.confirm.Done() { | |||
| 190 | + | if m.confirm.Result() { | |||
| 191 | + | // Perform the confirmed action (delete current entry) | |||
| 192 | + | cmds = append(cmds, m.deleteCurrentEntry()) | |||
| 193 | + | } | |||
| 194 | + | m.confirm = nil | |||
| 195 | + | } | |||
| 196 | + | return m, cmd | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | switch msg := msg.(type) { | |||
| 200 | + | case tea.WindowSizeMsg: | |||
| 201 | + | m.width = msg.Width | |||
| 202 | + | m.height = msg.Height | |||
| 203 | + | m.help.Width = msg.Width | |||
| 204 | + | m.updateList() | |||
| 205 | + | ||||
| 206 | + | case tea.KeyMsg: | |||
| 207 | + | // Search mode handling | |||
| 208 | + | if m.searchMode { | |||
| 209 | + | switch msg.String() { | |||
| 210 | + | case "esc": | |||
| 211 | + | m.searchMode = false | |||
| 212 | + | m.search.Blur() | |||
| 213 | + | return m, nil | |||
| 214 | + | case "enter": | |||
| 215 | + | m.searchMode = false | |||
| 216 | + | m.search.Blur() | |||
| 217 | + | // Perform search | |||
| 218 | + | return m, m.performSearch(m.search.Value()) | |||
| 219 | + | default: | |||
| 220 | + | var cmd tea.Cmd | |||
| 221 | + | m.search, cmd = m.search.Update(msg) | |||
| 222 | + | return m, cmd | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // Normal mode handling | |||
| 227 | + | switch { | |||
| 228 | + | case key.Matches(msg, m.keys.Quit): | |||
| 229 | + | return m, tea.Quit | |||
| 230 | + | ||||
| 231 | + | case key.Matches(msg, m.keys.Help): | |||
| 232 | + | m.showHelp = !m.showHelp | |||
| 233 | + | ||||
| 234 | + | case key.Matches(msg, m.keys.Search): | |||
| 235 | + | m.searchMode = true | |||
| 236 | + | m.search.Focus() | |||
| 237 | + | return m, textinput.Blink | |||
| 238 | + | ||||
| 239 | + | case key.Matches(msg, m.keys.Tab1): | |||
| 240 | + | m.tabs.SetActive(0) | |||
| 241 | + | m.updateList() | |||
| 242 | + | ||||
| 243 | + | case key.Matches(msg, m.keys.Tab2): | |||
| 244 | + | m.tabs.SetActive(1) | |||
| 245 | + | m.updateList() | |||
| 246 | + | ||||
| 247 | + | case key.Matches(msg, m.keys.Tab3): | |||
| 248 | + | m.tabs.SetActive(2) | |||
| 249 | + | m.updateList() | |||
| 250 | + | ||||
| 251 | + | case key.Matches(msg, m.keys.Tab4): | |||
| 252 | + | m.tabs.SetActive(3) | |||
| 253 | + | m.updateList() | |||
| 254 | + | ||||
| 255 | + | case key.Matches(msg, m.keys.NextTab): | |||
| 256 | + | // Cycle to next tab (wraps around) | |||
| 257 | + | next := (m.tabs.Active + 1) % 4 | |||
| 258 | + | m.tabs.SetActive(next) | |||
| 259 | + | m.updateList() | |||
| 260 | + | ||||
| 261 | + | case key.Matches(msg, m.keys.PrevTab): | |||
| 262 | + | // Cycle to previous tab (wraps around) | |||
| 263 | + | prev := (m.tabs.Active + 3) % 4 // +3 is same as -1 mod 4 | |||
| 264 | + | m.tabs.SetActive(prev) | |||
| 265 | + | m.updateList() | |||
| 266 | + | ||||
| 267 | + | case key.Matches(msg, m.keys.Open): | |||
| 268 | + | // Open selected URL in browser | |||
| 269 | + | if item := m.list.SelectedItem(); item != nil { | |||
| 270 | + | if hi, ok := item.(styles.HistoryItem); ok { | |||
| 271 | + | cmds = append(cmds, m.openURL(hi.URL)) | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | case key.Matches(msg, m.keys.Delete): | |||
| 276 | + | // Show delete confirmation | |||
| 277 | + | if item := m.list.SelectedItem(); item != nil { | |||
| 278 | + | confirm := styles.NewConfirm(m.theme, "Delete this entry?") | |||
| 279 | + | m.confirm = &confirm | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | case key.Matches(msg, m.keys.DeleteDomain): | |||
| 283 | + | // Delete all from domain | |||
| 284 | + | if item := m.list.SelectedItem(); item != nil { | |||
| 285 | + | if hi, ok := item.(styles.HistoryItem); ok { | |||
| 286 | + | confirm := styles.NewConfirm(m.theme, "Delete all from "+hi.Domain+"?") | |||
| 287 | + | m.confirm = &confirm | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | case key.Matches(msg, m.keys.Cleanup): | |||
| 292 | + | // Show cleanup modal | |||
| 293 | + | cleanup := styles.NewCleanup(m.theme) | |||
| 294 | + | m.cleanup = &cleanup | |||
| 295 | + | ||||
| 296 | + | case key.Matches(msg, m.keys.Filter): | |||
| 297 | + | // Cycle through domain filters | |||
| 298 | + | m.cycleDomainFilter() | |||
| 299 | + | m.updateList() | |||
| 300 | + | ||||
| 301 | + | default: | |||
| 302 | + | // Pass to list | |||
| 303 | + | var cmd tea.Cmd | |||
| 304 | + | m.list, cmd = m.list.Update(msg) | |||
| 305 | + | cmds = append(cmds, cmd) | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | case historyLoadedMsg: | |||
| 309 | + | if msg.err != nil { | |||
| 310 | + | m.err = msg.err | |||
| 311 | + | } else { | |||
| 312 | + | m.entries = msg.entries | |||
| 313 | + | m.updateList() | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | case domainStatsMsg: | |||
| 317 | + | if msg.err != nil { | |||
| 318 | + | m.err = msg.err | |||
| 319 | + | } else { | |||
| 320 | + | m.topDomains = msg.domains | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | case historyDeletedMsg: | |||
| 324 | + | if msg.err != nil { | |||
| 325 | + | m.err = msg.err | |||
| 326 | + | } else { | |||
| 327 | + | // Reload history | |||
| 328 | + | cmds = append(cmds, m.loadHistory) | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | case historyCleanedMsg: | |||
| 332 | + | if msg.err != nil { | |||
| 333 | + | m.err = msg.err | |||
| 334 | + | } else { | |||
| 335 | + | // Reload history | |||
| 336 | + | cmds = append(cmds, m.loadHistory) | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | return m, tea.Batch(cmds...) | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | // updateList updates the list with entries for the current tab. | |||
| 344 | + | func (m *HistoryModel) updateList() { | |||
| 345 | + | activeTab := m.tabs.Active | |||
| 346 | + | if activeTab < 0 || activeTab >= len(m.entries) { | |||
| 347 | + | return | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | entries := m.entries[activeTab] | |||
| 351 | + | ||||
| 352 | + | // Apply domain filter if set | |||
| 353 | + | if m.domainFilter != "" { | |||
| 354 | + | filtered := make([]*entity.HistoryEntry, 0) | |||
| 355 | + | for _, e := range entries { | |||
| 356 | + | if getDomain(e.URL) == m.domainFilter { | |||
| 357 | + | filtered = append(filtered, e) | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | entries = filtered | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | // Convert to list items | |||
| 364 | + | items := make([]styles.HistoryItem, len(entries)) | |||
| 365 | + | for i, e := range entries { | |||
| 366 | + | items[i] = styles.HistoryItem{ | |||
| 367 | + | ID: e.ID, | |||
| 368 | + | URL: e.URL, | |||
| 369 | + | Title: e.Title, | |||
| 370 | + | Domain: getDomain(e.URL), | |||
| 371 | + | VisitCount: int(e.VisitCount), | |||
| 372 | + | LastVisited: e.LastVisited, | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | // Create new list | |||
| 377 | + | listHeight := m.height - 8 // Account for tabs, search, help | |||
| 378 | + | if listHeight < 5 { | |||
| 379 | + | listHeight = 5 | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | m.list = styles.NewHistoryList(m.theme, items, m.width, listHeight) | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | // cycleDomainFilter cycles through domain filters. | |||
| 386 | + | func (m *HistoryModel) cycleDomainFilter() { | |||
| 387 | + | if len(m.topDomains) == 0 { | |||
| 388 | + | m.domainFilter = "" | |||
| 389 | + | return | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | // Find current filter in list | |||
| 393 | + | currentIdx := -1 | |||
| 394 | + | for i, d := range m.topDomains { | |||
| 395 | + | if d.Domain == m.domainFilter { | |||
| 396 | + | currentIdx = i | |||
| 397 | + | break | |||
| 398 | + | } | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | // Move to next, or clear filter | |||
| 402 | + | if currentIdx == len(m.topDomains)-1 { | |||
| 403 | + | m.domainFilter = "" // Clear filter | |||
| 404 | + | } else { | |||
| 405 | + | m.domainFilter = m.topDomains[currentIdx+1].Domain | |||
| 406 | + | } | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | // performSearch executes a fuzzy search. | |||
| 410 | + | func (m HistoryModel) performSearch(query string) tea.Cmd { | |||
| 411 | + | return func() tea.Msg { | |||
| 412 | + | log := logging.FromContext(m.ctx) | |||
| 413 | + | log.Debug().Str("query", query).Msg("performing search") | |||
| 414 | + | ||||
| 415 | + | result, err := m.historyUC.Search(m.ctx, usecase.SearchInput{ | |||
| 416 | + | Query: query, | |||
| 417 | + | Limit: 100, | |||
| 418 | + | }) | |||
| 419 | + | if err != nil { | |||
| 420 | + | log.Error().Err(err).Msg("search failed") | |||
| 421 | + | return historyLoadedMsg{err: err} | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | log.Debug().Int("matches", len(result.Matches)).Msg("search completed") | |||
| 425 | + | ||||
| 426 | + | // Convert matches to single list (all in "Today" tab for search results) | |||
| 427 | + | entries := make([]*entity.HistoryEntry, len(result.Matches)) | |||
| 428 | + | for i, match := range result.Matches { | |||
| 429 | + | entries[i] = match.Entry | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | grouped := make([][]*entity.HistoryEntry, 4) | |||
| 433 | + | grouped[0] = entries // Put all results in first tab | |||
| 434 | + | for i := 1; i < 4; i++ { | |||
| 435 | + | grouped[i] = make([]*entity.HistoryEntry, 0) | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | return historyLoadedMsg{entries: grouped} | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | // deleteCurrentEntry deletes the currently selected entry. | |||
| 443 | + | func (m HistoryModel) deleteCurrentEntry() tea.Cmd { | |||
| 444 | + | return func() tea.Msg { | |||
| 445 | + | if item := m.list.SelectedItem(); item != nil { | |||
| 446 | + | if hi, ok := item.(styles.HistoryItem); ok { | |||
| 447 | + | log := logging.FromContext(m.ctx) | |||
| 448 | + | log.Debug().Int64("id", hi.ID).Str("url", hi.URL).Msg("deleting history entry") | |||
| 449 | + | err := m.historyUC.Delete(m.ctx, hi.ID) | |||
| 450 | + | if err != nil { | |||
| 451 | + | log.Error().Err(err).Msg("failed to delete entry") | |||
| 452 | + | } | |||
| 453 | + | return historyDeletedMsg{err: err} | |||
| 454 | + | } | |||
| 455 | + | } | |||
| 456 | + | return historyDeletedMsg{} | |||
| 457 | + | } | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | // performCleanup cleans up history based on selected range. | |||
| 461 | + | func (m HistoryModel) performCleanup(cleanupRange styles.CleanupRange) tea.Cmd { | |||
| 462 | + | return func() tea.Msg { | |||
| 463 | + | log := logging.FromContext(m.ctx) | |||
| 464 | + | cutoff := cleanupRange.CutoffTime() | |||
| 465 | + | ||||
| 466 | + | var err error | |||
| 467 | + | if cutoff.IsZero() { | |||
| 468 | + | log.Info().Msg("clearing all history") | |||
| 469 | + | err = m.historyUC.ClearAll(m.ctx) | |||
| 470 | + | } else { | |||
| 471 | + | log.Info().Time("cutoff", cutoff).Msg("clearing history older than cutoff") | |||
| 472 | + | err = m.historyUC.ClearOlderThan(m.ctx, cutoff) | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | if err != nil { | |||
| 476 | + | log.Error().Err(err).Msg("failed to cleanup history") | |||
| 477 | + | } | |||
| 478 | + | return historyCleanedMsg{err: err} | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | // openURL opens a URL in the default browser. | |||
| 483 | + | func (m HistoryModel) openURL(urlStr string) tea.Cmd { | |||
| 484 | + | return func() tea.Msg { | |||
| 485 | + | // Use xdg-open on Linux | |||
| 486 | + | _ = exec.Command("xdg-open", urlStr).Start() | |||
| 487 | + | return nil | |||
| 488 | + | } | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | // View implements tea.Model. | |||
| 492 | + | func (m HistoryModel) View() string { | |||
| 493 | + | // Handle modals | |||
| 494 | + | if m.cleanup != nil { | |||
| 495 | + | return m.cleanup.View() | |||
| 496 | + | } | |||
| 497 | + | if m.confirm != nil { | |||
| 498 | + | return m.confirm.View() | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | t := m.theme | |||
| 502 | + | ||||
| 503 | + | // Build tab counts | |||
| 504 | + | counts := make([]int, 4) | |||
| 505 | + | for i, entries := range m.entries { | |||
| 506 | + | counts[i] = len(entries) | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | // Header with tabs | |||
| 510 | + | header := m.tabs.ViewWithCounts(counts) | |||
| 511 | + | ||||
| 512 | + | // Search bar | |||
| 513 | + | var searchBar string | |||
| 514 | + | if m.searchMode { | |||
| 515 | + | searchBar = t.InputFocused.Render(m.search.View()) | |||
| 516 | + | } else { | |||
| 517 | + | if m.domainFilter != "" { | |||
| 518 | + | searchBar = t.Subtle.Render("Filter: ") + t.Badge.Render(m.domainFilter) + t.Subtle.Render(" (f to cycle)") | |||
| 519 | + | } else { | |||
| 520 | + | searchBar = t.Subtle.Render("Press / to search, f to filter by domain, Tab/Shift+Tab to switch tabs") | |||
| 521 | + | } | |||
| 522 | + | } | |||
| 523 | + | ||||
| 524 | + | // List | |||
| 525 | + | listView := m.list.View() | |||
| 526 | + | ||||
| 527 | + | // Error display | |||
| 528 | + | if m.err != nil { | |||
| 529 | + | listView = t.ErrorStyle.Render("Error: " + m.err.Error()) | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | // Help | |||
| 533 | + | var helpView string | |||
| 534 | + | if m.showHelp { | |||
| 535 | + | helpView = m.help.View(m.keys) | |||
| 536 | + | } else { | |||
| 537 | + | helpView = t.Subtle.Render("? for help • q to quit") | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | // Combine all parts | |||
| 541 | + | return lipgloss.JoinVertical( | |||
| 542 | + | lipgloss.Left, | |||
| 543 | + | header, | |||
| 544 | + | "", | |||
| 545 | + | searchBar, | |||
| 546 | + | "", | |||
| 547 | + | listView, | |||
| 548 | + | "", | |||
| 549 | + | helpView, | |||
| 550 | + | ) | |||
| 551 | + | } | |||
| 552 | + | ||||
| 553 | + | // getDomain extracts domain from URL. | |||
| 554 | + | func getDomain(urlStr string) string { | |||
| 555 | + | u, err := url.Parse(urlStr) | |||
| 556 | + | if err != nil { | |||
| 557 | + | return "" | |||
| 558 | + | } | |||
| 559 | + | return u.Host | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | // HistoryListModel is a simpler non-interactive model for JSON output. | |||
| 563 | + | type HistoryListModel struct { | |||
| 564 | + | entries []*entity.HistoryEntry | |||
| 565 | + | max int | |||
| 566 | + | err error | |||
| 567 | + | ||||
| 568 | + | ctx context.Context | |||
| 569 | + | historyUC *usecase.SearchHistoryUseCase | |||
| 570 | + | } | |||
| 571 | + | ||||
| 572 | + | // NewHistoryListModel creates a model for list output. | |||
| 573 | + | func NewHistoryListModel(ctx context.Context, historyUC *usecase.SearchHistoryUseCase, maxEntries int) HistoryListModel { | |||
| 574 | + | return HistoryListModel{ | |||
| 575 | + | ctx: ctx, | |||
| 576 | + | historyUC: historyUC, | |||
| 577 | + | max: maxEntries, | |||
| 578 | + | } | |||
| 579 | + | } | |||
| 580 | + | ||||
| 581 | + | // Init implements tea.Model. | |||
| 582 | + | func (m HistoryListModel) Init() tea.Cmd { | |||
| 583 | + | return func() tea.Msg { | |||
| 584 | + | log := logging.FromContext(m.ctx) | |||
| 585 | + | log.Debug().Int("max", m.max).Msg("loading history for JSON output") | |||
| 586 | + | ||||
| 587 | + | entries, err := m.historyUC.GetRecent(m.ctx, m.max, 0) | |||
| 588 | + | if err != nil { | |||
| 589 | + | log.Error().Err(err).Msg("failed to load history") | |||
| 590 | + | return historyLoadedMsg{err: err} | |||
| 591 | + | } | |||
| 592 | + | ||||
| 593 | + | log.Debug().Int("count", len(entries)).Msg("loaded history entries") | |||
| 594 | + | ||||
| 595 | + | grouped := make([][]*entity.HistoryEntry, 1) | |||
| 596 | + | grouped[0] = entries | |||
| 597 | + | return historyLoadedMsg{entries: grouped} | |||
| 598 | + | } | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | // Update implements tea.Model. | |||
| 602 | + | func (m HistoryListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 603 | + | switch msg := msg.(type) { | |||
| 604 | + | case historyLoadedMsg: | |||
| 605 | + | if msg.err != nil { | |||
| 606 | + | m.err = msg.err | |||
| 607 | + | } else if len(msg.entries) > 0 { | |||
| 608 | + | m.entries = msg.entries[0] | |||
| 609 | + | } | |||
| 610 | + | return m, tea.Quit | |||
| 611 | + | } | |||
| 612 | + | return m, nil | |||
| 613 | + | } | |||
| 614 | + | ||||
| 615 | + | // View implements tea.Model. | |||
| 616 | + | func (m HistoryListModel) View() string { | |||
| 617 | + | return "" // Output handled externally | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | // Entries returns the loaded entries. | |||
| 621 | + | func (m HistoryListModel) Entries() []*entity.HistoryEntry { | |||
| 622 | + | return m.entries | |||
| 623 | + | } | |||
| 624 | + | ||||
| 625 | + | // Error returns any error that occurred. | |||
| 626 | + | func (m HistoryListModel) Error() error { | |||
| 627 | + | return m.err | |||
| 628 | + | } | |||
| 629 | + | ||||
| 630 | + | // Ensure interface compliance at compile time. | |||
| 631 | + | var _ tea.Model = (*HistoryModel)(nil) | |||
| 632 | + | var _ tea.Model = (*HistoryListModel)(nil) | |||
| 633 | + | ||||
| 634 | + | // Unexported helper to avoid exposing time package in imports when not needed. | |||
| 635 | + | var _ = time.Now | |||
A internal/cli/model/stats.go +245 -0
| @@ -0,0 +1,245 @@ | |||
| 1 | + | package model | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/charmbracelet/bubbles/table" | |
| 8 | + | tea "github.com/charmbracelet/bubbletea" | |
| 9 | + | "github.com/charmbracelet/lipgloss" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 12 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // StatsModel displays history analytics. | |
| 17 | + | type StatsModel struct { | |
| 18 | + | analytics *entity.HistoryAnalytics | |
| 19 | + | table table.Model | |
| 20 | + | loading bool | |
| 21 | + | err error | |
| 22 | + | width int | |
| 23 | + | height int | |
| 24 | + | ||
| 25 | + | historyUC *usecase.SearchHistoryUseCase | |
| 26 | + | theme *styles.Theme | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // NewStatsModel creates a new stats display model. | |
| 30 | + | func NewStatsModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) StatsModel { | |
| 31 | + | return StatsModel{ | |
| 32 | + | historyUC: historyUC, | |
| 33 | + | theme: theme, | |
| 34 | + | loading: true, | |
| 35 | + | width: 80, | |
| 36 | + | height: 24, | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // analyticsLoadedMsg is sent when analytics are loaded. | |
| 41 | + | type analyticsLoadedMsg struct { | |
| 42 | + | analytics *entity.HistoryAnalytics | |
| 43 | + | err error | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Init implements tea.Model. | |
| 47 | + | func (m StatsModel) Init() tea.Cmd { | |
| 48 | + | return m.loadAnalytics | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // loadAnalytics loads history analytics. | |
| 52 | + | func (m StatsModel) loadAnalytics() tea.Msg { | |
| 53 | + | ctx := context.Background() | |
| 54 | + | analytics, err := m.historyUC.GetAnalytics(ctx) | |
| 55 | + | return analyticsLoadedMsg{analytics: analytics, err: err} | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Update implements tea.Model. | |
| 59 | + | func (m StatsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 60 | + | switch msg := msg.(type) { | |
| 61 | + | case tea.WindowSizeMsg: | |
| 62 | + | m.width = msg.Width | |
| 63 | + | m.height = msg.Height | |
| 64 | + | m.updateTable() | |
| 65 | + | ||
| 66 | + | case tea.KeyMsg: | |
| 67 | + | switch msg.String() { | |
| 68 | + | case "q", "ctrl+c", "esc": | |
| 69 | + | return m, tea.Quit | |
| 70 | + | default: | |
| 71 | + | var cmd tea.Cmd | |
| 72 | + | m.table, cmd = m.table.Update(msg) | |
| 73 | + | return m, cmd | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | case analyticsLoadedMsg: | |
| 77 | + | m.loading = false | |
| 78 | + | if msg.err != nil { | |
| 79 | + | m.err = msg.err | |
| 80 | + | } else { | |
| 81 | + | m.analytics = msg.analytics | |
| 82 | + | m.updateTable() | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | return m, nil | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // updateTable builds the domain stats table. | |
| 90 | + | func (m *StatsModel) updateTable() { | |
| 91 | + | if m.analytics == nil || len(m.analytics.TopDomains) == 0 { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | columns := styles.StatsTableColumns() | |
| 96 | + | ||
| 97 | + | rows := make([]table.Row, len(m.analytics.TopDomains)) | |
| 98 | + | for i, d := range m.analytics.TopDomains { | |
| 99 | + | rows[i] = table.Row{ | |
| 100 | + | d.Domain, | |
| 101 | + | formatNumber(d.TotalVisits), | |
| 102 | + | formatNumber(d.PageCount), | |
| 103 | + | styles.RelativeTime(d.LastVisit), | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | tableHeight := len(rows) | |
| 108 | + | if tableHeight > m.height-10 { | |
| 109 | + | tableHeight = m.height - 10 | |
| 110 | + | } | |
| 111 | + | if tableHeight < 3 { | |
| 112 | + | tableHeight = 3 | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | m.table = styles.NewStyledTable(m.theme, columns, rows, m.width-4, tableHeight) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // View implements tea.Model. | |
| 119 | + | func (m StatsModel) View() string { | |
| 120 | + | t := m.theme | |
| 121 | + | ||
| 122 | + | if m.loading { | |
| 123 | + | spinner := styles.NewLoading(t, "Loading analytics...") | |
| 124 | + | return t.Box.Render(spinner.View()) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | if m.err != nil { | |
| 128 | + | return t.Box.Render(t.ErrorStyle.Render("Error: " + m.err.Error())) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | if m.analytics == nil { | |
| 132 | + | return t.Box.Render(t.Subtle.Render("No data available")) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // Build header with summary stats | |
| 136 | + | header := lipgloss.JoinVertical( | |
| 137 | + | lipgloss.Left, | |
| 138 | + | t.Title.Render("History Analytics"), | |
| 139 | + | "", | |
| 140 | + | lipgloss.JoinHorizontal( | |
| 141 | + | lipgloss.Top, | |
| 142 | + | t.Badge.Render(fmt.Sprintf("%d entries", m.analytics.TotalEntries)), | |
| 143 | + | " ", | |
| 144 | + | t.Badge.Render(fmt.Sprintf("%d visits", m.analytics.TotalVisits)), | |
| 145 | + | " ", | |
| 146 | + | t.BadgeMuted.Render(fmt.Sprintf("%d days", m.analytics.UniqueDays)), | |
| 147 | + | ), | |
| 148 | + | ) | |
| 149 | + | ||
| 150 | + | // Domain table | |
| 151 | + | tableHeader := t.Subtitle.Render("Top Domains") | |
| 152 | + | tableView := m.table.View() | |
| 153 | + | ||
| 154 | + | // Daily activity (simple bar chart) | |
| 155 | + | var activityView string | |
| 156 | + | if len(m.analytics.DailyVisits) > 0 { | |
| 157 | + | activityView = m.renderDailyActivity() | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // Help | |
| 161 | + | helpView := t.Subtle.Render("q to quit") | |
| 162 | + | ||
| 163 | + | // Combine | |
| 164 | + | content := lipgloss.JoinVertical( | |
| 165 | + | lipgloss.Left, | |
| 166 | + | header, | |
| 167 | + | "", | |
| 168 | + | tableHeader, | |
| 169 | + | tableView, | |
| 170 | + | ) | |
| 171 | + | ||
| 172 | + | if activityView != "" { | |
| 173 | + | content = lipgloss.JoinVertical( | |
| 174 | + | lipgloss.Left, | |
| 175 | + | content, | |
| 176 | + | "", | |
| 177 | + | t.Subtitle.Render("Daily Activity (Last 30 Days)"), | |
| 178 | + | activityView, | |
| 179 | + | ) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | content = lipgloss.JoinVertical( | |
| 183 | + | lipgloss.Left, | |
| 184 | + | content, | |
| 185 | + | "", | |
| 186 | + | helpView, | |
| 187 | + | ) | |
| 188 | + | ||
| 189 | + | return content | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // renderDailyActivity renders a simple sparkline-style bar chart. | |
| 193 | + | func (m StatsModel) renderDailyActivity() string { | |
| 194 | + | if m.analytics == nil || len(m.analytics.DailyVisits) == 0 { | |
| 195 | + | return "" | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // Find max for scaling | |
| 199 | + | var maxVisits int64 | |
| 200 | + | for _, d := range m.analytics.DailyVisits { | |
| 201 | + | if d.Visits > maxVisits { | |
| 202 | + | maxVisits = d.Visits | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | if maxVisits == 0 { | |
| 207 | + | return m.theme.Subtle.Render("No activity") | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // Build sparkline using block characters | |
| 211 | + | blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} | |
| 212 | + | var result string | |
| 213 | + | ||
| 214 | + | // Show last 30 days or available data | |
| 215 | + | days := m.analytics.DailyVisits | |
| 216 | + | if len(days) > 30 { | |
| 217 | + | days = days[len(days)-30:] | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | for _, d := range days { | |
| 221 | + | // Scale to block index (0-7) | |
| 222 | + | idx := int(float64(d.Visits) / float64(maxVisits) * 7) | |
| 223 | + | if idx > 7 { | |
| 224 | + | idx = 7 | |
| 225 | + | } | |
| 226 | + | result += string(blocks[idx]) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | return lipgloss.NewStyle().Foreground(m.theme.Accent).Render(result) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // formatNumber formats a number for display. | |
| 233 | + | func formatNumber(n int64) string { | |
| 234 | + | switch { | |
| 235 | + | case n >= 1000000: | |
| 236 | + | return fmt.Sprintf("%.1fM", float64(n)/1000000) | |
| 237 | + | case n >= 1000: | |
| 238 | + | return fmt.Sprintf("%.1fK", float64(n)/1000) | |
| 239 | + | default: | |
| 240 | + | return fmt.Sprintf("%d", n) | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // Ensure interface compliance. | |
| 245 | + | var _ tea.Model = (*StatsModel)(nil) | |
| @@ -0,0 +1,245 @@ | |||||
| 1 | + | package model | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/charmbracelet/bubbles/table" | |||
| 8 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 9 | + | "github.com/charmbracelet/lipgloss" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 12 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // StatsModel displays history analytics. | |||
| 17 | + | type StatsModel struct { | |||
| 18 | + | analytics *entity.HistoryAnalytics | |||
| 19 | + | table table.Model | |||
| 20 | + | loading bool | |||
| 21 | + | err error | |||
| 22 | + | width int | |||
| 23 | + | height int | |||
| 24 | + | ||||
| 25 | + | historyUC *usecase.SearchHistoryUseCase | |||
| 26 | + | theme *styles.Theme | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // NewStatsModel creates a new stats display model. | |||
| 30 | + | func NewStatsModel(theme *styles.Theme, historyUC *usecase.SearchHistoryUseCase) StatsModel { | |||
| 31 | + | return StatsModel{ | |||
| 32 | + | historyUC: historyUC, | |||
| 33 | + | theme: theme, | |||
| 34 | + | loading: true, | |||
| 35 | + | width: 80, | |||
| 36 | + | height: 24, | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // analyticsLoadedMsg is sent when analytics are loaded. | |||
| 41 | + | type analyticsLoadedMsg struct { | |||
| 42 | + | analytics *entity.HistoryAnalytics | |||
| 43 | + | err error | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Init implements tea.Model. | |||
| 47 | + | func (m StatsModel) Init() tea.Cmd { | |||
| 48 | + | return m.loadAnalytics | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // loadAnalytics loads history analytics. | |||
| 52 | + | func (m StatsModel) loadAnalytics() tea.Msg { | |||
| 53 | + | ctx := context.Background() | |||
| 54 | + | analytics, err := m.historyUC.GetAnalytics(ctx) | |||
| 55 | + | return analyticsLoadedMsg{analytics: analytics, err: err} | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Update implements tea.Model. | |||
| 59 | + | func (m StatsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 60 | + | switch msg := msg.(type) { | |||
| 61 | + | case tea.WindowSizeMsg: | |||
| 62 | + | m.width = msg.Width | |||
| 63 | + | m.height = msg.Height | |||
| 64 | + | m.updateTable() | |||
| 65 | + | ||||
| 66 | + | case tea.KeyMsg: | |||
| 67 | + | switch msg.String() { | |||
| 68 | + | case "q", "ctrl+c", "esc": | |||
| 69 | + | return m, tea.Quit | |||
| 70 | + | default: | |||
| 71 | + | var cmd tea.Cmd | |||
| 72 | + | m.table, cmd = m.table.Update(msg) | |||
| 73 | + | return m, cmd | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | case analyticsLoadedMsg: | |||
| 77 | + | m.loading = false | |||
| 78 | + | if msg.err != nil { | |||
| 79 | + | m.err = msg.err | |||
| 80 | + | } else { | |||
| 81 | + | m.analytics = msg.analytics | |||
| 82 | + | m.updateTable() | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | return m, nil | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // updateTable builds the domain stats table. | |||
| 90 | + | func (m *StatsModel) updateTable() { | |||
| 91 | + | if m.analytics == nil || len(m.analytics.TopDomains) == 0 { | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | columns := styles.StatsTableColumns() | |||
| 96 | + | ||||
| 97 | + | rows := make([]table.Row, len(m.analytics.TopDomains)) | |||
| 98 | + | for i, d := range m.analytics.TopDomains { | |||
| 99 | + | rows[i] = table.Row{ | |||
| 100 | + | d.Domain, | |||
| 101 | + | formatNumber(d.TotalVisits), | |||
| 102 | + | formatNumber(d.PageCount), | |||
| 103 | + | styles.RelativeTime(d.LastVisit), | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | tableHeight := len(rows) | |||
| 108 | + | if tableHeight > m.height-10 { | |||
| 109 | + | tableHeight = m.height - 10 | |||
| 110 | + | } | |||
| 111 | + | if tableHeight < 3 { | |||
| 112 | + | tableHeight = 3 | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | m.table = styles.NewStyledTable(m.theme, columns, rows, m.width-4, tableHeight) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // View implements tea.Model. | |||
| 119 | + | func (m StatsModel) View() string { | |||
| 120 | + | t := m.theme | |||
| 121 | + | ||||
| 122 | + | if m.loading { | |||
| 123 | + | spinner := styles.NewLoading(t, "Loading analytics...") | |||
| 124 | + | return t.Box.Render(spinner.View()) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | if m.err != nil { | |||
| 128 | + | return t.Box.Render(t.ErrorStyle.Render("Error: " + m.err.Error())) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | if m.analytics == nil { | |||
| 132 | + | return t.Box.Render(t.Subtle.Render("No data available")) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // Build header with summary stats | |||
| 136 | + | header := lipgloss.JoinVertical( | |||
| 137 | + | lipgloss.Left, | |||
| 138 | + | t.Title.Render("History Analytics"), | |||
| 139 | + | "", | |||
| 140 | + | lipgloss.JoinHorizontal( | |||
| 141 | + | lipgloss.Top, | |||
| 142 | + | t.Badge.Render(fmt.Sprintf("%d entries", m.analytics.TotalEntries)), | |||
| 143 | + | " ", | |||
| 144 | + | t.Badge.Render(fmt.Sprintf("%d visits", m.analytics.TotalVisits)), | |||
| 145 | + | " ", | |||
| 146 | + | t.BadgeMuted.Render(fmt.Sprintf("%d days", m.analytics.UniqueDays)), | |||
| 147 | + | ), | |||
| 148 | + | ) | |||
| 149 | + | ||||
| 150 | + | // Domain table | |||
| 151 | + | tableHeader := t.Subtitle.Render("Top Domains") | |||
| 152 | + | tableView := m.table.View() | |||
| 153 | + | ||||
| 154 | + | // Daily activity (simple bar chart) | |||
| 155 | + | var activityView string | |||
| 156 | + | if len(m.analytics.DailyVisits) > 0 { | |||
| 157 | + | activityView = m.renderDailyActivity() | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // Help | |||
| 161 | + | helpView := t.Subtle.Render("q to quit") | |||
| 162 | + | ||||
| 163 | + | // Combine | |||
| 164 | + | content := lipgloss.JoinVertical( | |||
| 165 | + | lipgloss.Left, | |||
| 166 | + | header, | |||
| 167 | + | "", | |||
| 168 | + | tableHeader, | |||
| 169 | + | tableView, | |||
| 170 | + | ) | |||
| 171 | + | ||||
| 172 | + | if activityView != "" { | |||
| 173 | + | content = lipgloss.JoinVertical( | |||
| 174 | + | lipgloss.Left, | |||
| 175 | + | content, | |||
| 176 | + | "", | |||
| 177 | + | t.Subtitle.Render("Daily Activity (Last 30 Days)"), | |||
| 178 | + | activityView, | |||
| 179 | + | ) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | content = lipgloss.JoinVertical( | |||
| 183 | + | lipgloss.Left, | |||
| 184 | + | content, | |||
| 185 | + | "", | |||
| 186 | + | helpView, | |||
| 187 | + | ) | |||
| 188 | + | ||||
| 189 | + | return content | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // renderDailyActivity renders a simple sparkline-style bar chart. | |||
| 193 | + | func (m StatsModel) renderDailyActivity() string { | |||
| 194 | + | if m.analytics == nil || len(m.analytics.DailyVisits) == 0 { | |||
| 195 | + | return "" | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // Find max for scaling | |||
| 199 | + | var maxVisits int64 | |||
| 200 | + | for _, d := range m.analytics.DailyVisits { | |||
| 201 | + | if d.Visits > maxVisits { | |||
| 202 | + | maxVisits = d.Visits | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | if maxVisits == 0 { | |||
| 207 | + | return m.theme.Subtle.Render("No activity") | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // Build sparkline using block characters | |||
| 211 | + | blocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} | |||
| 212 | + | var result string | |||
| 213 | + | ||||
| 214 | + | // Show last 30 days or available data | |||
| 215 | + | days := m.analytics.DailyVisits | |||
| 216 | + | if len(days) > 30 { | |||
| 217 | + | days = days[len(days)-30:] | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | for _, d := range days { | |||
| 221 | + | // Scale to block index (0-7) | |||
| 222 | + | idx := int(float64(d.Visits) / float64(maxVisits) * 7) | |||
| 223 | + | if idx > 7 { | |||
| 224 | + | idx = 7 | |||
| 225 | + | } | |||
| 226 | + | result += string(blocks[idx]) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | return lipgloss.NewStyle().Foreground(m.theme.Accent).Render(result) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | // formatNumber formats a number for display. | |||
| 233 | + | func formatNumber(n int64) string { | |||
| 234 | + | switch { | |||
| 235 | + | case n >= 1000000: | |||
| 236 | + | return fmt.Sprintf("%.1fM", float64(n)/1000000) | |||
| 237 | + | case n >= 1000: | |||
| 238 | + | return fmt.Sprintf("%.1fK", float64(n)/1000) | |||
| 239 | + | default: | |||
| 240 | + | return fmt.Sprintf("%d", n) | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // Ensure interface compliance. | |||
| 245 | + | var _ tea.Model = (*StatsModel)(nil) | |||
A internal/cli/styles/badge.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/charmbracelet/lipgloss" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Badge renders styled metadata badges. | |
| 11 | + | ||
| 12 | + | // VisitBadge renders a visit count badge. | |
| 13 | + | func (t *Theme) VisitBadge(count int) string { | |
| 14 | + | text := fmt.Sprintf("%d visits", count) | |
| 15 | + | if count == 1 { | |
| 16 | + | text = "1 visit" | |
| 17 | + | } | |
| 18 | + | return t.BadgeMuted.Render(text) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // TimeBadge renders a relative time badge. | |
| 22 | + | func (t *Theme) TimeBadge(tm time.Time) string { | |
| 23 | + | text := RelativeTime(tm) | |
| 24 | + | return t.BadgeMuted.Render(text) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // DomainBadge renders a domain badge. | |
| 28 | + | func (t *Theme) DomainBadge(domain string) string { | |
| 29 | + | return t.Badge.Render(domain) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // AccentBadge renders a badge with accent color. | |
| 33 | + | func (t *Theme) AccentBadge(text string) string { | |
| 34 | + | return t.Badge.Render(text) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // MutedBadge renders a badge with muted colors. | |
| 38 | + | func (t *Theme) MutedBadge(text string) string { | |
| 39 | + | return t.BadgeMuted.Render(text) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // StatusBadge renders a status badge with custom colors. | |
| 43 | + | func (t *Theme) StatusBadge(text string, fg, bg lipgloss.Color) string { | |
| 44 | + | style := lipgloss.NewStyle(). | |
| 45 | + | Foreground(fg). | |
| 46 | + | Background(bg). | |
| 47 | + | Padding(0, 1) | |
| 48 | + | return style.Render(text) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // RelativeTime formats a time as a human-readable relative string. | |
| 52 | + | func RelativeTime(tm time.Time) string { | |
| 53 | + | now := time.Now() | |
| 54 | + | diff := now.Sub(tm) | |
| 55 | + | ||
| 56 | + | switch { | |
| 57 | + | case diff < time.Minute: | |
| 58 | + | return "just now" | |
| 59 | + | case diff < time.Hour: | |
| 60 | + | mins := int(diff.Minutes()) | |
| 61 | + | if mins == 1 { | |
| 62 | + | return "1m ago" | |
| 63 | + | } | |
| 64 | + | return fmt.Sprintf("%dm ago", mins) | |
| 65 | + | case diff < 24*time.Hour: | |
| 66 | + | hours := int(diff.Hours()) | |
| 67 | + | if hours == 1 { | |
| 68 | + | return "1h ago" | |
| 69 | + | } | |
| 70 | + | return fmt.Sprintf("%dh ago", hours) | |
| 71 | + | case diff < 7*24*time.Hour: | |
| 72 | + | days := int(diff.Hours() / 24) | |
| 73 | + | if days == 1 { | |
| 74 | + | return "1d ago" | |
| 75 | + | } | |
| 76 | + | return fmt.Sprintf("%dd ago", days) | |
| 77 | + | case diff < 30*24*time.Hour: | |
| 78 | + | weeks := int(diff.Hours() / (24 * 7)) | |
| 79 | + | if weeks == 1 { | |
| 80 | + | return "1w ago" | |
| 81 | + | } | |
| 82 | + | return fmt.Sprintf("%dw ago", weeks) | |
| 83 | + | case diff < 365*24*time.Hour: | |
| 84 | + | months := int(diff.Hours() / (24 * 30)) | |
| 85 | + | if months == 1 { | |
| 86 | + | return "1mo ago" | |
| 87 | + | } | |
| 88 | + | return fmt.Sprintf("%dmo ago", months) | |
| 89 | + | default: | |
| 90 | + | years := int(diff.Hours() / (24 * 365)) | |
| 91 | + | if years == 1 { | |
| 92 | + | return "1y ago" | |
| 93 | + | } | |
| 94 | + | return fmt.Sprintf("%dy ago", years) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // TimeCategory returns which timeline category a time belongs to. | |
| 99 | + | type TimeCategory int | |
| 100 | + | ||
| 101 | + | const ( | |
| 102 | + | TimeCategoryToday TimeCategory = iota | |
| 103 | + | TimeCategoryYesterday | |
| 104 | + | TimeCategoryThisWeek | |
| 105 | + | TimeCategoryOlder | |
| 106 | + | ) | |
| 107 | + | ||
| 108 | + | // GetTimeCategory returns the category for a given time. | |
| 109 | + | func GetTimeCategory(tm time.Time) TimeCategory { | |
| 110 | + | now := time.Now() | |
| 111 | + | today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) | |
| 112 | + | yesterday := today.AddDate(0, 0, -1) | |
| 113 | + | weekAgo := today.AddDate(0, 0, -7) | |
| 114 | + | ||
| 115 | + | switch { | |
| 116 | + | case tm.After(today) || tm.Equal(today): | |
| 117 | + | return TimeCategoryToday | |
| 118 | + | case tm.After(yesterday) || tm.Equal(yesterday): | |
| 119 | + | return TimeCategoryYesterday | |
| 120 | + | case tm.After(weekAgo): | |
| 121 | + | return TimeCategoryThisWeek | |
| 122 | + | default: | |
| 123 | + | return TimeCategoryOlder | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // TimeCategoryLabel returns the display label for a time category. | |
| 128 | + | func TimeCategoryLabel(cat TimeCategory) string { | |
| 129 | + | switch cat { | |
| 130 | + | case TimeCategoryToday: | |
| 131 | + | return "Today" | |
| 132 | + | case TimeCategoryYesterday: | |
| 133 | + | return "Yesterday" | |
| 134 | + | case TimeCategoryThisWeek: | |
| 135 | + | return "This Week" | |
| 136 | + | case TimeCategoryOlder: | |
| 137 | + | return "Older" | |
| 138 | + | default: | |
| 139 | + | return "Unknown" | |
| 140 | + | } | |
| 141 | + | } | |
| @@ -0,0 +1,141 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/charmbracelet/lipgloss" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Badge renders styled metadata badges. | |||
| 11 | + | ||||
| 12 | + | // VisitBadge renders a visit count badge. | |||
| 13 | + | func (t *Theme) VisitBadge(count int) string { | |||
| 14 | + | text := fmt.Sprintf("%d visits", count) | |||
| 15 | + | if count == 1 { | |||
| 16 | + | text = "1 visit" | |||
| 17 | + | } | |||
| 18 | + | return t.BadgeMuted.Render(text) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // TimeBadge renders a relative time badge. | |||
| 22 | + | func (t *Theme) TimeBadge(tm time.Time) string { | |||
| 23 | + | text := RelativeTime(tm) | |||
| 24 | + | return t.BadgeMuted.Render(text) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // DomainBadge renders a domain badge. | |||
| 28 | + | func (t *Theme) DomainBadge(domain string) string { | |||
| 29 | + | return t.Badge.Render(domain) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // AccentBadge renders a badge with accent color. | |||
| 33 | + | func (t *Theme) AccentBadge(text string) string { | |||
| 34 | + | return t.Badge.Render(text) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // MutedBadge renders a badge with muted colors. | |||
| 38 | + | func (t *Theme) MutedBadge(text string) string { | |||
| 39 | + | return t.BadgeMuted.Render(text) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // StatusBadge renders a status badge with custom colors. | |||
| 43 | + | func (t *Theme) StatusBadge(text string, fg, bg lipgloss.Color) string { | |||
| 44 | + | style := lipgloss.NewStyle(). | |||
| 45 | + | Foreground(fg). | |||
| 46 | + | Background(bg). | |||
| 47 | + | Padding(0, 1) | |||
| 48 | + | return style.Render(text) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // RelativeTime formats a time as a human-readable relative string. | |||
| 52 | + | func RelativeTime(tm time.Time) string { | |||
| 53 | + | now := time.Now() | |||
| 54 | + | diff := now.Sub(tm) | |||
| 55 | + | ||||
| 56 | + | switch { | |||
| 57 | + | case diff < time.Minute: | |||
| 58 | + | return "just now" | |||
| 59 | + | case diff < time.Hour: | |||
| 60 | + | mins := int(diff.Minutes()) | |||
| 61 | + | if mins == 1 { | |||
| 62 | + | return "1m ago" | |||
| 63 | + | } | |||
| 64 | + | return fmt.Sprintf("%dm ago", mins) | |||
| 65 | + | case diff < 24*time.Hour: | |||
| 66 | + | hours := int(diff.Hours()) | |||
| 67 | + | if hours == 1 { | |||
| 68 | + | return "1h ago" | |||
| 69 | + | } | |||
| 70 | + | return fmt.Sprintf("%dh ago", hours) | |||
| 71 | + | case diff < 7*24*time.Hour: | |||
| 72 | + | days := int(diff.Hours() / 24) | |||
| 73 | + | if days == 1 { | |||
| 74 | + | return "1d ago" | |||
| 75 | + | } | |||
| 76 | + | return fmt.Sprintf("%dd ago", days) | |||
| 77 | + | case diff < 30*24*time.Hour: | |||
| 78 | + | weeks := int(diff.Hours() / (24 * 7)) | |||
| 79 | + | if weeks == 1 { | |||
| 80 | + | return "1w ago" | |||
| 81 | + | } | |||
| 82 | + | return fmt.Sprintf("%dw ago", weeks) | |||
| 83 | + | case diff < 365*24*time.Hour: | |||
| 84 | + | months := int(diff.Hours() / (24 * 30)) | |||
| 85 | + | if months == 1 { | |||
| 86 | + | return "1mo ago" | |||
| 87 | + | } | |||
| 88 | + | return fmt.Sprintf("%dmo ago", months) | |||
| 89 | + | default: | |||
| 90 | + | years := int(diff.Hours() / (24 * 365)) | |||
| 91 | + | if years == 1 { | |||
| 92 | + | return "1y ago" | |||
| 93 | + | } | |||
| 94 | + | return fmt.Sprintf("%dy ago", years) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // TimeCategory returns which timeline category a time belongs to. | |||
| 99 | + | type TimeCategory int | |||
| 100 | + | ||||
| 101 | + | const ( | |||
| 102 | + | TimeCategoryToday TimeCategory = iota | |||
| 103 | + | TimeCategoryYesterday | |||
| 104 | + | TimeCategoryThisWeek | |||
| 105 | + | TimeCategoryOlder | |||
| 106 | + | ) | |||
| 107 | + | ||||
| 108 | + | // GetTimeCategory returns the category for a given time. | |||
| 109 | + | func GetTimeCategory(tm time.Time) TimeCategory { | |||
| 110 | + | now := time.Now() | |||
| 111 | + | today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) | |||
| 112 | + | yesterday := today.AddDate(0, 0, -1) | |||
| 113 | + | weekAgo := today.AddDate(0, 0, -7) | |||
| 114 | + | ||||
| 115 | + | switch { | |||
| 116 | + | case tm.After(today) || tm.Equal(today): | |||
| 117 | + | return TimeCategoryToday | |||
| 118 | + | case tm.After(yesterday) || tm.Equal(yesterday): | |||
| 119 | + | return TimeCategoryYesterday | |||
| 120 | + | case tm.After(weekAgo): | |||
| 121 | + | return TimeCategoryThisWeek | |||
| 122 | + | default: | |||
| 123 | + | return TimeCategoryOlder | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // TimeCategoryLabel returns the display label for a time category. | |||
| 128 | + | func TimeCategoryLabel(cat TimeCategory) string { | |||
| 129 | + | switch cat { | |||
| 130 | + | case TimeCategoryToday: | |||
| 131 | + | return "Today" | |||
| 132 | + | case TimeCategoryYesterday: | |||
| 133 | + | return "Yesterday" | |||
| 134 | + | case TimeCategoryThisWeek: | |||
| 135 | + | return "This Week" | |||
| 136 | + | case TimeCategoryOlder: | |||
| 137 | + | return "Older" | |||
| 138 | + | default: | |||
| 139 | + | return "Unknown" | |||
| 140 | + | } | |||
| 141 | + | } | |||
A internal/cli/styles/cleanup.go +216 -0
| @@ -0,0 +1,216 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/charmbracelet/bubbles/key" | |
| 7 | + | tea "github.com/charmbracelet/bubbletea" | |
| 8 | + | "github.com/charmbracelet/lipgloss" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // CleanupRange represents a time range for history cleanup. | |
| 12 | + | type CleanupRange int | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | CleanupRangeLastHour CleanupRange = iota | |
| 16 | + | CleanupRangeLast24Hours | |
| 17 | + | CleanupRangeLast7Days | |
| 18 | + | CleanupRangeLast30Days | |
| 19 | + | CleanupRangeAll | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // Cursor symbols for list items (using Unicode). | |
| 23 | + | const ( | |
| 24 | + | cursorEmpty = " " | |
| 25 | + | cursorSelected = "\u25b8 " // ▸ Black right-pointing small triangle | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // CleanupRangeInfo holds display info for a cleanup range. | |
| 29 | + | type CleanupRangeInfo struct { | |
| 30 | + | Range CleanupRange | |
| 31 | + | Label string | |
| 32 | + | Description string | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // AllCleanupRanges returns all available cleanup ranges. | |
| 36 | + | func AllCleanupRanges() []CleanupRangeInfo { | |
| 37 | + | return []CleanupRangeInfo{ | |
| 38 | + | {CleanupRangeLastHour, "Last hour", "Delete entries from the last hour"}, | |
| 39 | + | {CleanupRangeLast24Hours, "Last 24 hours", "Delete entries from the last day"}, | |
| 40 | + | {CleanupRangeLast7Days, "Last 7 days", "Delete entries from the last week"}, | |
| 41 | + | {CleanupRangeLast30Days, "Last 30 days", "Delete entries from the last month"}, | |
| 42 | + | {CleanupRangeAll, "All time", "Delete all history entries"}, | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // CutoffTime returns the cutoff time for this range. | |
| 47 | + | func (r CleanupRange) CutoffTime() time.Time { | |
| 48 | + | now := time.Now() | |
| 49 | + | switch r { | |
| 50 | + | case CleanupRangeLastHour: | |
| 51 | + | return now.Add(-1 * time.Hour) | |
| 52 | + | case CleanupRangeLast24Hours: | |
| 53 | + | return now.Add(-24 * time.Hour) | |
| 54 | + | case CleanupRangeLast7Days: | |
| 55 | + | return now.Add(-7 * 24 * time.Hour) | |
| 56 | + | case CleanupRangeLast30Days: | |
| 57 | + | return now.Add(-30 * 24 * time.Hour) | |
| 58 | + | case CleanupRangeAll: | |
| 59 | + | return time.Time{} // Zero time = delete all | |
| 60 | + | default: | |
| 61 | + | return time.Time{} | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // CleanupModel is a cleanup range selection modal. | |
| 66 | + | type CleanupModel struct { | |
| 67 | + | Ranges []CleanupRangeInfo | |
| 68 | + | Selected int | |
| 69 | + | Confirmed bool | |
| 70 | + | Cancelled bool | |
| 71 | + | theme *Theme | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // CleanupKeyMap defines keybindings for cleanup modal. | |
| 75 | + | type CleanupKeyMap struct { | |
| 76 | + | Up key.Binding | |
| 77 | + | Down key.Binding | |
| 78 | + | Confirm key.Binding | |
| 79 | + | Cancel key.Binding | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // DefaultCleanupKeyMap returns default keybindings. | |
| 83 | + | func DefaultCleanupKeyMap() CleanupKeyMap { | |
| 84 | + | return CleanupKeyMap{ | |
| 85 | + | Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), | |
| 86 | + | Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), | |
| 87 | + | Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), | |
| 88 | + | Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")), | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // NewCleanup creates a new cleanup modal. | |
| 93 | + | func NewCleanup(theme *Theme) CleanupModel { | |
| 94 | + | return CleanupModel{ | |
| 95 | + | Ranges: AllCleanupRanges(), | |
| 96 | + | Selected: 0, | |
| 97 | + | theme: theme, | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Init implements tea.Model. | |
| 102 | + | func (m CleanupModel) Init() tea.Cmd { | |
| 103 | + | return nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // Update implements tea.Model. | |
| 107 | + | func (m CleanupModel) Update(msg tea.Msg) (CleanupModel, tea.Cmd) { | |
| 108 | + | keys := DefaultCleanupKeyMap() | |
| 109 | + | ||
| 110 | + | switch msg := msg.(type) { | |
| 111 | + | case tea.KeyMsg: | |
| 112 | + | switch { | |
| 113 | + | case key.Matches(msg, keys.Up): | |
| 114 | + | if m.Selected > 0 { | |
| 115 | + | m.Selected-- | |
| 116 | + | } | |
| 117 | + | case key.Matches(msg, keys.Down): | |
| 118 | + | if m.Selected < len(m.Ranges)-1 { | |
| 119 | + | m.Selected++ | |
| 120 | + | } | |
| 121 | + | case key.Matches(msg, keys.Confirm): | |
| 122 | + | m.Confirmed = true | |
| 123 | + | case key.Matches(msg, keys.Cancel): | |
| 124 | + | m.Cancelled = true | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | return m, nil | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // View implements tea.Model. | |
| 132 | + | func (m CleanupModel) View() string { | |
| 133 | + | t := m.theme | |
| 134 | + | ||
| 135 | + | // Build header | |
| 136 | + | header := t.Title.Copy(). | |
| 137 | + | Foreground(t.Error). | |
| 138 | + | Render("Clear History") | |
| 139 | + | ||
| 140 | + | // Build range list | |
| 141 | + | var rows []string | |
| 142 | + | for i, r := range m.Ranges { | |
| 143 | + | cursor := cursorEmpty | |
| 144 | + | style := t.ListItem | |
| 145 | + | if i == m.Selected { | |
| 146 | + | cursor = cursorSelected | |
| 147 | + | style = t.ListItemSelected | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | label := style.Render(r.Label) | |
| 151 | + | desc := t.Subtle.Render(r.Description) | |
| 152 | + | ||
| 153 | + | row := lipgloss.JoinHorizontal( | |
| 154 | + | lipgloss.Left, | |
| 155 | + | t.Highlight.Render(cursor), | |
| 156 | + | lipgloss.JoinVertical(lipgloss.Left, label, desc), | |
| 157 | + | ) | |
| 158 | + | rows = append(rows, row) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | list := lipgloss.JoinVertical(lipgloss.Left, rows...) | |
| 162 | + | ||
| 163 | + | // Warning for "All time" | |
| 164 | + | var warning string | |
| 165 | + | if m.Ranges[m.Selected].Range == CleanupRangeAll { | |
| 166 | + | warning = t.ErrorStyle.Render("⚠ This will delete ALL history entries!") | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // Help text | |
| 170 | + | help := t.Subtle.Render("↑/↓ to select • enter to confirm • esc to cancel") | |
| 171 | + | ||
| 172 | + | // Combine | |
| 173 | + | var content string | |
| 174 | + | if warning != "" { | |
| 175 | + | content = lipgloss.JoinVertical( | |
| 176 | + | lipgloss.Left, | |
| 177 | + | header, | |
| 178 | + | "", | |
| 179 | + | list, | |
| 180 | + | "", | |
| 181 | + | warning, | |
| 182 | + | "", | |
| 183 | + | help, | |
| 184 | + | ) | |
| 185 | + | } else { | |
| 186 | + | content = lipgloss.JoinVertical( | |
| 187 | + | lipgloss.Left, | |
| 188 | + | header, | |
| 189 | + | "", | |
| 190 | + | list, | |
| 191 | + | "", | |
| 192 | + | help, | |
| 193 | + | ) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | return t.Box.Render(content) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // Done returns true if the modal is complete. | |
| 200 | + | func (m CleanupModel) Done() bool { | |
| 201 | + | return m.Confirmed || m.Cancelled | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // SelectedRange returns the selected cleanup range. | |
| 205 | + | func (m CleanupModel) SelectedRange() CleanupRange { | |
| 206 | + | if m.Selected >= 0 && m.Selected < len(m.Ranges) { | |
| 207 | + | return m.Ranges[m.Selected].Range | |
| 208 | + | } | |
| 209 | + | return CleanupRangeAll | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // CleanupResultMsg is sent when cleanup modal completes. | |
| 213 | + | type CleanupResultMsg struct { | |
| 214 | + | Confirmed bool | |
| 215 | + | Range CleanupRange | |
| 216 | + | } | |
| @@ -0,0 +1,216 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/charmbracelet/bubbles/key" | |||
| 7 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 8 | + | "github.com/charmbracelet/lipgloss" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // CleanupRange represents a time range for history cleanup. | |||
| 12 | + | type CleanupRange int | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | CleanupRangeLastHour CleanupRange = iota | |||
| 16 | + | CleanupRangeLast24Hours | |||
| 17 | + | CleanupRangeLast7Days | |||
| 18 | + | CleanupRangeLast30Days | |||
| 19 | + | CleanupRangeAll | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // Cursor symbols for list items (using Unicode). | |||
| 23 | + | const ( | |||
| 24 | + | cursorEmpty = " " | |||
| 25 | + | cursorSelected = "\u25b8 " // ▸ Black right-pointing small triangle | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // CleanupRangeInfo holds display info for a cleanup range. | |||
| 29 | + | type CleanupRangeInfo struct { | |||
| 30 | + | Range CleanupRange | |||
| 31 | + | Label string | |||
| 32 | + | Description string | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // AllCleanupRanges returns all available cleanup ranges. | |||
| 36 | + | func AllCleanupRanges() []CleanupRangeInfo { | |||
| 37 | + | return []CleanupRangeInfo{ | |||
| 38 | + | {CleanupRangeLastHour, "Last hour", "Delete entries from the last hour"}, | |||
| 39 | + | {CleanupRangeLast24Hours, "Last 24 hours", "Delete entries from the last day"}, | |||
| 40 | + | {CleanupRangeLast7Days, "Last 7 days", "Delete entries from the last week"}, | |||
| 41 | + | {CleanupRangeLast30Days, "Last 30 days", "Delete entries from the last month"}, | |||
| 42 | + | {CleanupRangeAll, "All time", "Delete all history entries"}, | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // CutoffTime returns the cutoff time for this range. | |||
| 47 | + | func (r CleanupRange) CutoffTime() time.Time { | |||
| 48 | + | now := time.Now() | |||
| 49 | + | switch r { | |||
| 50 | + | case CleanupRangeLastHour: | |||
| 51 | + | return now.Add(-1 * time.Hour) | |||
| 52 | + | case CleanupRangeLast24Hours: | |||
| 53 | + | return now.Add(-24 * time.Hour) | |||
| 54 | + | case CleanupRangeLast7Days: | |||
| 55 | + | return now.Add(-7 * 24 * time.Hour) | |||
| 56 | + | case CleanupRangeLast30Days: | |||
| 57 | + | return now.Add(-30 * 24 * time.Hour) | |||
| 58 | + | case CleanupRangeAll: | |||
| 59 | + | return time.Time{} // Zero time = delete all | |||
| 60 | + | default: | |||
| 61 | + | return time.Time{} | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // CleanupModel is a cleanup range selection modal. | |||
| 66 | + | type CleanupModel struct { | |||
| 67 | + | Ranges []CleanupRangeInfo | |||
| 68 | + | Selected int | |||
| 69 | + | Confirmed bool | |||
| 70 | + | Cancelled bool | |||
| 71 | + | theme *Theme | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // CleanupKeyMap defines keybindings for cleanup modal. | |||
| 75 | + | type CleanupKeyMap struct { | |||
| 76 | + | Up key.Binding | |||
| 77 | + | Down key.Binding | |||
| 78 | + | Confirm key.Binding | |||
| 79 | + | Cancel key.Binding | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // DefaultCleanupKeyMap returns default keybindings. | |||
| 83 | + | func DefaultCleanupKeyMap() CleanupKeyMap { | |||
| 84 | + | return CleanupKeyMap{ | |||
| 85 | + | Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), | |||
| 86 | + | Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), | |||
| 87 | + | Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), | |||
| 88 | + | Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")), | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // NewCleanup creates a new cleanup modal. | |||
| 93 | + | func NewCleanup(theme *Theme) CleanupModel { | |||
| 94 | + | return CleanupModel{ | |||
| 95 | + | Ranges: AllCleanupRanges(), | |||
| 96 | + | Selected: 0, | |||
| 97 | + | theme: theme, | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // Init implements tea.Model. | |||
| 102 | + | func (m CleanupModel) Init() tea.Cmd { | |||
| 103 | + | return nil | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // Update implements tea.Model. | |||
| 107 | + | func (m CleanupModel) Update(msg tea.Msg) (CleanupModel, tea.Cmd) { | |||
| 108 | + | keys := DefaultCleanupKeyMap() | |||
| 109 | + | ||||
| 110 | + | switch msg := msg.(type) { | |||
| 111 | + | case tea.KeyMsg: | |||
| 112 | + | switch { | |||
| 113 | + | case key.Matches(msg, keys.Up): | |||
| 114 | + | if m.Selected > 0 { | |||
| 115 | + | m.Selected-- | |||
| 116 | + | } | |||
| 117 | + | case key.Matches(msg, keys.Down): | |||
| 118 | + | if m.Selected < len(m.Ranges)-1 { | |||
| 119 | + | m.Selected++ | |||
| 120 | + | } | |||
| 121 | + | case key.Matches(msg, keys.Confirm): | |||
| 122 | + | m.Confirmed = true | |||
| 123 | + | case key.Matches(msg, keys.Cancel): | |||
| 124 | + | m.Cancelled = true | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | return m, nil | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // View implements tea.Model. | |||
| 132 | + | func (m CleanupModel) View() string { | |||
| 133 | + | t := m.theme | |||
| 134 | + | ||||
| 135 | + | // Build header | |||
| 136 | + | header := t.Title.Copy(). | |||
| 137 | + | Foreground(t.Error). | |||
| 138 | + | Render("Clear History") | |||
| 139 | + | ||||
| 140 | + | // Build range list | |||
| 141 | + | var rows []string | |||
| 142 | + | for i, r := range m.Ranges { | |||
| 143 | + | cursor := cursorEmpty | |||
| 144 | + | style := t.ListItem | |||
| 145 | + | if i == m.Selected { | |||
| 146 | + | cursor = cursorSelected | |||
| 147 | + | style = t.ListItemSelected | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | label := style.Render(r.Label) | |||
| 151 | + | desc := t.Subtle.Render(r.Description) | |||
| 152 | + | ||||
| 153 | + | row := lipgloss.JoinHorizontal( | |||
| 154 | + | lipgloss.Left, | |||
| 155 | + | t.Highlight.Render(cursor), | |||
| 156 | + | lipgloss.JoinVertical(lipgloss.Left, label, desc), | |||
| 157 | + | ) | |||
| 158 | + | rows = append(rows, row) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | list := lipgloss.JoinVertical(lipgloss.Left, rows...) | |||
| 162 | + | ||||
| 163 | + | // Warning for "All time" | |||
| 164 | + | var warning string | |||
| 165 | + | if m.Ranges[m.Selected].Range == CleanupRangeAll { | |||
| 166 | + | warning = t.ErrorStyle.Render("⚠ This will delete ALL history entries!") | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // Help text | |||
| 170 | + | help := t.Subtle.Render("↑/↓ to select • enter to confirm • esc to cancel") | |||
| 171 | + | ||||
| 172 | + | // Combine | |||
| 173 | + | var content string | |||
| 174 | + | if warning != "" { | |||
| 175 | + | content = lipgloss.JoinVertical( | |||
| 176 | + | lipgloss.Left, | |||
| 177 | + | header, | |||
| 178 | + | "", | |||
| 179 | + | list, | |||
| 180 | + | "", | |||
| 181 | + | warning, | |||
| 182 | + | "", | |||
| 183 | + | help, | |||
| 184 | + | ) | |||
| 185 | + | } else { | |||
| 186 | + | content = lipgloss.JoinVertical( | |||
| 187 | + | lipgloss.Left, | |||
| 188 | + | header, | |||
| 189 | + | "", | |||
| 190 | + | list, | |||
| 191 | + | "", | |||
| 192 | + | help, | |||
| 193 | + | ) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | return t.Box.Render(content) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // Done returns true if the modal is complete. | |||
| 200 | + | func (m CleanupModel) Done() bool { | |||
| 201 | + | return m.Confirmed || m.Cancelled | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // SelectedRange returns the selected cleanup range. | |||
| 205 | + | func (m CleanupModel) SelectedRange() CleanupRange { | |||
| 206 | + | if m.Selected >= 0 && m.Selected < len(m.Ranges) { | |||
| 207 | + | return m.Ranges[m.Selected].Range | |||
| 208 | + | } | |||
| 209 | + | return CleanupRangeAll | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // CleanupResultMsg is sent when cleanup modal completes. | |||
| 213 | + | type CleanupResultMsg struct { | |||
| 214 | + | Confirmed bool | |||
| 215 | + | Range CleanupRange | |||
| 216 | + | } | |||
A internal/cli/styles/confirm.go +128 -0
| @@ -0,0 +1,128 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/charmbracelet/bubbles/key" | |
| 5 | + | tea "github.com/charmbracelet/bubbletea" | |
| 6 | + | "github.com/charmbracelet/lipgloss" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ConfirmModel is a yes/no confirmation dialog. | |
| 10 | + | type ConfirmModel struct { | |
| 11 | + | Message string | |
| 12 | + | Yes bool // Current selection | |
| 13 | + | Confirmed bool // User pressed enter | |
| 14 | + | Cancelled bool // User pressed escape | |
| 15 | + | theme *Theme | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // ConfirmKeyMap defines keybindings for the confirm dialog. | |
| 19 | + | type ConfirmKeyMap struct { | |
| 20 | + | Yes key.Binding | |
| 21 | + | No key.Binding | |
| 22 | + | Left key.Binding | |
| 23 | + | Right key.Binding | |
| 24 | + | Confirm key.Binding | |
| 25 | + | Cancel key.Binding | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // DefaultConfirmKeyMap returns the default keybindings. | |
| 29 | + | func DefaultConfirmKeyMap() ConfirmKeyMap { | |
| 30 | + | return ConfirmKeyMap{ | |
| 31 | + | Yes: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "yes")), | |
| 32 | + | No: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "no")), | |
| 33 | + | Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "no")), | |
| 34 | + | Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "yes")), | |
| 35 | + | Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), | |
| 36 | + | Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // NewConfirm creates a new confirmation dialog. | |
| 41 | + | func NewConfirm(theme *Theme, message string) ConfirmModel { | |
| 42 | + | return ConfirmModel{ | |
| 43 | + | Message: message, | |
| 44 | + | Yes: false, // Default to "No" | |
| 45 | + | theme: theme, | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Init implements tea.Model. | |
| 50 | + | func (m ConfirmModel) Init() tea.Cmd { | |
| 51 | + | return nil | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Update implements tea.Model. | |
| 55 | + | func (m ConfirmModel) Update(msg tea.Msg) (ConfirmModel, tea.Cmd) { | |
| 56 | + | keys := DefaultConfirmKeyMap() | |
| 57 | + | ||
| 58 | + | switch msg := msg.(type) { | |
| 59 | + | case tea.KeyMsg: | |
| 60 | + | switch { | |
| 61 | + | case key.Matches(msg, keys.Yes): | |
| 62 | + | m.Yes = true | |
| 63 | + | case key.Matches(msg, keys.No): | |
| 64 | + | m.Yes = false | |
| 65 | + | case key.Matches(msg, keys.Left): | |
| 66 | + | m.Yes = false | |
| 67 | + | case key.Matches(msg, keys.Right): | |
| 68 | + | m.Yes = true | |
| 69 | + | case key.Matches(msg, keys.Confirm): | |
| 70 | + | m.Confirmed = true | |
| 71 | + | case key.Matches(msg, keys.Cancel): | |
| 72 | + | m.Cancelled = true | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | return m, nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // View implements tea.Model. | |
| 80 | + | func (m ConfirmModel) View() string { | |
| 81 | + | t := m.theme | |
| 82 | + | ||
| 83 | + | // Build buttons | |
| 84 | + | yesStyle := t.InactiveTab | |
| 85 | + | noStyle := t.InactiveTab | |
| 86 | + | ||
| 87 | + | if m.Yes { | |
| 88 | + | yesStyle = t.ActiveTab | |
| 89 | + | } else { | |
| 90 | + | noStyle = t.ActiveTab | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | yes := yesStyle.Render(" Yes ") | |
| 94 | + | no := noStyle.Render(" No ") | |
| 95 | + | ||
| 96 | + | buttons := lipgloss.JoinHorizontal(lipgloss.Center, no, " ", yes) | |
| 97 | + | ||
| 98 | + | // Build dialog box | |
| 99 | + | content := lipgloss.JoinVertical( | |
| 100 | + | lipgloss.Center, | |
| 101 | + | t.Title.Render(m.Message), | |
| 102 | + | "", | |
| 103 | + | buttons, | |
| 104 | + | "", | |
| 105 | + | t.Subtle.Render("y/n or ←/→ to select • enter to confirm • esc to cancel"), | |
| 106 | + | ) | |
| 107 | + | ||
| 108 | + | return t.Box.Render(content) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // Done returns true if the dialog is complete. | |
| 112 | + | func (m ConfirmModel) Done() bool { | |
| 113 | + | return m.Confirmed || m.Cancelled | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Result returns true if user confirmed "Yes". | |
| 117 | + | func (m ConfirmModel) Result() bool { | |
| 118 | + | return m.Confirmed && m.Yes | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // ConfirmResult wraps the confirm dialog result for parent models. | |
| 122 | + | type ConfirmResult struct { | |
| 123 | + | Confirmed bool | |
| 124 | + | Yes bool | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // ConfirmResultMsg is sent when the confirm dialog completes. | |
| 128 | + | type ConfirmResultMsg ConfirmResult | |
| @@ -0,0 +1,128 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/charmbracelet/bubbles/key" | |||
| 5 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 6 | + | "github.com/charmbracelet/lipgloss" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ConfirmModel is a yes/no confirmation dialog. | |||
| 10 | + | type ConfirmModel struct { | |||
| 11 | + | Message string | |||
| 12 | + | Yes bool // Current selection | |||
| 13 | + | Confirmed bool // User pressed enter | |||
| 14 | + | Cancelled bool // User pressed escape | |||
| 15 | + | theme *Theme | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // ConfirmKeyMap defines keybindings for the confirm dialog. | |||
| 19 | + | type ConfirmKeyMap struct { | |||
| 20 | + | Yes key.Binding | |||
| 21 | + | No key.Binding | |||
| 22 | + | Left key.Binding | |||
| 23 | + | Right key.Binding | |||
| 24 | + | Confirm key.Binding | |||
| 25 | + | Cancel key.Binding | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // DefaultConfirmKeyMap returns the default keybindings. | |||
| 29 | + | func DefaultConfirmKeyMap() ConfirmKeyMap { | |||
| 30 | + | return ConfirmKeyMap{ | |||
| 31 | + | Yes: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "yes")), | |||
| 32 | + | No: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "no")), | |||
| 33 | + | Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "no")), | |||
| 34 | + | Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "yes")), | |||
| 35 | + | Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), | |||
| 36 | + | Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // NewConfirm creates a new confirmation dialog. | |||
| 41 | + | func NewConfirm(theme *Theme, message string) ConfirmModel { | |||
| 42 | + | return ConfirmModel{ | |||
| 43 | + | Message: message, | |||
| 44 | + | Yes: false, // Default to "No" | |||
| 45 | + | theme: theme, | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Init implements tea.Model. | |||
| 50 | + | func (m ConfirmModel) Init() tea.Cmd { | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // Update implements tea.Model. | |||
| 55 | + | func (m ConfirmModel) Update(msg tea.Msg) (ConfirmModel, tea.Cmd) { | |||
| 56 | + | keys := DefaultConfirmKeyMap() | |||
| 57 | + | ||||
| 58 | + | switch msg := msg.(type) { | |||
| 59 | + | case tea.KeyMsg: | |||
| 60 | + | switch { | |||
| 61 | + | case key.Matches(msg, keys.Yes): | |||
| 62 | + | m.Yes = true | |||
| 63 | + | case key.Matches(msg, keys.No): | |||
| 64 | + | m.Yes = false | |||
| 65 | + | case key.Matches(msg, keys.Left): | |||
| 66 | + | m.Yes = false | |||
| 67 | + | case key.Matches(msg, keys.Right): | |||
| 68 | + | m.Yes = true | |||
| 69 | + | case key.Matches(msg, keys.Confirm): | |||
| 70 | + | m.Confirmed = true | |||
| 71 | + | case key.Matches(msg, keys.Cancel): | |||
| 72 | + | m.Cancelled = true | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | return m, nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // View implements tea.Model. | |||
| 80 | + | func (m ConfirmModel) View() string { | |||
| 81 | + | t := m.theme | |||
| 82 | + | ||||
| 83 | + | // Build buttons | |||
| 84 | + | yesStyle := t.InactiveTab | |||
| 85 | + | noStyle := t.InactiveTab | |||
| 86 | + | ||||
| 87 | + | if m.Yes { | |||
| 88 | + | yesStyle = t.ActiveTab | |||
| 89 | + | } else { | |||
| 90 | + | noStyle = t.ActiveTab | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | yes := yesStyle.Render(" Yes ") | |||
| 94 | + | no := noStyle.Render(" No ") | |||
| 95 | + | ||||
| 96 | + | buttons := lipgloss.JoinHorizontal(lipgloss.Center, no, " ", yes) | |||
| 97 | + | ||||
| 98 | + | // Build dialog box | |||
| 99 | + | content := lipgloss.JoinVertical( | |||
| 100 | + | lipgloss.Center, | |||
| 101 | + | t.Title.Render(m.Message), | |||
| 102 | + | "", | |||
| 103 | + | buttons, | |||
| 104 | + | "", | |||
| 105 | + | t.Subtle.Render("y/n or ←/→ to select • enter to confirm • esc to cancel"), | |||
| 106 | + | ) | |||
| 107 | + | ||||
| 108 | + | return t.Box.Render(content) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // Done returns true if the dialog is complete. | |||
| 112 | + | func (m ConfirmModel) Done() bool { | |||
| 113 | + | return m.Confirmed || m.Cancelled | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Result returns true if user confirmed "Yes". | |||
| 117 | + | func (m ConfirmModel) Result() bool { | |||
| 118 | + | return m.Confirmed && m.Yes | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // ConfirmResult wraps the confirm dialog result for parent models. | |||
| 122 | + | type ConfirmResult struct { | |||
| 123 | + | Confirmed bool | |||
| 124 | + | Yes bool | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // ConfirmResultMsg is sent when the confirm dialog completes. | |||
| 128 | + | type ConfirmResultMsg ConfirmResult | |||
A internal/cli/styles/help.go +174 -0
| @@ -0,0 +1,174 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/charmbracelet/bubbles/help" | |
| 5 | + | "github.com/charmbracelet/bubbles/key" | |
| 6 | + | "github.com/charmbracelet/lipgloss" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // KeyMap defines keybindings that can be rendered as help. | |
| 10 | + | type KeyMap interface { | |
| 11 | + | ShortHelp() []key.Binding | |
| 12 | + | FullHelp() [][]key.Binding | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // HistoryKeyMap defines keybindings for history browser. | |
| 16 | + | type HistoryKeyMap struct { | |
| 17 | + | Up key.Binding | |
| 18 | + | Down key.Binding | |
| 19 | + | Tab1 key.Binding | |
| 20 | + | Tab2 key.Binding | |
| 21 | + | Tab3 key.Binding | |
| 22 | + | Tab4 key.Binding | |
| 23 | + | NextTab key.Binding | |
| 24 | + | PrevTab key.Binding | |
| 25 | + | Search key.Binding | |
| 26 | + | Open key.Binding | |
| 27 | + | Delete key.Binding | |
| 28 | + | DeleteDomain key.Binding | |
| 29 | + | Cleanup key.Binding | |
| 30 | + | Filter key.Binding | |
| 31 | + | Help key.Binding | |
| 32 | + | Quit key.Binding | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // ShortHelp returns keybindings to show in compact help. | |
| 36 | + | func (k HistoryKeyMap) ShortHelp() []key.Binding { | |
| 37 | + | return []key.Binding{k.Up, k.Down, k.Open, k.Search, k.Help, k.Quit} | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // FullHelp returns keybindings for expanded help. | |
| 41 | + | func (k HistoryKeyMap) FullHelp() [][]key.Binding { | |
| 42 | + | return [][]key.Binding{ | |
| 43 | + | {k.Up, k.Down, k.Open}, | |
| 44 | + | {k.NextTab, k.PrevTab, k.Tab1, k.Tab2, k.Tab3, k.Tab4}, | |
| 45 | + | {k.Search, k.Filter}, | |
| 46 | + | {k.Delete, k.DeleteDomain, k.Cleanup}, | |
| 47 | + | {k.Help, k.Quit}, | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // DefaultHistoryKeyMap returns the default history keybindings. | |
| 52 | + | func DefaultHistoryKeyMap() HistoryKeyMap { | |
| 53 | + | return HistoryKeyMap{ | |
| 54 | + | Up: key.NewBinding( | |
| 55 | + | key.WithKeys("up", "k"), | |
| 56 | + | key.WithHelp("↑/k", "up"), | |
| 57 | + | ), | |
| 58 | + | Down: key.NewBinding( | |
| 59 | + | key.WithKeys("down", "j"), | |
| 60 | + | key.WithHelp("↓/j", "down"), | |
| 61 | + | ), | |
| 62 | + | Tab1: key.NewBinding( | |
| 63 | + | key.WithKeys("1"), | |
| 64 | + | key.WithHelp("1", "today"), | |
| 65 | + | ), | |
| 66 | + | Tab2: key.NewBinding( | |
| 67 | + | key.WithKeys("2"), | |
| 68 | + | key.WithHelp("2", "yesterday"), | |
| 69 | + | ), | |
| 70 | + | Tab3: key.NewBinding( | |
| 71 | + | key.WithKeys("3"), | |
| 72 | + | key.WithHelp("3", "this week"), | |
| 73 | + | ), | |
| 74 | + | Tab4: key.NewBinding( | |
| 75 | + | key.WithKeys("4"), | |
| 76 | + | key.WithHelp("4", "older"), | |
| 77 | + | ), | |
| 78 | + | NextTab: key.NewBinding( | |
| 79 | + | key.WithKeys("tab"), | |
| 80 | + | key.WithHelp("tab", "next tab"), | |
| 81 | + | ), | |
| 82 | + | PrevTab: key.NewBinding( | |
| 83 | + | key.WithKeys("shift+tab"), | |
| 84 | + | key.WithHelp("S-tab", "prev tab"), | |
| 85 | + | ), | |
| 86 | + | Search: key.NewBinding( | |
| 87 | + | key.WithKeys("/"), | |
| 88 | + | key.WithHelp("/", "search"), | |
| 89 | + | ), | |
| 90 | + | Open: key.NewBinding( | |
| 91 | + | key.WithKeys("enter"), | |
| 92 | + | key.WithHelp("enter", "open"), | |
| 93 | + | ), | |
| 94 | + | Delete: key.NewBinding( | |
| 95 | + | key.WithKeys("d"), | |
| 96 | + | key.WithHelp("d", "delete"), | |
| 97 | + | ), | |
| 98 | + | DeleteDomain: key.NewBinding( | |
| 99 | + | key.WithKeys("D"), | |
| 100 | + | key.WithHelp("D", "delete domain"), | |
| 101 | + | ), | |
| 102 | + | Cleanup: key.NewBinding( | |
| 103 | + | key.WithKeys("c"), | |
| 104 | + | key.WithHelp("c", "cleanup"), | |
| 105 | + | ), | |
| 106 | + | Filter: key.NewBinding( | |
| 107 | + | key.WithKeys("f"), | |
| 108 | + | key.WithHelp("f", "filter domain"), | |
| 109 | + | ), | |
| 110 | + | Help: key.NewBinding( | |
| 111 | + | key.WithKeys("?"), | |
| 112 | + | key.WithHelp("?", "help"), | |
| 113 | + | ), | |
| 114 | + | Quit: key.NewBinding( | |
| 115 | + | key.WithKeys("q", "ctrl+c"), | |
| 116 | + | key.WithHelp("q", "quit"), | |
| 117 | + | ), | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // NewStyledHelp creates a themed help model. | |
| 122 | + | func NewStyledHelp(theme *Theme) help.Model { | |
| 123 | + | h := help.New() | |
| 124 | + | h.Styles.ShortKey = lipgloss.NewStyle().Foreground(theme.Accent) | |
| 125 | + | h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(theme.Muted) | |
| 126 | + | h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(theme.Border) | |
| 127 | + | h.Styles.FullKey = lipgloss.NewStyle().Foreground(theme.Accent) | |
| 128 | + | h.Styles.FullDesc = lipgloss.NewStyle().Foreground(theme.Text) | |
| 129 | + | h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(theme.Border) | |
| 130 | + | return h | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // DmenuKeyMap defines keybindings for dmenu browser. | |
| 134 | + | type DmenuKeyMap struct { | |
| 135 | + | Up key.Binding | |
| 136 | + | Down key.Binding | |
| 137 | + | Open key.Binding | |
| 138 | + | Cancel key.Binding | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // ShortHelp returns keybindings to show in compact help. | |
| 142 | + | func (k DmenuKeyMap) ShortHelp() []key.Binding { | |
| 143 | + | return []key.Binding{k.Up, k.Down, k.Open, k.Cancel} | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // FullHelp returns keybindings for expanded help. | |
| 147 | + | func (k DmenuKeyMap) FullHelp() [][]key.Binding { | |
| 148 | + | return [][]key.Binding{ | |
| 149 | + | {k.Up, k.Down}, | |
| 150 | + | {k.Open, k.Cancel}, | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // DefaultDmenuKeyMap returns the default dmenu keybindings. | |
| 155 | + | func DefaultDmenuKeyMap() DmenuKeyMap { | |
| 156 | + | return DmenuKeyMap{ | |
| 157 | + | Up: key.NewBinding( | |
| 158 | + | key.WithKeys("up", "k"), | |
| 159 | + | key.WithHelp("↑/k", "up"), | |
| 160 | + | ), | |
| 161 | + | Down: key.NewBinding( | |
| 162 | + | key.WithKeys("down", "j"), | |
| 163 | + | key.WithHelp("↓/j", "down"), | |
| 164 | + | ), | |
| 165 | + | Open: key.NewBinding( | |
| 166 | + | key.WithKeys("enter"), | |
| 167 | + | key.WithHelp("enter", "open"), | |
| 168 | + | ), | |
| 169 | + | Cancel: key.NewBinding( | |
| 170 | + | key.WithKeys("esc", "ctrl+c"), | |
| 171 | + | key.WithHelp("esc", "cancel"), | |
| 172 | + | ), | |
| 173 | + | } | |
| 174 | + | } | |
| @@ -0,0 +1,174 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/charmbracelet/bubbles/help" | |||
| 5 | + | "github.com/charmbracelet/bubbles/key" | |||
| 6 | + | "github.com/charmbracelet/lipgloss" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // KeyMap defines keybindings that can be rendered as help. | |||
| 10 | + | type KeyMap interface { | |||
| 11 | + | ShortHelp() []key.Binding | |||
| 12 | + | FullHelp() [][]key.Binding | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // HistoryKeyMap defines keybindings for history browser. | |||
| 16 | + | type HistoryKeyMap struct { | |||
| 17 | + | Up key.Binding | |||
| 18 | + | Down key.Binding | |||
| 19 | + | Tab1 key.Binding | |||
| 20 | + | Tab2 key.Binding | |||
| 21 | + | Tab3 key.Binding | |||
| 22 | + | Tab4 key.Binding | |||
| 23 | + | NextTab key.Binding | |||
| 24 | + | PrevTab key.Binding | |||
| 25 | + | Search key.Binding | |||
| 26 | + | Open key.Binding | |||
| 27 | + | Delete key.Binding | |||
| 28 | + | DeleteDomain key.Binding | |||
| 29 | + | Cleanup key.Binding | |||
| 30 | + | Filter key.Binding | |||
| 31 | + | Help key.Binding | |||
| 32 | + | Quit key.Binding | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // ShortHelp returns keybindings to show in compact help. | |||
| 36 | + | func (k HistoryKeyMap) ShortHelp() []key.Binding { | |||
| 37 | + | return []key.Binding{k.Up, k.Down, k.Open, k.Search, k.Help, k.Quit} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // FullHelp returns keybindings for expanded help. | |||
| 41 | + | func (k HistoryKeyMap) FullHelp() [][]key.Binding { | |||
| 42 | + | return [][]key.Binding{ | |||
| 43 | + | {k.Up, k.Down, k.Open}, | |||
| 44 | + | {k.NextTab, k.PrevTab, k.Tab1, k.Tab2, k.Tab3, k.Tab4}, | |||
| 45 | + | {k.Search, k.Filter}, | |||
| 46 | + | {k.Delete, k.DeleteDomain, k.Cleanup}, | |||
| 47 | + | {k.Help, k.Quit}, | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // DefaultHistoryKeyMap returns the default history keybindings. | |||
| 52 | + | func DefaultHistoryKeyMap() HistoryKeyMap { | |||
| 53 | + | return HistoryKeyMap{ | |||
| 54 | + | Up: key.NewBinding( | |||
| 55 | + | key.WithKeys("up", "k"), | |||
| 56 | + | key.WithHelp("↑/k", "up"), | |||
| 57 | + | ), | |||
| 58 | + | Down: key.NewBinding( | |||
| 59 | + | key.WithKeys("down", "j"), | |||
| 60 | + | key.WithHelp("↓/j", "down"), | |||
| 61 | + | ), | |||
| 62 | + | Tab1: key.NewBinding( | |||
| 63 | + | key.WithKeys("1"), | |||
| 64 | + | key.WithHelp("1", "today"), | |||
| 65 | + | ), | |||
| 66 | + | Tab2: key.NewBinding( | |||
| 67 | + | key.WithKeys("2"), | |||
| 68 | + | key.WithHelp("2", "yesterday"), | |||
| 69 | + | ), | |||
| 70 | + | Tab3: key.NewBinding( | |||
| 71 | + | key.WithKeys("3"), | |||
| 72 | + | key.WithHelp("3", "this week"), | |||
| 73 | + | ), | |||
| 74 | + | Tab4: key.NewBinding( | |||
| 75 | + | key.WithKeys("4"), | |||
| 76 | + | key.WithHelp("4", "older"), | |||
| 77 | + | ), | |||
| 78 | + | NextTab: key.NewBinding( | |||
| 79 | + | key.WithKeys("tab"), | |||
| 80 | + | key.WithHelp("tab", "next tab"), | |||
| 81 | + | ), | |||
| 82 | + | PrevTab: key.NewBinding( | |||
| 83 | + | key.WithKeys("shift+tab"), | |||
| 84 | + | key.WithHelp("S-tab", "prev tab"), | |||
| 85 | + | ), | |||
| 86 | + | Search: key.NewBinding( | |||
| 87 | + | key.WithKeys("/"), | |||
| 88 | + | key.WithHelp("/", "search"), | |||
| 89 | + | ), | |||
| 90 | + | Open: key.NewBinding( | |||
| 91 | + | key.WithKeys("enter"), | |||
| 92 | + | key.WithHelp("enter", "open"), | |||
| 93 | + | ), | |||
| 94 | + | Delete: key.NewBinding( | |||
| 95 | + | key.WithKeys("d"), | |||
| 96 | + | key.WithHelp("d", "delete"), | |||
| 97 | + | ), | |||
| 98 | + | DeleteDomain: key.NewBinding( | |||
| 99 | + | key.WithKeys("D"), | |||
| 100 | + | key.WithHelp("D", "delete domain"), | |||
| 101 | + | ), | |||
| 102 | + | Cleanup: key.NewBinding( | |||
| 103 | + | key.WithKeys("c"), | |||
| 104 | + | key.WithHelp("c", "cleanup"), | |||
| 105 | + | ), | |||
| 106 | + | Filter: key.NewBinding( | |||
| 107 | + | key.WithKeys("f"), | |||
| 108 | + | key.WithHelp("f", "filter domain"), | |||
| 109 | + | ), | |||
| 110 | + | Help: key.NewBinding( | |||
| 111 | + | key.WithKeys("?"), | |||
| 112 | + | key.WithHelp("?", "help"), | |||
| 113 | + | ), | |||
| 114 | + | Quit: key.NewBinding( | |||
| 115 | + | key.WithKeys("q", "ctrl+c"), | |||
| 116 | + | key.WithHelp("q", "quit"), | |||
| 117 | + | ), | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // NewStyledHelp creates a themed help model. | |||
| 122 | + | func NewStyledHelp(theme *Theme) help.Model { | |||
| 123 | + | h := help.New() | |||
| 124 | + | h.Styles.ShortKey = lipgloss.NewStyle().Foreground(theme.Accent) | |||
| 125 | + | h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(theme.Muted) | |||
| 126 | + | h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(theme.Border) | |||
| 127 | + | h.Styles.FullKey = lipgloss.NewStyle().Foreground(theme.Accent) | |||
| 128 | + | h.Styles.FullDesc = lipgloss.NewStyle().Foreground(theme.Text) | |||
| 129 | + | h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(theme.Border) | |||
| 130 | + | return h | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // DmenuKeyMap defines keybindings for dmenu browser. | |||
| 134 | + | type DmenuKeyMap struct { | |||
| 135 | + | Up key.Binding | |||
| 136 | + | Down key.Binding | |||
| 137 | + | Open key.Binding | |||
| 138 | + | Cancel key.Binding | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // ShortHelp returns keybindings to show in compact help. | |||
| 142 | + | func (k DmenuKeyMap) ShortHelp() []key.Binding { | |||
| 143 | + | return []key.Binding{k.Up, k.Down, k.Open, k.Cancel} | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // FullHelp returns keybindings for expanded help. | |||
| 147 | + | func (k DmenuKeyMap) FullHelp() [][]key.Binding { | |||
| 148 | + | return [][]key.Binding{ | |||
| 149 | + | {k.Up, k.Down}, | |||
| 150 | + | {k.Open, k.Cancel}, | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // DefaultDmenuKeyMap returns the default dmenu keybindings. | |||
| 155 | + | func DefaultDmenuKeyMap() DmenuKeyMap { | |||
| 156 | + | return DmenuKeyMap{ | |||
| 157 | + | Up: key.NewBinding( | |||
| 158 | + | key.WithKeys("up", "k"), | |||
| 159 | + | key.WithHelp("↑/k", "up"), | |||
| 160 | + | ), | |||
| 161 | + | Down: key.NewBinding( | |||
| 162 | + | key.WithKeys("down", "j"), | |||
| 163 | + | key.WithHelp("↓/j", "down"), | |||
| 164 | + | ), | |||
| 165 | + | Open: key.NewBinding( | |||
| 166 | + | key.WithKeys("enter"), | |||
| 167 | + | key.WithHelp("enter", "open"), | |||
| 168 | + | ), | |||
| 169 | + | Cancel: key.NewBinding( | |||
| 170 | + | key.WithKeys("esc", "ctrl+c"), | |||
| 171 | + | key.WithHelp("esc", "cancel"), | |||
| 172 | + | ), | |||
| 173 | + | } | |||
| 174 | + | } | |||
A internal/cli/styles/input.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/charmbracelet/bubbles/textinput" | |
| 5 | + | "github.com/charmbracelet/lipgloss" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // NewStyledInput creates a themed text input. | |
| 9 | + | func NewStyledInput(theme *Theme, placeholder string) textinput.Model { | |
| 10 | + | ti := textinput.New() | |
| 11 | + | ti.Placeholder = placeholder | |
| 12 | + | ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(theme.Muted) | |
| 13 | + | ti.TextStyle = lipgloss.NewStyle().Foreground(theme.Text) | |
| 14 | + | ti.Cursor.Style = lipgloss.NewStyle().Foreground(theme.Accent) | |
| 15 | + | ti.PromptStyle = lipgloss.NewStyle().Foreground(theme.Accent) | |
| 16 | + | ti.Prompt = "/ " | |
| 17 | + | return ti | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // NewSearchInput creates a search-specific input. | |
| 21 | + | func NewSearchInput(theme *Theme) textinput.Model { | |
| 22 | + | ti := NewStyledInput(theme, "Search...") | |
| 23 | + | ti.Prompt = "🔍 " | |
| 24 | + | ti.CharLimit = 256 | |
| 25 | + | return ti | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // NewURLInput creates a URL input field. | |
| 29 | + | func NewURLInput(theme *Theme) textinput.Model { | |
| 30 | + | ti := NewStyledInput(theme, "Enter URL or search query...") | |
| 31 | + | ti.Prompt = "→ " | |
| 32 | + | ti.CharLimit = 2048 | |
| 33 | + | return ti | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // InputBox wraps a text input in a styled box. | |
| 37 | + | func (t *Theme) InputBox(input string, focused bool) string { | |
| 38 | + | style := t.Input | |
| 39 | + | if focused { | |
| 40 | + | style = t.InputFocused | |
| 41 | + | } | |
| 42 | + | return style.Render(input) | |
| 43 | + | } | |
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/charmbracelet/bubbles/textinput" | |||
| 5 | + | "github.com/charmbracelet/lipgloss" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // NewStyledInput creates a themed text input. | |||
| 9 | + | func NewStyledInput(theme *Theme, placeholder string) textinput.Model { | |||
| 10 | + | ti := textinput.New() | |||
| 11 | + | ti.Placeholder = placeholder | |||
| 12 | + | ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(theme.Muted) | |||
| 13 | + | ti.TextStyle = lipgloss.NewStyle().Foreground(theme.Text) | |||
| 14 | + | ti.Cursor.Style = lipgloss.NewStyle().Foreground(theme.Accent) | |||
| 15 | + | ti.PromptStyle = lipgloss.NewStyle().Foreground(theme.Accent) | |||
| 16 | + | ti.Prompt = "/ " | |||
| 17 | + | return ti | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // NewSearchInput creates a search-specific input. | |||
| 21 | + | func NewSearchInput(theme *Theme) textinput.Model { | |||
| 22 | + | ti := NewStyledInput(theme, "Search...") | |||
| 23 | + | ti.Prompt = "🔍 " | |||
| 24 | + | ti.CharLimit = 256 | |||
| 25 | + | return ti | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // NewURLInput creates a URL input field. | |||
| 29 | + | func NewURLInput(theme *Theme) textinput.Model { | |||
| 30 | + | ti := NewStyledInput(theme, "Enter URL or search query...") | |||
| 31 | + | ti.Prompt = "→ " | |||
| 32 | + | ti.CharLimit = 2048 | |||
| 33 | + | return ti | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // InputBox wraps a text input in a styled box. | |||
| 37 | + | func (t *Theme) InputBox(input string, focused bool) string { | |||
| 38 | + | style := t.Input | |||
| 39 | + | if focused { | |||
| 40 | + | style = t.InputFocused | |||
| 41 | + | } | |||
| 42 | + | return style.Render(input) | |||
| 43 | + | } | |||
A internal/cli/styles/list.go +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "io" | |
| 6 | + | "strings" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/charmbracelet/bubbles/list" | |
| 10 | + | tea "github.com/charmbracelet/bubbletea" | |
| 11 | + | "github.com/charmbracelet/lipgloss" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // HistoryItem represents a history entry for the list. | |
| 15 | + | type HistoryItem struct { | |
| 16 | + | ID int64 | |
| 17 | + | URL string | |
| 18 | + | Title string | |
| 19 | + | Domain string | |
| 20 | + | VisitCount int | |
| 21 | + | LastVisited time.Time | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // FilterValue implements list.Item. | |
| 25 | + | func (i HistoryItem) FilterValue() string { | |
| 26 | + | return i.Title + " " + i.URL | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Title implements list.DefaultItem. | |
| 30 | + | func (i HistoryItem) TitleValue() string { | |
| 31 | + | if i.Title != "" { | |
| 32 | + | return i.Title | |
| 33 | + | } | |
| 34 | + | return i.URL | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Description implements list.DefaultItem. | |
| 38 | + | func (i HistoryItem) DescriptionValue() string { | |
| 39 | + | return i.URL | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // HistoryDelegate renders history items with theme styling. | |
| 43 | + | type HistoryDelegate struct { | |
| 44 | + | Theme *Theme | |
| 45 | + | ShowDomain bool | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // NewHistoryDelegate creates a themed history list delegate. | |
| 49 | + | func NewHistoryDelegate(theme *Theme) HistoryDelegate { | |
| 50 | + | return HistoryDelegate{ | |
| 51 | + | Theme: theme, | |
| 52 | + | ShowDomain: true, | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // Height returns the height of each item. | |
| 57 | + | func (d HistoryDelegate) Height() int { | |
| 58 | + | return 2 | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Spacing returns the spacing between items. | |
| 62 | + | func (d HistoryDelegate) Spacing() int { | |
| 63 | + | return 0 | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Update handles item-level events. | |
| 67 | + | func (d HistoryDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { | |
| 68 | + | return nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Render renders a single list item. | |
| 72 | + | func (d HistoryDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { | |
| 73 | + | hi, ok := item.(HistoryItem) | |
| 74 | + | if !ok { | |
| 75 | + | return | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | t := d.Theme | |
| 79 | + | isSelected := index == m.Index() | |
| 80 | + | ||
| 81 | + | // Title line | |
| 82 | + | title := hi.TitleValue() | |
| 83 | + | if len(title) > 60 { | |
| 84 | + | title = title[:57] + "..." | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // URL/description line | |
| 88 | + | url := hi.URL | |
| 89 | + | if len(url) > 50 { | |
| 90 | + | url = url[:47] + "..." | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Metadata badges | |
| 94 | + | visitBadge := t.MutedBadge(fmt.Sprintf("%d", hi.VisitCount)) | |
| 95 | + | timeBadge := t.MutedBadge(RelativeTime(hi.LastVisited)) | |
| 96 | + | ||
| 97 | + | // Build cursor | |
| 98 | + | cursor := cursorEmpty | |
| 99 | + | if isSelected { | |
| 100 | + | cursor = cursorSelected | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Apply styles | |
| 104 | + | cursorStyle := t.Highlight | |
| 105 | + | titleStyle := t.ListItemTitle | |
| 106 | + | urlStyle := t.ListItemDesc | |
| 107 | + | ||
| 108 | + | if isSelected { | |
| 109 | + | titleStyle = titleStyle.Copy().Foreground(t.Accent).Bold(true) | |
| 110 | + | urlStyle = urlStyle.Copy().Foreground(t.Text) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // First line: cursor + title | |
| 114 | + | line1 := lipgloss.JoinHorizontal( | |
| 115 | + | lipgloss.Left, | |
| 116 | + | cursorStyle.Render(cursor), | |
| 117 | + | titleStyle.Render(title), | |
| 118 | + | ) | |
| 119 | + | ||
| 120 | + | // Second line: url + badges | |
| 121 | + | meta := lipgloss.JoinHorizontal( | |
| 122 | + | lipgloss.Left, | |
| 123 | + | visitBadge, | |
| 124 | + | " ", | |
| 125 | + | timeBadge, | |
| 126 | + | ) | |
| 127 | + | ||
| 128 | + | line2 := lipgloss.JoinHorizontal( | |
| 129 | + | lipgloss.Left, | |
| 130 | + | strings.Repeat(" ", 3), // Indent under cursor | |
| 131 | + | urlStyle.Render(url), | |
| 132 | + | " ", | |
| 133 | + | meta, | |
| 134 | + | ) | |
| 135 | + | ||
| 136 | + | _, _ = fmt.Fprintf(w, "%s\n%s", line1, line2) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // NewHistoryList creates a themed list for history items. | |
| 140 | + | func NewHistoryList(theme *Theme, items []HistoryItem, width, height int) list.Model { | |
| 141 | + | // Convert to list.Item slice | |
| 142 | + | listItems := make([]list.Item, len(items)) | |
| 143 | + | for i, item := range items { | |
| 144 | + | listItems[i] = item | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | delegate := NewHistoryDelegate(theme) | |
| 148 | + | ||
| 149 | + | l := list.New(listItems, delegate, width, height) | |
| 150 | + | l.SetShowTitle(false) | |
| 151 | + | l.SetShowStatusBar(false) | |
| 152 | + | l.SetShowFilter(false) | |
| 153 | + | l.SetShowHelp(false) | |
| 154 | + | l.SetShowPagination(true) | |
| 155 | + | ||
| 156 | + | // Apply theme colors to pagination | |
| 157 | + | l.Styles.PaginationStyle = lipgloss.NewStyle().Foreground(theme.Muted) | |
| 158 | + | l.Styles.ActivePaginationDot = lipgloss.NewStyle().Foreground(theme.Accent) | |
| 159 | + | l.Styles.InactivePaginationDot = lipgloss.NewStyle().Foreground(theme.Muted) | |
| 160 | + | ||
| 161 | + | return l | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // SimpleItem is a basic list item with title and description. | |
| 165 | + | type SimpleItem struct { | |
| 166 | + | TitleText string | |
| 167 | + | DescText string | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // FilterValue implements list.Item. | |
| 171 | + | func (i SimpleItem) FilterValue() string { | |
| 172 | + | return i.TitleText + " " + i.DescText | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // SimpleDelegate renders simple items. | |
| 176 | + | type SimpleDelegate struct { | |
| 177 | + | Theme *Theme | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // Height returns the height of each item. | |
| 181 | + | func (d SimpleDelegate) Height() int { | |
| 182 | + | return 1 | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // Spacing returns the spacing between items. | |
| 186 | + | func (d SimpleDelegate) Spacing() int { | |
| 187 | + | return 0 | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // Update handles item-level events. | |
| 191 | + | func (d SimpleDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { | |
| 192 | + | return nil | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // Render renders a single list item. | |
| 196 | + | func (d SimpleDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { | |
| 197 | + | si, ok := item.(SimpleItem) | |
| 198 | + | if !ok { | |
| 199 | + | return | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | t := d.Theme | |
| 203 | + | isSelected := index == m.Index() | |
| 204 | + | ||
| 205 | + | cursor := cursorEmpty | |
| 206 | + | style := t.ListItem | |
| 207 | + | if isSelected { | |
| 208 | + | cursor = cursorSelected | |
| 209 | + | style = t.ListItemSelected | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | line := lipgloss.JoinHorizontal( | |
| 213 | + | lipgloss.Left, | |
| 214 | + | t.Highlight.Render(cursor), | |
| 215 | + | style.Render(si.TitleText), | |
| 216 | + | ) | |
| 217 | + | ||
| 218 | + | if si.DescText != "" { | |
| 219 | + | line = lipgloss.JoinHorizontal( | |
| 220 | + | lipgloss.Left, | |
| 221 | + | line, | |
| 222 | + | " ", | |
| 223 | + | t.Subtle.Render(si.DescText), | |
| 224 | + | ) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | _, _ = fmt.Fprint(w, line) | |
| 228 | + | } | |
| @@ -0,0 +1,228 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "io" | |||
| 6 | + | "strings" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/charmbracelet/bubbles/list" | |||
| 10 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 11 | + | "github.com/charmbracelet/lipgloss" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // HistoryItem represents a history entry for the list. | |||
| 15 | + | type HistoryItem struct { | |||
| 16 | + | ID int64 | |||
| 17 | + | URL string | |||
| 18 | + | Title string | |||
| 19 | + | Domain string | |||
| 20 | + | VisitCount int | |||
| 21 | + | LastVisited time.Time | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // FilterValue implements list.Item. | |||
| 25 | + | func (i HistoryItem) FilterValue() string { | |||
| 26 | + | return i.Title + " " + i.URL | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // Title implements list.DefaultItem. | |||
| 30 | + | func (i HistoryItem) TitleValue() string { | |||
| 31 | + | if i.Title != "" { | |||
| 32 | + | return i.Title | |||
| 33 | + | } | |||
| 34 | + | return i.URL | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Description implements list.DefaultItem. | |||
| 38 | + | func (i HistoryItem) DescriptionValue() string { | |||
| 39 | + | return i.URL | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // HistoryDelegate renders history items with theme styling. | |||
| 43 | + | type HistoryDelegate struct { | |||
| 44 | + | Theme *Theme | |||
| 45 | + | ShowDomain bool | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // NewHistoryDelegate creates a themed history list delegate. | |||
| 49 | + | func NewHistoryDelegate(theme *Theme) HistoryDelegate { | |||
| 50 | + | return HistoryDelegate{ | |||
| 51 | + | Theme: theme, | |||
| 52 | + | ShowDomain: true, | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // Height returns the height of each item. | |||
| 57 | + | func (d HistoryDelegate) Height() int { | |||
| 58 | + | return 2 | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Spacing returns the spacing between items. | |||
| 62 | + | func (d HistoryDelegate) Spacing() int { | |||
| 63 | + | return 0 | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Update handles item-level events. | |||
| 67 | + | func (d HistoryDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { | |||
| 68 | + | return nil | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // Render renders a single list item. | |||
| 72 | + | func (d HistoryDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { | |||
| 73 | + | hi, ok := item.(HistoryItem) | |||
| 74 | + | if !ok { | |||
| 75 | + | return | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | t := d.Theme | |||
| 79 | + | isSelected := index == m.Index() | |||
| 80 | + | ||||
| 81 | + | // Title line | |||
| 82 | + | title := hi.TitleValue() | |||
| 83 | + | if len(title) > 60 { | |||
| 84 | + | title = title[:57] + "..." | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // URL/description line | |||
| 88 | + | url := hi.URL | |||
| 89 | + | if len(url) > 50 { | |||
| 90 | + | url = url[:47] + "..." | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // Metadata badges | |||
| 94 | + | visitBadge := t.MutedBadge(fmt.Sprintf("%d", hi.VisitCount)) | |||
| 95 | + | timeBadge := t.MutedBadge(RelativeTime(hi.LastVisited)) | |||
| 96 | + | ||||
| 97 | + | // Build cursor | |||
| 98 | + | cursor := cursorEmpty | |||
| 99 | + | if isSelected { | |||
| 100 | + | cursor = cursorSelected | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Apply styles | |||
| 104 | + | cursorStyle := t.Highlight | |||
| 105 | + | titleStyle := t.ListItemTitle | |||
| 106 | + | urlStyle := t.ListItemDesc | |||
| 107 | + | ||||
| 108 | + | if isSelected { | |||
| 109 | + | titleStyle = titleStyle.Copy().Foreground(t.Accent).Bold(true) | |||
| 110 | + | urlStyle = urlStyle.Copy().Foreground(t.Text) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // First line: cursor + title | |||
| 114 | + | line1 := lipgloss.JoinHorizontal( | |||
| 115 | + | lipgloss.Left, | |||
| 116 | + | cursorStyle.Render(cursor), | |||
| 117 | + | titleStyle.Render(title), | |||
| 118 | + | ) | |||
| 119 | + | ||||
| 120 | + | // Second line: url + badges | |||
| 121 | + | meta := lipgloss.JoinHorizontal( | |||
| 122 | + | lipgloss.Left, | |||
| 123 | + | visitBadge, | |||
| 124 | + | " ", | |||
| 125 | + | timeBadge, | |||
| 126 | + | ) | |||
| 127 | + | ||||
| 128 | + | line2 := lipgloss.JoinHorizontal( | |||
| 129 | + | lipgloss.Left, | |||
| 130 | + | strings.Repeat(" ", 3), // Indent under cursor | |||
| 131 | + | urlStyle.Render(url), | |||
| 132 | + | " ", | |||
| 133 | + | meta, | |||
| 134 | + | ) | |||
| 135 | + | ||||
| 136 | + | _, _ = fmt.Fprintf(w, "%s\n%s", line1, line2) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // NewHistoryList creates a themed list for history items. | |||
| 140 | + | func NewHistoryList(theme *Theme, items []HistoryItem, width, height int) list.Model { | |||
| 141 | + | // Convert to list.Item slice | |||
| 142 | + | listItems := make([]list.Item, len(items)) | |||
| 143 | + | for i, item := range items { | |||
| 144 | + | listItems[i] = item | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | delegate := NewHistoryDelegate(theme) | |||
| 148 | + | ||||
| 149 | + | l := list.New(listItems, delegate, width, height) | |||
| 150 | + | l.SetShowTitle(false) | |||
| 151 | + | l.SetShowStatusBar(false) | |||
| 152 | + | l.SetShowFilter(false) | |||
| 153 | + | l.SetShowHelp(false) | |||
| 154 | + | l.SetShowPagination(true) | |||
| 155 | + | ||||
| 156 | + | // Apply theme colors to pagination | |||
| 157 | + | l.Styles.PaginationStyle = lipgloss.NewStyle().Foreground(theme.Muted) | |||
| 158 | + | l.Styles.ActivePaginationDot = lipgloss.NewStyle().Foreground(theme.Accent) | |||
| 159 | + | l.Styles.InactivePaginationDot = lipgloss.NewStyle().Foreground(theme.Muted) | |||
| 160 | + | ||||
| 161 | + | return l | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // SimpleItem is a basic list item with title and description. | |||
| 165 | + | type SimpleItem struct { | |||
| 166 | + | TitleText string | |||
| 167 | + | DescText string | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // FilterValue implements list.Item. | |||
| 171 | + | func (i SimpleItem) FilterValue() string { | |||
| 172 | + | return i.TitleText + " " + i.DescText | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // SimpleDelegate renders simple items. | |||
| 176 | + | type SimpleDelegate struct { | |||
| 177 | + | Theme *Theme | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // Height returns the height of each item. | |||
| 181 | + | func (d SimpleDelegate) Height() int { | |||
| 182 | + | return 1 | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // Spacing returns the spacing between items. | |||
| 186 | + | func (d SimpleDelegate) Spacing() int { | |||
| 187 | + | return 0 | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // Update handles item-level events. | |||
| 191 | + | func (d SimpleDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { | |||
| 192 | + | return nil | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // Render renders a single list item. | |||
| 196 | + | func (d SimpleDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { | |||
| 197 | + | si, ok := item.(SimpleItem) | |||
| 198 | + | if !ok { | |||
| 199 | + | return | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | t := d.Theme | |||
| 203 | + | isSelected := index == m.Index() | |||
| 204 | + | ||||
| 205 | + | cursor := cursorEmpty | |||
| 206 | + | style := t.ListItem | |||
| 207 | + | if isSelected { | |||
| 208 | + | cursor = cursorSelected | |||
| 209 | + | style = t.ListItemSelected | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | line := lipgloss.JoinHorizontal( | |||
| 213 | + | lipgloss.Left, | |||
| 214 | + | t.Highlight.Render(cursor), | |||
| 215 | + | style.Render(si.TitleText), | |||
| 216 | + | ) | |||
| 217 | + | ||||
| 218 | + | if si.DescText != "" { | |||
| 219 | + | line = lipgloss.JoinHorizontal( | |||
| 220 | + | lipgloss.Left, | |||
| 221 | + | line, | |||
| 222 | + | " ", | |||
| 223 | + | t.Subtle.Render(si.DescText), | |||
| 224 | + | ) | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | _, _ = fmt.Fprint(w, line) | |||
| 228 | + | } | |||
A internal/cli/styles/spinner.go +83 -0
| @@ -0,0 +1,83 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/charmbracelet/bubbles/spinner" | |
| 5 | + | "github.com/charmbracelet/lipgloss" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // SpinnerType defines available spinner styles. | |
| 9 | + | type SpinnerType int | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | SpinnerDots SpinnerType = iota | |
| 13 | + | SpinnerLine | |
| 14 | + | SpinnerMiniDot | |
| 15 | + | SpinnerJump | |
| 16 | + | SpinnerPulse | |
| 17 | + | SpinnerPoints | |
| 18 | + | SpinnerGlobe | |
| 19 | + | SpinnerMoon | |
| 20 | + | SpinnerMonkey | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // NewStyledSpinner creates a themed spinner. | |
| 24 | + | func NewStyledSpinner(theme *Theme, spinnerType SpinnerType) spinner.Model { | |
| 25 | + | s := spinner.New() | |
| 26 | + | s.Style = lipgloss.NewStyle().Foreground(theme.Accent) | |
| 27 | + | ||
| 28 | + | switch spinnerType { | |
| 29 | + | case SpinnerDots: | |
| 30 | + | s.Spinner = spinner.Dot | |
| 31 | + | case SpinnerLine: | |
| 32 | + | s.Spinner = spinner.Line | |
| 33 | + | case SpinnerMiniDot: | |
| 34 | + | s.Spinner = spinner.MiniDot | |
| 35 | + | case SpinnerJump: | |
| 36 | + | s.Spinner = spinner.Jump | |
| 37 | + | case SpinnerPulse: | |
| 38 | + | s.Spinner = spinner.Pulse | |
| 39 | + | case SpinnerPoints: | |
| 40 | + | s.Spinner = spinner.Points | |
| 41 | + | case SpinnerGlobe: | |
| 42 | + | s.Spinner = spinner.Globe | |
| 43 | + | case SpinnerMoon: | |
| 44 | + | s.Spinner = spinner.Moon | |
| 45 | + | case SpinnerMonkey: | |
| 46 | + | s.Spinner = spinner.Monkey | |
| 47 | + | default: | |
| 48 | + | s.Spinner = spinner.Dot | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | return s | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // NewDefaultSpinner creates the default themed spinner. | |
| 55 | + | func NewDefaultSpinner(theme *Theme) spinner.Model { | |
| 56 | + | return NewStyledSpinner(theme, SpinnerDots) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // LoadingModel wraps a spinner with a message. | |
| 60 | + | type LoadingModel struct { | |
| 61 | + | Spinner spinner.Model | |
| 62 | + | Message string | |
| 63 | + | theme *Theme | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // NewLoading creates a loading indicator with message. | |
| 67 | + | func NewLoading(theme *Theme, message string) LoadingModel { | |
| 68 | + | return LoadingModel{ | |
| 69 | + | Spinner: NewDefaultSpinner(theme), | |
| 70 | + | Message: message, | |
| 71 | + | theme: theme, | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // View renders the loading indicator. | |
| 76 | + | func (m LoadingModel) View() string { | |
| 77 | + | return lipgloss.JoinHorizontal( | |
| 78 | + | lipgloss.Center, | |
| 79 | + | m.Spinner.View(), | |
| 80 | + | " ", | |
| 81 | + | m.theme.Subtle.Render(m.Message), | |
| 82 | + | ) | |
| 83 | + | } | |
| @@ -0,0 +1,83 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/charmbracelet/bubbles/spinner" | |||
| 5 | + | "github.com/charmbracelet/lipgloss" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // SpinnerType defines available spinner styles. | |||
| 9 | + | type SpinnerType int | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | SpinnerDots SpinnerType = iota | |||
| 13 | + | SpinnerLine | |||
| 14 | + | SpinnerMiniDot | |||
| 15 | + | SpinnerJump | |||
| 16 | + | SpinnerPulse | |||
| 17 | + | SpinnerPoints | |||
| 18 | + | SpinnerGlobe | |||
| 19 | + | SpinnerMoon | |||
| 20 | + | SpinnerMonkey | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // NewStyledSpinner creates a themed spinner. | |||
| 24 | + | func NewStyledSpinner(theme *Theme, spinnerType SpinnerType) spinner.Model { | |||
| 25 | + | s := spinner.New() | |||
| 26 | + | s.Style = lipgloss.NewStyle().Foreground(theme.Accent) | |||
| 27 | + | ||||
| 28 | + | switch spinnerType { | |||
| 29 | + | case SpinnerDots: | |||
| 30 | + | s.Spinner = spinner.Dot | |||
| 31 | + | case SpinnerLine: | |||
| 32 | + | s.Spinner = spinner.Line | |||
| 33 | + | case SpinnerMiniDot: | |||
| 34 | + | s.Spinner = spinner.MiniDot | |||
| 35 | + | case SpinnerJump: | |||
| 36 | + | s.Spinner = spinner.Jump | |||
| 37 | + | case SpinnerPulse: | |||
| 38 | + | s.Spinner = spinner.Pulse | |||
| 39 | + | case SpinnerPoints: | |||
| 40 | + | s.Spinner = spinner.Points | |||
| 41 | + | case SpinnerGlobe: | |||
| 42 | + | s.Spinner = spinner.Globe | |||
| 43 | + | case SpinnerMoon: | |||
| 44 | + | s.Spinner = spinner.Moon | |||
| 45 | + | case SpinnerMonkey: | |||
| 46 | + | s.Spinner = spinner.Monkey | |||
| 47 | + | default: | |||
| 48 | + | s.Spinner = spinner.Dot | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | return s | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // NewDefaultSpinner creates the default themed spinner. | |||
| 55 | + | func NewDefaultSpinner(theme *Theme) spinner.Model { | |||
| 56 | + | return NewStyledSpinner(theme, SpinnerDots) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // LoadingModel wraps a spinner with a message. | |||
| 60 | + | type LoadingModel struct { | |||
| 61 | + | Spinner spinner.Model | |||
| 62 | + | Message string | |||
| 63 | + | theme *Theme | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // NewLoading creates a loading indicator with message. | |||
| 67 | + | func NewLoading(theme *Theme, message string) LoadingModel { | |||
| 68 | + | return LoadingModel{ | |||
| 69 | + | Spinner: NewDefaultSpinner(theme), | |||
| 70 | + | Message: message, | |||
| 71 | + | theme: theme, | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // View renders the loading indicator. | |||
| 76 | + | func (m LoadingModel) View() string { | |||
| 77 | + | return lipgloss.JoinHorizontal( | |||
| 78 | + | lipgloss.Center, | |||
| 79 | + | m.Spinner.View(), | |||
| 80 | + | " ", | |||
| 81 | + | m.theme.Subtle.Render(m.Message), | |||
| 82 | + | ) | |||
| 83 | + | } | |||
A internal/cli/styles/table.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/charmbracelet/bubbles/table" | |
| 5 | + | "github.com/charmbracelet/lipgloss" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // NewStyledTable creates a themed table model. | |
| 9 | + | func NewStyledTable(theme *Theme, columns []table.Column, rows []table.Row, width, height int) table.Model { | |
| 10 | + | t := table.New( | |
| 11 | + | table.WithColumns(columns), | |
| 12 | + | table.WithRows(rows), | |
| 13 | + | table.WithFocused(true), | |
| 14 | + | table.WithHeight(height), | |
| 15 | + | table.WithWidth(width), | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Apply theme styles | |
| 19 | + | s := table.DefaultStyles() | |
| 20 | + | s.Header = s.Header. | |
| 21 | + | BorderStyle(lipgloss.NormalBorder()). | |
| 22 | + | BorderForeground(theme.Border). | |
| 23 | + | BorderBottom(true). | |
| 24 | + | Foreground(theme.Accent). | |
| 25 | + | Bold(true) | |
| 26 | + | s.Selected = s.Selected. | |
| 27 | + | Foreground(theme.Text). | |
| 28 | + | Background(theme.SurfaceVariant). | |
| 29 | + | Bold(true) | |
| 30 | + | s.Cell = s.Cell. | |
| 31 | + | Foreground(theme.Text) | |
| 32 | + | ||
| 33 | + | t.SetStyles(s) | |
| 34 | + | return t | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // StatsTableColumns returns columns for domain stats table. | |
| 38 | + | func StatsTableColumns() []table.Column { | |
| 39 | + | return []table.Column{ | |
| 40 | + | {Title: "Domain", Width: 30}, | |
| 41 | + | {Title: "Visits", Width: 10}, | |
| 42 | + | {Title: "Entries", Width: 10}, | |
| 43 | + | {Title: "Last Visit", Width: 15}, | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // HistoryTableColumns returns columns for history list table. | |
| 48 | + | func HistoryTableColumns() []table.Column { | |
| 49 | + | return []table.Column{ | |
| 50 | + | {Title: "Title", Width: 40}, | |
| 51 | + | {Title: "URL", Width: 30}, | |
| 52 | + | {Title: "Visits", Width: 8}, | |
| 53 | + | {Title: "Last Visit", Width: 12}, | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // DomainStatsRow converts domain stats to table row. | |
| 58 | + | type DomainStatsRow struct { | |
| 59 | + | Domain string | |
| 60 | + | Visits int | |
| 61 | + | Entries int | |
| 62 | + | LastVisit string | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // ToRow converts to table.Row. | |
| 66 | + | func (d DomainStatsRow) ToRow() table.Row { | |
| 67 | + | return table.Row{d.Domain, formatInt(d.Visits), formatInt(d.Entries), d.LastVisit} | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // formatInt formats an integer for display. | |
| 71 | + | func formatInt(n int) string { | |
| 72 | + | switch { | |
| 73 | + | case n >= 1000000: | |
| 74 | + | return formatFloat(float64(n)/1000000) + "M" | |
| 75 | + | case n >= 1000: | |
| 76 | + | return formatFloat(float64(n)/1000) + "K" | |
| 77 | + | default: | |
| 78 | + | return intToString(n) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // formatFloat formats a float with one decimal. | |
| 83 | + | func formatFloat(f float64) string { | |
| 84 | + | i := int(f * 10) | |
| 85 | + | whole := i / 10 | |
| 86 | + | dec := i % 10 | |
| 87 | + | if dec == 0 { | |
| 88 | + | return intToString(whole) | |
| 89 | + | } | |
| 90 | + | return intToString(whole) + "." + intToString(dec) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // intToString converts int to string without fmt. | |
| 94 | + | func intToString(n int) string { | |
| 95 | + | if n == 0 { | |
| 96 | + | return "0" | |
| 97 | + | } | |
| 98 | + | if n < 0 { | |
| 99 | + | return "-" + intToString(-n) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | digits := make([]byte, 0, 10) | |
| 103 | + | for n > 0 { | |
| 104 | + | digits = append(digits, byte('0'+n%10)) | |
| 105 | + | n /= 10 | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // Reverse | |
| 109 | + | for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 { | |
| 110 | + | digits[i], digits[j] = digits[j], digits[i] | |
| 111 | + | } | |
| 112 | + | return string(digits) | |
| 113 | + | } | |
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/charmbracelet/bubbles/table" | |||
| 5 | + | "github.com/charmbracelet/lipgloss" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // NewStyledTable creates a themed table model. | |||
| 9 | + | func NewStyledTable(theme *Theme, columns []table.Column, rows []table.Row, width, height int) table.Model { | |||
| 10 | + | t := table.New( | |||
| 11 | + | table.WithColumns(columns), | |||
| 12 | + | table.WithRows(rows), | |||
| 13 | + | table.WithFocused(true), | |||
| 14 | + | table.WithHeight(height), | |||
| 15 | + | table.WithWidth(width), | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // Apply theme styles | |||
| 19 | + | s := table.DefaultStyles() | |||
| 20 | + | s.Header = s.Header. | |||
| 21 | + | BorderStyle(lipgloss.NormalBorder()). | |||
| 22 | + | BorderForeground(theme.Border). | |||
| 23 | + | BorderBottom(true). | |||
| 24 | + | Foreground(theme.Accent). | |||
| 25 | + | Bold(true) | |||
| 26 | + | s.Selected = s.Selected. | |||
| 27 | + | Foreground(theme.Text). | |||
| 28 | + | Background(theme.SurfaceVariant). | |||
| 29 | + | Bold(true) | |||
| 30 | + | s.Cell = s.Cell. | |||
| 31 | + | Foreground(theme.Text) | |||
| 32 | + | ||||
| 33 | + | t.SetStyles(s) | |||
| 34 | + | return t | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // StatsTableColumns returns columns for domain stats table. | |||
| 38 | + | func StatsTableColumns() []table.Column { | |||
| 39 | + | return []table.Column{ | |||
| 40 | + | {Title: "Domain", Width: 30}, | |||
| 41 | + | {Title: "Visits", Width: 10}, | |||
| 42 | + | {Title: "Entries", Width: 10}, | |||
| 43 | + | {Title: "Last Visit", Width: 15}, | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // HistoryTableColumns returns columns for history list table. | |||
| 48 | + | func HistoryTableColumns() []table.Column { | |||
| 49 | + | return []table.Column{ | |||
| 50 | + | {Title: "Title", Width: 40}, | |||
| 51 | + | {Title: "URL", Width: 30}, | |||
| 52 | + | {Title: "Visits", Width: 8}, | |||
| 53 | + | {Title: "Last Visit", Width: 12}, | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // DomainStatsRow converts domain stats to table row. | |||
| 58 | + | type DomainStatsRow struct { | |||
| 59 | + | Domain string | |||
| 60 | + | Visits int | |||
| 61 | + | Entries int | |||
| 62 | + | LastVisit string | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // ToRow converts to table.Row. | |||
| 66 | + | func (d DomainStatsRow) ToRow() table.Row { | |||
| 67 | + | return table.Row{d.Domain, formatInt(d.Visits), formatInt(d.Entries), d.LastVisit} | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // formatInt formats an integer for display. | |||
| 71 | + | func formatInt(n int) string { | |||
| 72 | + | switch { | |||
| 73 | + | case n >= 1000000: | |||
| 74 | + | return formatFloat(float64(n)/1000000) + "M" | |||
| 75 | + | case n >= 1000: | |||
| 76 | + | return formatFloat(float64(n)/1000) + "K" | |||
| 77 | + | default: | |||
| 78 | + | return intToString(n) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // formatFloat formats a float with one decimal. | |||
| 83 | + | func formatFloat(f float64) string { | |||
| 84 | + | i := int(f * 10) | |||
| 85 | + | whole := i / 10 | |||
| 86 | + | dec := i % 10 | |||
| 87 | + | if dec == 0 { | |||
| 88 | + | return intToString(whole) | |||
| 89 | + | } | |||
| 90 | + | return intToString(whole) + "." + intToString(dec) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // intToString converts int to string without fmt. | |||
| 94 | + | func intToString(n int) string { | |||
| 95 | + | if n == 0 { | |||
| 96 | + | return "0" | |||
| 97 | + | } | |||
| 98 | + | if n < 0 { | |||
| 99 | + | return "-" + intToString(-n) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | digits := make([]byte, 0, 10) | |||
| 103 | + | for n > 0 { | |||
| 104 | + | digits = append(digits, byte('0'+n%10)) | |||
| 105 | + | n /= 10 | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // Reverse | |||
| 109 | + | for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 { | |||
| 110 | + | digits[i], digits[j] = digits[j], digits[i] | |||
| 111 | + | } | |||
| 112 | + | return string(digits) | |||
| 113 | + | } | |||
A internal/cli/styles/tabs.go +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/charmbracelet/lipgloss" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // TabsModel represents a horizontal tab bar. | |
| 10 | + | type TabsModel struct { | |
| 11 | + | Tabs []string | |
| 12 | + | Active int | |
| 13 | + | theme *Theme | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // NewTabs creates a new tab bar with the given labels. | |
| 17 | + | func NewTabs(theme *Theme, tabs ...string) TabsModel { | |
| 18 | + | return TabsModel{ | |
| 19 | + | Tabs: tabs, | |
| 20 | + | Active: 0, | |
| 21 | + | theme: theme, | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // SetActive sets the active tab index. | |
| 26 | + | func (m *TabsModel) SetActive(index int) { | |
| 27 | + | if index >= 0 && index < len(m.Tabs) { | |
| 28 | + | m.Active = index | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Next moves to the next tab. | |
| 33 | + | func (m *TabsModel) Next() { | |
| 34 | + | m.Active = (m.Active + 1) % len(m.Tabs) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Prev moves to the previous tab. | |
| 38 | + | func (m *TabsModel) Prev() { | |
| 39 | + | m.Active = (m.Active - 1 + len(m.Tabs)) % len(m.Tabs) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // View renders the tab bar. | |
| 43 | + | func (m TabsModel) View() string { | |
| 44 | + | var tabs []string | |
| 45 | + | ||
| 46 | + | for i, tab := range m.Tabs { | |
| 47 | + | style := m.theme.InactiveTab | |
| 48 | + | if i == m.Active { | |
| 49 | + | style = m.theme.ActiveTab | |
| 50 | + | } | |
| 51 | + | tabs = append(tabs, style.Render(tab)) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Join tabs with separator | |
| 55 | + | gap := lipgloss.NewStyle(). | |
| 56 | + | Foreground(m.theme.Border). | |
| 57 | + | Render(" │ ") | |
| 58 | + | ||
| 59 | + | row := "" | |
| 60 | + | for i, tab := range tabs { | |
| 61 | + | if i > 0 { | |
| 62 | + | row += gap | |
| 63 | + | } | |
| 64 | + | row += tab | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | return m.theme.TabBar.Width(80).Render(row) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // ViewCompact renders a compact tab bar without separators. | |
| 71 | + | func (m TabsModel) ViewCompact() string { | |
| 72 | + | var tabs []string | |
| 73 | + | ||
| 74 | + | for i, tab := range m.Tabs { | |
| 75 | + | style := m.theme.InactiveTab | |
| 76 | + | if i == m.Active { | |
| 77 | + | style = m.theme.ActiveTab | |
| 78 | + | } | |
| 79 | + | tabs = append(tabs, style.Render(tab)) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // TimelineTabs creates tabs for history timeline view. | |
| 86 | + | func TimelineTabs(theme *Theme) TabsModel { | |
| 87 | + | return NewTabs(theme, "Today", "Yesterday", "This Week", "Older") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // ViewWithCounts renders tabs with item counts. | |
| 91 | + | func (m TabsModel) ViewWithCounts(counts []int) string { | |
| 92 | + | var tabs []string | |
| 93 | + | ||
| 94 | + | for i, tab := range m.Tabs { | |
| 95 | + | count := 0 | |
| 96 | + | if i < len(counts) { | |
| 97 | + | count = counts[i] | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | label := tab | |
| 101 | + | if count > 0 { | |
| 102 | + | label = lipgloss.JoinHorizontal( | |
| 103 | + | lipgloss.Center, | |
| 104 | + | tab, | |
| 105 | + | " ", | |
| 106 | + | m.theme.BadgeMuted.Render(formatCount(count)), | |
| 107 | + | ) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | style := m.theme.InactiveTab | |
| 111 | + | if i == m.Active { | |
| 112 | + | style = m.theme.ActiveTab | |
| 113 | + | } | |
| 114 | + | tabs = append(tabs, style.Render(label)) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | gap := lipgloss.NewStyle(). | |
| 118 | + | Foreground(m.theme.Border). | |
| 119 | + | Render(" ") | |
| 120 | + | ||
| 121 | + | return lipgloss.JoinHorizontal(lipgloss.Top, join(tabs, gap)...) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // join inserts a separator between items. | |
| 125 | + | func join(items []string, sep string) []string { | |
| 126 | + | if len(items) == 0 { | |
| 127 | + | return items | |
| 128 | + | } | |
| 129 | + | result := make([]string, 0, len(items)*2-1) | |
| 130 | + | for i, item := range items { | |
| 131 | + | if i > 0 { | |
| 132 | + | result = append(result, sep) | |
| 133 | + | } | |
| 134 | + | result = append(result, item) | |
| 135 | + | } | |
| 136 | + | return result | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // formatCount formats a count for display. | |
| 140 | + | func formatCount(n int) string { | |
| 141 | + | if n >= 1000 { | |
| 142 | + | return "999+" | |
| 143 | + | } | |
| 144 | + | return fmt.Sprintf("%d", n) | |
| 145 | + | } | |
| @@ -0,0 +1,145 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/charmbracelet/lipgloss" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // TabsModel represents a horizontal tab bar. | |||
| 10 | + | type TabsModel struct { | |||
| 11 | + | Tabs []string | |||
| 12 | + | Active int | |||
| 13 | + | theme *Theme | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // NewTabs creates a new tab bar with the given labels. | |||
| 17 | + | func NewTabs(theme *Theme, tabs ...string) TabsModel { | |||
| 18 | + | return TabsModel{ | |||
| 19 | + | Tabs: tabs, | |||
| 20 | + | Active: 0, | |||
| 21 | + | theme: theme, | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // SetActive sets the active tab index. | |||
| 26 | + | func (m *TabsModel) SetActive(index int) { | |||
| 27 | + | if index >= 0 && index < len(m.Tabs) { | |||
| 28 | + | m.Active = index | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // Next moves to the next tab. | |||
| 33 | + | func (m *TabsModel) Next() { | |||
| 34 | + | m.Active = (m.Active + 1) % len(m.Tabs) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Prev moves to the previous tab. | |||
| 38 | + | func (m *TabsModel) Prev() { | |||
| 39 | + | m.Active = (m.Active - 1 + len(m.Tabs)) % len(m.Tabs) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // View renders the tab bar. | |||
| 43 | + | func (m TabsModel) View() string { | |||
| 44 | + | var tabs []string | |||
| 45 | + | ||||
| 46 | + | for i, tab := range m.Tabs { | |||
| 47 | + | style := m.theme.InactiveTab | |||
| 48 | + | if i == m.Active { | |||
| 49 | + | style = m.theme.ActiveTab | |||
| 50 | + | } | |||
| 51 | + | tabs = append(tabs, style.Render(tab)) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // Join tabs with separator | |||
| 55 | + | gap := lipgloss.NewStyle(). | |||
| 56 | + | Foreground(m.theme.Border). | |||
| 57 | + | Render(" │ ") | |||
| 58 | + | ||||
| 59 | + | row := "" | |||
| 60 | + | for i, tab := range tabs { | |||
| 61 | + | if i > 0 { | |||
| 62 | + | row += gap | |||
| 63 | + | } | |||
| 64 | + | row += tab | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | return m.theme.TabBar.Width(80).Render(row) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // ViewCompact renders a compact tab bar without separators. | |||
| 71 | + | func (m TabsModel) ViewCompact() string { | |||
| 72 | + | var tabs []string | |||
| 73 | + | ||||
| 74 | + | for i, tab := range m.Tabs { | |||
| 75 | + | style := m.theme.InactiveTab | |||
| 76 | + | if i == m.Active { | |||
| 77 | + | style = m.theme.ActiveTab | |||
| 78 | + | } | |||
| 79 | + | tabs = append(tabs, style.Render(tab)) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // TimelineTabs creates tabs for history timeline view. | |||
| 86 | + | func TimelineTabs(theme *Theme) TabsModel { | |||
| 87 | + | return NewTabs(theme, "Today", "Yesterday", "This Week", "Older") | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // ViewWithCounts renders tabs with item counts. | |||
| 91 | + | func (m TabsModel) ViewWithCounts(counts []int) string { | |||
| 92 | + | var tabs []string | |||
| 93 | + | ||||
| 94 | + | for i, tab := range m.Tabs { | |||
| 95 | + | count := 0 | |||
| 96 | + | if i < len(counts) { | |||
| 97 | + | count = counts[i] | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | label := tab | |||
| 101 | + | if count > 0 { | |||
| 102 | + | label = lipgloss.JoinHorizontal( | |||
| 103 | + | lipgloss.Center, | |||
| 104 | + | tab, | |||
| 105 | + | " ", | |||
| 106 | + | m.theme.BadgeMuted.Render(formatCount(count)), | |||
| 107 | + | ) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | style := m.theme.InactiveTab | |||
| 111 | + | if i == m.Active { | |||
| 112 | + | style = m.theme.ActiveTab | |||
| 113 | + | } | |||
| 114 | + | tabs = append(tabs, style.Render(label)) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | gap := lipgloss.NewStyle(). | |||
| 118 | + | Foreground(m.theme.Border). | |||
| 119 | + | Render(" ") | |||
| 120 | + | ||||
| 121 | + | return lipgloss.JoinHorizontal(lipgloss.Top, join(tabs, gap)...) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // join inserts a separator between items. | |||
| 125 | + | func join(items []string, sep string) []string { | |||
| 126 | + | if len(items) == 0 { | |||
| 127 | + | return items | |||
| 128 | + | } | |||
| 129 | + | result := make([]string, 0, len(items)*2-1) | |||
| 130 | + | for i, item := range items { | |||
| 131 | + | if i > 0 { | |||
| 132 | + | result = append(result, sep) | |||
| 133 | + | } | |||
| 134 | + | result = append(result, item) | |||
| 135 | + | } | |||
| 136 | + | return result | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // formatCount formats a count for display. | |||
| 140 | + | func formatCount(n int) string { | |||
| 141 | + | if n >= 1000 { | |||
| 142 | + | return "999+" | |||
| 143 | + | } | |||
| 144 | + | return fmt.Sprintf("%d", n) | |||
| 145 | + | } | |||
A internal/cli/styles/theme.go +222 -0
| @@ -0,0 +1,222 @@ | |||
| 1 | + | // Package styles provides reusable lipgloss-based TUI components. | |
| 2 | + | package styles | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "github.com/charmbracelet/lipgloss" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Theme holds lipgloss colors and styles derived from config. | |
| 11 | + | type Theme struct { | |
| 12 | + | // Base colors (from config.ColorPalette) | |
| 13 | + | Background lipgloss.Color | |
| 14 | + | Surface lipgloss.Color | |
| 15 | + | SurfaceVariant lipgloss.Color | |
| 16 | + | Text lipgloss.Color | |
| 17 | + | Muted lipgloss.Color | |
| 18 | + | Accent lipgloss.Color | |
| 19 | + | Border lipgloss.Color | |
| 20 | + | ||
| 21 | + | // Additional semantic colors | |
| 22 | + | Error lipgloss.Color | |
| 23 | + | Warning lipgloss.Color | |
| 24 | + | Success lipgloss.Color | |
| 25 | + | ||
| 26 | + | // Pre-built styles | |
| 27 | + | Title lipgloss.Style | |
| 28 | + | Subtitle lipgloss.Style | |
| 29 | + | Normal lipgloss.Style | |
| 30 | + | Subtle lipgloss.Style | |
| 31 | + | Highlight lipgloss.Style | |
| 32 | + | ErrorStyle lipgloss.Style | |
| 33 | + | WarningStyle lipgloss.Style | |
| 34 | + | SuccessStyle lipgloss.Style | |
| 35 | + | ||
| 36 | + | // Component styles | |
| 37 | + | ActiveTab lipgloss.Style | |
| 38 | + | InactiveTab lipgloss.Style | |
| 39 | + | TabBar lipgloss.Style | |
| 40 | + | ||
| 41 | + | ListItem lipgloss.Style | |
| 42 | + | ListItemSelected lipgloss.Style | |
| 43 | + | ListItemTitle lipgloss.Style | |
| 44 | + | ListItemDesc lipgloss.Style | |
| 45 | + | ||
| 46 | + | Badge lipgloss.Style | |
| 47 | + | BadgeMuted lipgloss.Style | |
| 48 | + | ||
| 49 | + | Input lipgloss.Style | |
| 50 | + | InputFocused lipgloss.Style | |
| 51 | + | ||
| 52 | + | HelpKey lipgloss.Style | |
| 53 | + | HelpDesc lipgloss.Style | |
| 54 | + | ||
| 55 | + | Box lipgloss.Style | |
| 56 | + | BoxHeader lipgloss.Style | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // DefaultDarkPalette returns hardcoded dark theme colors. | |
| 60 | + | func DefaultDarkPalette() config.ColorPalette { | |
| 61 | + | return config.ColorPalette{ | |
| 62 | + | Background: "#0a0a0b", | |
| 63 | + | Surface: "#1a1a1b", | |
| 64 | + | SurfaceVariant: "#2d2d2d", | |
| 65 | + | Text: "#ffffff", | |
| 66 | + | Muted: "#909090", | |
| 67 | + | Accent: "#4ade80", | |
| 68 | + | Border: "#333333", | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // NewTheme creates a Theme from config, using dark palette only. | |
| 73 | + | func NewTheme(cfg *config.Config) *Theme { | |
| 74 | + | var p config.ColorPalette | |
| 75 | + | if cfg != nil && cfg.Appearance.DarkPalette.Background != "" { | |
| 76 | + | p = cfg.Appearance.DarkPalette | |
| 77 | + | } else { | |
| 78 | + | p = DefaultDarkPalette() | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | return NewThemeFromPalette(p) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // NewThemeFromPalette creates a Theme from a ColorPalette. | |
| 85 | + | func NewThemeFromPalette(p config.ColorPalette) *Theme { | |
| 86 | + | t := &Theme{ | |
| 87 | + | Background: lipgloss.Color(p.Background), | |
| 88 | + | Surface: lipgloss.Color(p.Surface), | |
| 89 | + | SurfaceVariant: lipgloss.Color(p.SurfaceVariant), | |
| 90 | + | Text: lipgloss.Color(p.Text), | |
| 91 | + | Muted: lipgloss.Color(p.Muted), | |
| 92 | + | Accent: lipgloss.Color(p.Accent), | |
| 93 | + | Border: lipgloss.Color(p.Border), | |
| 94 | + | ||
| 95 | + | // Semantic colors (not in config, use sensible defaults) | |
| 96 | + | Error: lipgloss.Color("#ef4444"), | |
| 97 | + | Warning: lipgloss.Color("#f59e0b"), | |
| 98 | + | Success: lipgloss.Color(p.Accent), // Use accent as success | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Build derived styles | |
| 102 | + | t.buildStyles() | |
| 103 | + | return t | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // buildStyles creates all derived lipgloss styles. | |
| 107 | + | func (t *Theme) buildStyles() { | |
| 108 | + | // Text styles | |
| 109 | + | t.Title = lipgloss.NewStyle(). | |
| 110 | + | Foreground(t.Text). | |
| 111 | + | Bold(true) | |
| 112 | + | ||
| 113 | + | t.Subtitle = lipgloss.NewStyle(). | |
| 114 | + | Foreground(t.Muted). | |
| 115 | + | Bold(true) | |
| 116 | + | ||
| 117 | + | t.Normal = lipgloss.NewStyle(). | |
| 118 | + | Foreground(t.Text) | |
| 119 | + | ||
| 120 | + | t.Subtle = lipgloss.NewStyle(). | |
| 121 | + | Foreground(t.Muted) | |
| 122 | + | ||
| 123 | + | t.Highlight = lipgloss.NewStyle(). | |
| 124 | + | Foreground(t.Accent). | |
| 125 | + | Bold(true) | |
| 126 | + | ||
| 127 | + | t.ErrorStyle = lipgloss.NewStyle(). | |
| 128 | + | Foreground(t.Error) | |
| 129 | + | ||
| 130 | + | t.WarningStyle = lipgloss.NewStyle(). | |
| 131 | + | Foreground(t.Warning) | |
| 132 | + | ||
| 133 | + | t.SuccessStyle = lipgloss.NewStyle(). | |
| 134 | + | Foreground(t.Success) | |
| 135 | + | ||
| 136 | + | // Tab styles | |
| 137 | + | t.ActiveTab = lipgloss.NewStyle(). | |
| 138 | + | Foreground(t.Background). | |
| 139 | + | Background(t.Accent). | |
| 140 | + | Padding(0, 2). | |
| 141 | + | Bold(true) | |
| 142 | + | ||
| 143 | + | t.InactiveTab = lipgloss.NewStyle(). | |
| 144 | + | Foreground(t.Muted). | |
| 145 | + | Background(t.Surface). | |
| 146 | + | Padding(0, 2) | |
| 147 | + | ||
| 148 | + | t.TabBar = lipgloss.NewStyle(). | |
| 149 | + | Background(t.Surface). | |
| 150 | + | BorderStyle(lipgloss.NormalBorder()). | |
| 151 | + | BorderBottom(true). | |
| 152 | + | BorderForeground(t.Border) | |
| 153 | + | ||
| 154 | + | // List item styles | |
| 155 | + | t.ListItem = lipgloss.NewStyle(). | |
| 156 | + | Foreground(t.Text). | |
| 157 | + | PaddingLeft(2) | |
| 158 | + | ||
| 159 | + | t.ListItemSelected = lipgloss.NewStyle(). | |
| 160 | + | Foreground(t.Accent). | |
| 161 | + | Background(t.SurfaceVariant). | |
| 162 | + | PaddingLeft(2). | |
| 163 | + | Bold(true) | |
| 164 | + | ||
| 165 | + | t.ListItemTitle = lipgloss.NewStyle(). | |
| 166 | + | Foreground(t.Text) | |
| 167 | + | ||
| 168 | + | t.ListItemDesc = lipgloss.NewStyle(). | |
| 169 | + | Foreground(t.Muted) | |
| 170 | + | ||
| 171 | + | // Badge styles | |
| 172 | + | t.Badge = lipgloss.NewStyle(). | |
| 173 | + | Foreground(t.Background). | |
| 174 | + | Background(t.Accent). | |
| 175 | + | Padding(0, 1) | |
| 176 | + | ||
| 177 | + | t.BadgeMuted = lipgloss.NewStyle(). | |
| 178 | + | Foreground(t.Text). | |
| 179 | + | Background(t.SurfaceVariant). | |
| 180 | + | Padding(0, 1) | |
| 181 | + | ||
| 182 | + | // Input styles | |
| 183 | + | t.Input = lipgloss.NewStyle(). | |
| 184 | + | Foreground(t.Text). | |
| 185 | + | Background(t.Surface). | |
| 186 | + | BorderStyle(lipgloss.RoundedBorder()). | |
| 187 | + | BorderForeground(t.Border). | |
| 188 | + | Padding(0, 1) | |
| 189 | + | ||
| 190 | + | t.InputFocused = lipgloss.NewStyle(). | |
| 191 | + | Foreground(t.Text). | |
| 192 | + | Background(t.Surface). | |
| 193 | + | BorderStyle(lipgloss.RoundedBorder()). | |
| 194 | + | BorderForeground(t.Accent). | |
| 195 | + | Padding(0, 1) | |
| 196 | + | ||
| 197 | + | // Help styles | |
| 198 | + | t.HelpKey = lipgloss.NewStyle(). | |
| 199 | + | Foreground(t.Accent) | |
| 200 | + | ||
| 201 | + | t.HelpDesc = lipgloss.NewStyle(). | |
| 202 | + | Foreground(t.Muted) | |
| 203 | + | ||
| 204 | + | // Box/container styles | |
| 205 | + | t.Box = lipgloss.NewStyle(). | |
| 206 | + | BorderStyle(lipgloss.RoundedBorder()). | |
| 207 | + | BorderForeground(t.Border). | |
| 208 | + | Padding(1, 2) | |
| 209 | + | ||
| 210 | + | t.BoxHeader = lipgloss.NewStyle(). | |
| 211 | + | Foreground(t.Text). | |
| 212 | + | Bold(true). | |
| 213 | + | BorderStyle(lipgloss.NormalBorder()). | |
| 214 | + | BorderBottom(true). | |
| 215 | + | BorderForeground(t.Border). | |
| 216 | + | MarginBottom(1) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // Renderer returns a lipgloss renderer for the current output. | |
| 220 | + | func (t *Theme) Renderer() *lipgloss.Renderer { | |
| 221 | + | return lipgloss.DefaultRenderer() | |
| 222 | + | } | |
| @@ -0,0 +1,222 @@ | |||||
| 1 | + | // Package styles provides reusable lipgloss-based TUI components. | |||
| 2 | + | package styles | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "github.com/charmbracelet/lipgloss" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Theme holds lipgloss colors and styles derived from config. | |||
| 11 | + | type Theme struct { | |||
| 12 | + | // Base colors (from config.ColorPalette) | |||
| 13 | + | Background lipgloss.Color | |||
| 14 | + | Surface lipgloss.Color | |||
| 15 | + | SurfaceVariant lipgloss.Color | |||
| 16 | + | Text lipgloss.Color | |||
| 17 | + | Muted lipgloss.Color | |||
| 18 | + | Accent lipgloss.Color | |||
| 19 | + | Border lipgloss.Color | |||
| 20 | + | ||||
| 21 | + | // Additional semantic colors | |||
| 22 | + | Error lipgloss.Color | |||
| 23 | + | Warning lipgloss.Color | |||
| 24 | + | Success lipgloss.Color | |||
| 25 | + | ||||
| 26 | + | // Pre-built styles | |||
| 27 | + | Title lipgloss.Style | |||
| 28 | + | Subtitle lipgloss.Style | |||
| 29 | + | Normal lipgloss.Style | |||
| 30 | + | Subtle lipgloss.Style | |||
| 31 | + | Highlight lipgloss.Style | |||
| 32 | + | ErrorStyle lipgloss.Style | |||
| 33 | + | WarningStyle lipgloss.Style | |||
| 34 | + | SuccessStyle lipgloss.Style | |||
| 35 | + | ||||
| 36 | + | // Component styles | |||
| 37 | + | ActiveTab lipgloss.Style | |||
| 38 | + | InactiveTab lipgloss.Style | |||
| 39 | + | TabBar lipgloss.Style | |||
| 40 | + | ||||
| 41 | + | ListItem lipgloss.Style | |||
| 42 | + | ListItemSelected lipgloss.Style | |||
| 43 | + | ListItemTitle lipgloss.Style | |||
| 44 | + | ListItemDesc lipgloss.Style | |||
| 45 | + | ||||
| 46 | + | Badge lipgloss.Style | |||
| 47 | + | BadgeMuted lipgloss.Style | |||
| 48 | + | ||||
| 49 | + | Input lipgloss.Style | |||
| 50 | + | InputFocused lipgloss.Style | |||
| 51 | + | ||||
| 52 | + | HelpKey lipgloss.Style | |||
| 53 | + | HelpDesc lipgloss.Style | |||
| 54 | + | ||||
| 55 | + | Box lipgloss.Style | |||
| 56 | + | BoxHeader lipgloss.Style | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // DefaultDarkPalette returns hardcoded dark theme colors. | |||
| 60 | + | func DefaultDarkPalette() config.ColorPalette { | |||
| 61 | + | return config.ColorPalette{ | |||
| 62 | + | Background: "#0a0a0b", | |||
| 63 | + | Surface: "#1a1a1b", | |||
| 64 | + | SurfaceVariant: "#2d2d2d", | |||
| 65 | + | Text: "#ffffff", | |||
| 66 | + | Muted: "#909090", | |||
| 67 | + | Accent: "#4ade80", | |||
| 68 | + | Border: "#333333", | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // NewTheme creates a Theme from config, using dark palette only. | |||
| 73 | + | func NewTheme(cfg *config.Config) *Theme { | |||
| 74 | + | var p config.ColorPalette | |||
| 75 | + | if cfg != nil && cfg.Appearance.DarkPalette.Background != "" { | |||
| 76 | + | p = cfg.Appearance.DarkPalette | |||
| 77 | + | } else { | |||
| 78 | + | p = DefaultDarkPalette() | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | return NewThemeFromPalette(p) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // NewThemeFromPalette creates a Theme from a ColorPalette. | |||
| 85 | + | func NewThemeFromPalette(p config.ColorPalette) *Theme { | |||
| 86 | + | t := &Theme{ | |||
| 87 | + | Background: lipgloss.Color(p.Background), | |||
| 88 | + | Surface: lipgloss.Color(p.Surface), | |||
| 89 | + | SurfaceVariant: lipgloss.Color(p.SurfaceVariant), | |||
| 90 | + | Text: lipgloss.Color(p.Text), | |||
| 91 | + | Muted: lipgloss.Color(p.Muted), | |||
| 92 | + | Accent: lipgloss.Color(p.Accent), | |||
| 93 | + | Border: lipgloss.Color(p.Border), | |||
| 94 | + | ||||
| 95 | + | // Semantic colors (not in config, use sensible defaults) | |||
| 96 | + | Error: lipgloss.Color("#ef4444"), | |||
| 97 | + | Warning: lipgloss.Color("#f59e0b"), | |||
| 98 | + | Success: lipgloss.Color(p.Accent), // Use accent as success | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // Build derived styles | |||
| 102 | + | t.buildStyles() | |||
| 103 | + | return t | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // buildStyles creates all derived lipgloss styles. | |||
| 107 | + | func (t *Theme) buildStyles() { | |||
| 108 | + | // Text styles | |||
| 109 | + | t.Title = lipgloss.NewStyle(). | |||
| 110 | + | Foreground(t.Text). | |||
| 111 | + | Bold(true) | |||
| 112 | + | ||||
| 113 | + | t.Subtitle = lipgloss.NewStyle(). | |||
| 114 | + | Foreground(t.Muted). | |||
| 115 | + | Bold(true) | |||
| 116 | + | ||||
| 117 | + | t.Normal = lipgloss.NewStyle(). | |||
| 118 | + | Foreground(t.Text) | |||
| 119 | + | ||||
| 120 | + | t.Subtle = lipgloss.NewStyle(). | |||
| 121 | + | Foreground(t.Muted) | |||
| 122 | + | ||||
| 123 | + | t.Highlight = lipgloss.NewStyle(). | |||
| 124 | + | Foreground(t.Accent). | |||
| 125 | + | Bold(true) | |||
| 126 | + | ||||
| 127 | + | t.ErrorStyle = lipgloss.NewStyle(). | |||
| 128 | + | Foreground(t.Error) | |||
| 129 | + | ||||
| 130 | + | t.WarningStyle = lipgloss.NewStyle(). | |||
| 131 | + | Foreground(t.Warning) | |||
| 132 | + | ||||
| 133 | + | t.SuccessStyle = lipgloss.NewStyle(). | |||
| 134 | + | Foreground(t.Success) | |||
| 135 | + | ||||
| 136 | + | // Tab styles | |||
| 137 | + | t.ActiveTab = lipgloss.NewStyle(). | |||
| 138 | + | Foreground(t.Background). | |||
| 139 | + | Background(t.Accent). | |||
| 140 | + | Padding(0, 2). | |||
| 141 | + | Bold(true) | |||
| 142 | + | ||||
| 143 | + | t.InactiveTab = lipgloss.NewStyle(). | |||
| 144 | + | Foreground(t.Muted). | |||
| 145 | + | Background(t.Surface). | |||
| 146 | + | Padding(0, 2) | |||
| 147 | + | ||||
| 148 | + | t.TabBar = lipgloss.NewStyle(). | |||
| 149 | + | Background(t.Surface). | |||
| 150 | + | BorderStyle(lipgloss.NormalBorder()). | |||
| 151 | + | BorderBottom(true). | |||
| 152 | + | BorderForeground(t.Border) | |||
| 153 | + | ||||
| 154 | + | // List item styles | |||
| 155 | + | t.ListItem = lipgloss.NewStyle(). | |||
| 156 | + | Foreground(t.Text). | |||
| 157 | + | PaddingLeft(2) | |||
| 158 | + | ||||
| 159 | + | t.ListItemSelected = lipgloss.NewStyle(). | |||
| 160 | + | Foreground(t.Accent). | |||
| 161 | + | Background(t.SurfaceVariant). | |||
| 162 | + | PaddingLeft(2). | |||
| 163 | + | Bold(true) | |||
| 164 | + | ||||
| 165 | + | t.ListItemTitle = lipgloss.NewStyle(). | |||
| 166 | + | Foreground(t.Text) | |||
| 167 | + | ||||
| 168 | + | t.ListItemDesc = lipgloss.NewStyle(). | |||
| 169 | + | Foreground(t.Muted) | |||
| 170 | + | ||||
| 171 | + | // Badge styles | |||
| 172 | + | t.Badge = lipgloss.NewStyle(). | |||
| 173 | + | Foreground(t.Background). | |||
| 174 | + | Background(t.Accent). | |||
| 175 | + | Padding(0, 1) | |||
| 176 | + | ||||
| 177 | + | t.BadgeMuted = lipgloss.NewStyle(). | |||
| 178 | + | Foreground(t.Text). | |||
| 179 | + | Background(t.SurfaceVariant). | |||
| 180 | + | Padding(0, 1) | |||
| 181 | + | ||||
| 182 | + | // Input styles | |||
| 183 | + | t.Input = lipgloss.NewStyle(). | |||
| 184 | + | Foreground(t.Text). | |||
| 185 | + | Background(t.Surface). | |||
| 186 | + | BorderStyle(lipgloss.RoundedBorder()). | |||
| 187 | + | BorderForeground(t.Border). | |||
| 188 | + | Padding(0, 1) | |||
| 189 | + | ||||
| 190 | + | t.InputFocused = lipgloss.NewStyle(). | |||
| 191 | + | Foreground(t.Text). | |||
| 192 | + | Background(t.Surface). | |||
| 193 | + | BorderStyle(lipgloss.RoundedBorder()). | |||
| 194 | + | BorderForeground(t.Accent). | |||
| 195 | + | Padding(0, 1) | |||
| 196 | + | ||||
| 197 | + | // Help styles | |||
| 198 | + | t.HelpKey = lipgloss.NewStyle(). | |||
| 199 | + | Foreground(t.Accent) | |||
| 200 | + | ||||
| 201 | + | t.HelpDesc = lipgloss.NewStyle(). | |||
| 202 | + | Foreground(t.Muted) | |||
| 203 | + | ||||
| 204 | + | // Box/container styles | |||
| 205 | + | t.Box = lipgloss.NewStyle(). | |||
| 206 | + | BorderStyle(lipgloss.RoundedBorder()). | |||
| 207 | + | BorderForeground(t.Border). | |||
| 208 | + | Padding(1, 2) | |||
| 209 | + | ||||
| 210 | + | t.BoxHeader = lipgloss.NewStyle(). | |||
| 211 | + | Foreground(t.Text). | |||
| 212 | + | Bold(true). | |||
| 213 | + | BorderStyle(lipgloss.NormalBorder()). | |||
| 214 | + | BorderBottom(true). | |||
| 215 | + | BorderForeground(t.Border). | |||
| 216 | + | MarginBottom(1) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // Renderer returns a lipgloss renderer for the current output. | |||
| 220 | + | func (t *Theme) Renderer() *lipgloss.Renderer { | |||
| 221 | + | return lipgloss.DefaultRenderer() | |||
| 222 | + | } | |||
A internal/logging/session.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package logging | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/rand" | |
| 5 | + | "encoding/hex" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // GenerateSessionID creates a unique session identifier. | |
| 10 | + | // Format: YYYYMMDD_HHMMSS_xxxx (timestamp + 4 random hex chars) | |
| 11 | + | // Example: 20251217_205106_a7b3 | |
| 12 | + | func GenerateSessionID() string { | |
| 13 | + | now := time.Now() | |
| 14 | + | random := make([]byte, 2) | |
| 15 | + | _, _ = rand.Read(random) | |
| 16 | + | return now.Format("20060102_150405") + "_" + hex.EncodeToString(random) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // ShortSessionID extracts the short ID (last 4 hex chars) from a full session ID. | |
| 20 | + | // Example: "20251217_205106_a7b3" -> "a7b3" | |
| 21 | + | func ShortSessionID(sessionID string) string { | |
| 22 | + | if len(sessionID) < 4 { | |
| 23 | + | return sessionID | |
| 24 | + | } | |
| 25 | + | return sessionID[len(sessionID)-4:] | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // ParseSessionFilename extracts session info from a log filename. | |
| 29 | + | // Example: "session_20251217_205106_a7b3.log" -> "20251217_205106_a7b3", true | |
| 30 | + | func ParseSessionFilename(filename string) (sessionID string, ok bool) { | |
| 31 | + | const prefix = "session_" | |
| 32 | + | const suffix = ".log" | |
| 33 | + | ||
| 34 | + | if len(filename) < len(prefix)+len(suffix) { | |
| 35 | + | return "", false | |
| 36 | + | } | |
| 37 | + | if filename[:len(prefix)] != prefix { | |
| 38 | + | return "", false | |
| 39 | + | } | |
| 40 | + | if filename[len(filename)-len(suffix):] != suffix { | |
| 41 | + | return "", false | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | return filename[len(prefix) : len(filename)-len(suffix)], true | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // SessionFilename generates the log filename for a session ID. | |
| 48 | + | // Example: "20251217_205106_a7b3" -> "session_20251217_205106_a7b3.log" | |
| 49 | + | func SessionFilename(sessionID string) string { | |
| 50 | + | return "session_" + sessionID + ".log" | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | package logging | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/rand" | |||
| 5 | + | "encoding/hex" | |||
| 6 | + | "time" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // GenerateSessionID creates a unique session identifier. | |||
| 10 | + | // Format: YYYYMMDD_HHMMSS_xxxx (timestamp + 4 random hex chars) | |||
| 11 | + | // Example: 20251217_205106_a7b3 | |||
| 12 | + | func GenerateSessionID() string { | |||
| 13 | + | now := time.Now() | |||
| 14 | + | random := make([]byte, 2) | |||
| 15 | + | _, _ = rand.Read(random) | |||
| 16 | + | return now.Format("20060102_150405") + "_" + hex.EncodeToString(random) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // ShortSessionID extracts the short ID (last 4 hex chars) from a full session ID. | |||
| 20 | + | // Example: "20251217_205106_a7b3" -> "a7b3" | |||
| 21 | + | func ShortSessionID(sessionID string) string { | |||
| 22 | + | if len(sessionID) < 4 { | |||
| 23 | + | return sessionID | |||
| 24 | + | } | |||
| 25 | + | return sessionID[len(sessionID)-4:] | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // ParseSessionFilename extracts session info from a log filename. | |||
| 29 | + | // Example: "session_20251217_205106_a7b3.log" -> "20251217_205106_a7b3", true | |||
| 30 | + | func ParseSessionFilename(filename string) (sessionID string, ok bool) { | |||
| 31 | + | const prefix = "session_" | |||
| 32 | + | const suffix = ".log" | |||
| 33 | + | ||||
| 34 | + | if len(filename) < len(prefix)+len(suffix) { | |||
| 35 | + | return "", false | |||
| 36 | + | } | |||
| 37 | + | if filename[:len(prefix)] != prefix { | |||
| 38 | + | return "", false | |||
| 39 | + | } | |||
| 40 | + | if filename[len(filename)-len(suffix):] != suffix { | |||
| 41 | + | return "", false | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | return filename[len(prefix) : len(filename)-len(suffix)], true | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // SessionFilename generates the log filename for a session ID. | |||
| 48 | + | // Example: "20251217_205106_a7b3" -> "session_20251217_205106_a7b3.log" | |||
| 49 | + | func SessionFilename(sessionID string) string { | |||
| 50 | + | return "session_" + sessionID + ".log" | |||
| 51 | + | } | |||