cli: align config/crashes/sessions subcommands with styles
- commit
- f6e095c1ef17944741417c15ca1ff79ef0cda183
- author
- brice · · 2026-02-10 06:17 UTC
- parent
- 517f612
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +253 -51
M internal/cli/cmd/config.go +21 -4
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | 7 | "os/exec" | |
| 8 | + | "strings" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/charmbracelet/bubbles/spinner" | |
| 10 | 11 | tea "github.com/charmbracelet/bubbletea" | |
| @@ -108,27 +109,43 @@ func runConfigSchema(_ *cobra.Command, _ []string) error { | |||
| 108 | 109 | ||
| 109 | 110 | // runConfigOpen opens the config file in the user's preferred editor. | |
| 110 | 111 | func runConfigOpen(_ *cobra.Command, _ []string) error { | |
| 112 | + | app := GetApp() | |
| 113 | + | if app == nil { | |
| 114 | + | return fmt.Errorf("app not initialized") | |
| 115 | + | } | |
| 116 | + | ||
| 111 | 117 | configFile, err := config.GetConfigFile() | |
| 112 | 118 | if err != nil { | |
| 113 | - | return fmt.Errorf("get config file: %w", err) | |
| 119 | + | renderer := styles.NewConfigRenderer(app.Theme) | |
| 120 | + | return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("get config file: %w", err)))) | |
| 114 | 121 | } | |
| 115 | 122 | ||
| 116 | 123 | // Check if config file exists | |
| 117 | 124 | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |
| 118 | - | return fmt.Errorf("config file does not exist: %s", configFile) | |
| 125 | + | renderer := styles.NewConfigRenderer(app.Theme) | |
| 126 | + | return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("config file does not exist: %s", configFile)))) | |
| 119 | 127 | } | |
| 120 | 128 | ||
| 121 | 129 | editor := getEditor() | |
| 122 | 130 | if editor == "" { | |
| 123 | - | return fmt.Errorf("no editor found: set $EDITOR or $VISUAL environment variable") | |
| 131 | + | renderer := styles.NewConfigRenderer(app.Theme) | |
| 132 | + | const msg = "no editor found: set $EDITOR or $VISUAL environment variable" | |
| 133 | + | return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("%s", msg)))) | |
| 124 | 134 | } | |
| 125 | 135 | ||
| 136 | + | // Keep this a simple, non-interactive message; the actual editor takes over the terminal. | |
| 137 | + | fmt.Println(styles.NewConfigRenderer(app.Theme).RenderOpening(configFile, editor)) | |
| 138 | + | ||
| 126 | 139 | cmd := exec.Command(editor, configFile) | |
| 127 | 140 | cmd.Stdin = os.Stdin | |
| 128 | 141 | cmd.Stdout = os.Stdout | |
| 129 | 142 | cmd.Stderr = os.Stderr | |
| 130 | 143 | ||
| 131 | - | return cmd.Run() | |
| 144 | + | if err := cmd.Run(); err != nil { | |
| 145 | + | renderer := styles.NewConfigRenderer(app.Theme) | |
| 146 | + | return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("open config: %w", err)))) | |
| 147 | + | } | |
| 148 | + | return nil | |
| 132 | 149 | } | |
| 133 | 150 | ||
| 134 | 151 | // getEditor returns the user's preferred editor. | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | "os/exec" | 7 | "os/exec" | ||
| 8 | + | "strings" | |||
| 8 | 9 | ||||
| 9 | "github.com/charmbracelet/bubbles/spinner" | 10 | "github.com/charmbracelet/bubbles/spinner" | ||
| 10 | tea "github.com/charmbracelet/bubbletea" | 11 | tea "github.com/charmbracelet/bubbletea" | ||
| @@ -108,27 +109,43 @@ func runConfigSchema(_ *cobra.Command, _ []string) error { | |||||
| 108 | 109 | ||||
| 109 | // runConfigOpen opens the config file in the user's preferred editor. | 110 | // runConfigOpen opens the config file in the user's preferred editor. | ||
| 110 | func runConfigOpen(_ *cobra.Command, _ []string) error { | 111 | func runConfigOpen(_ *cobra.Command, _ []string) error { | ||
| 112 | + | app := GetApp() | |||
| 113 | + | if app == nil { | |||
| 114 | + | return fmt.Errorf("app not initialized") | |||
| 115 | + | } | |||
| 116 | + | ||||
| 111 | configFile, err := config.GetConfigFile() | 117 | configFile, err := config.GetConfigFile() | ||
| 112 | if err != nil { | 118 | if err != nil { | ||
| 113 | - | return fmt.Errorf("get config file: %w", err) | 119 | + | renderer := styles.NewConfigRenderer(app.Theme) |
| 120 | + | return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("get config file: %w", err)))) | |||
| 114 | } | 121 | } | ||
| 115 | 122 | ||||
| 116 | // Check if config file exists | 123 | // Check if config file exists | ||
| 117 | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | 124 | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | ||
| 118 | - | return fmt.Errorf("config file does not exist: %s", configFile) | 125 | + | renderer := styles.NewConfigRenderer(app.Theme) |
| 126 | + | return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("config file does not exist: %s", configFile)))) | |||
| 119 | } | 127 | } | ||
| 120 | 128 | ||||
| 121 | editor := getEditor() | 129 | editor := getEditor() | ||
| 122 | if editor == "" { | 130 | if editor == "" { | ||
| 123 | - | return fmt.Errorf("no editor found: set $EDITOR or $VISUAL environment variable") | 131 | + | renderer := styles.NewConfigRenderer(app.Theme) |
| 132 | + | const msg = "no editor found: set $EDITOR or $VISUAL environment variable" | |||
| 133 | + | return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("%s", msg)))) | |||
| 124 | } | 134 | } | ||
| 125 | 135 | ||||
| 136 | + | // Keep this a simple, non-interactive message; the actual editor takes over the terminal. | |||
| 137 | + | fmt.Println(styles.NewConfigRenderer(app.Theme).RenderOpening(configFile, editor)) | |||
| 138 | + | ||||
| 126 | cmd := exec.Command(editor, configFile) | 139 | cmd := exec.Command(editor, configFile) | ||
| 127 | cmd.Stdin = os.Stdin | 140 | cmd.Stdin = os.Stdin | ||
| 128 | cmd.Stdout = os.Stdout | 141 | cmd.Stdout = os.Stdout | ||
| 129 | cmd.Stderr = os.Stderr | 142 | cmd.Stderr = os.Stderr | ||
| 130 | 143 | ||||
| 131 | - | return cmd.Run() | 144 | + | if err := cmd.Run(); err != nil { |
| 145 | + | renderer := styles.NewConfigRenderer(app.Theme) | |||
| 146 | + | return fmt.Errorf("%s", strings.TrimSpace(renderer.RenderError(fmt.Errorf("open config: %w", err)))) | |||
| 147 | + | } | |||
| 148 | + | return nil | |||
| 132 | } | 149 | } | ||
| 133 | 150 | ||||
| 134 | // getEditor returns the user's preferred editor. | 151 | // getEditor returns the user's preferred editor. | ||
M internal/cli/cmd/crashes.go +7 -4
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/spf13/cobra" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 14 | 15 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| @@ -95,15 +96,16 @@ func runCrashesShow(_ *cobra.Command, args []string) error { | |||
| 95 | 96 | if app == nil { | |
| 96 | 97 | return fmt.Errorf("app not initialized") | |
| 97 | 98 | } | |
| 99 | + | renderer := styles.NewCrashesCLIRenderer(app.Theme) | |
| 98 | 100 | ||
| 99 | 101 | report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0]) | |
| 100 | 102 | if err != nil { | |
| 101 | - | return err | |
| 103 | + | return fmt.Errorf("%s\n%s", renderer.RenderError(err), renderer.RenderHintList()) | |
| 102 | 104 | } | |
| 103 | 105 | ||
| 104 | 106 | body, err := os.ReadFile(report.MarkdownPath) | |
| 105 | 107 | if err != nil { | |
| 106 | - | return fmt.Errorf("read crash report markdown: %w", err) | |
| 108 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err))) | |
| 107 | 109 | } | |
| 108 | 110 | fmt.Print(string(body)) | |
| 109 | 111 | return nil | |
| @@ -114,15 +116,16 @@ func runCrashesIssue(_ *cobra.Command, args []string) error { | |||
| 114 | 116 | if app == nil { | |
| 115 | 117 | return fmt.Errorf("app not initialized") | |
| 116 | 118 | } | |
| 119 | + | renderer := styles.NewCrashesCLIRenderer(app.Theme) | |
| 117 | 120 | ||
| 118 | 121 | report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0]) | |
| 119 | 122 | if err != nil { | |
| 120 | - | return err | |
| 123 | + | return fmt.Errorf("%s\n%s", renderer.RenderError(err), renderer.RenderHintList()) | |
| 121 | 124 | } | |
| 122 | 125 | ||
| 123 | 126 | body, err := os.ReadFile(report.MarkdownPath) | |
| 124 | 127 | if err != nil { | |
| 125 | - | return fmt.Errorf("read crash report markdown: %w", err) | |
| 128 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err))) | |
| 126 | 129 | } | |
| 127 | 130 | all := string(body) | |
| 128 | 131 | start := strings.Index(all, "## GitHub Issue Template") | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | "github.com/spf13/cobra" | 12 | "github.com/spf13/cobra" | ||
| 13 | 13 | ||||
| 14 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 14 | "github.com/bnema/dumber/internal/infrastructure/config" | 15 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| @@ -95,15 +96,16 @@ func runCrashesShow(_ *cobra.Command, args []string) error { | |||||
| 95 | if app == nil { | 96 | if app == nil { | ||
| 96 | return fmt.Errorf("app not initialized") | 97 | return fmt.Errorf("app not initialized") | ||
| 97 | } | 98 | } | ||
| 99 | + | renderer := styles.NewCrashesCLIRenderer(app.Theme) | |||
| 98 | 100 | ||||
| 99 | report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0]) | 101 | report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0]) | ||
| 100 | if err != nil { | 102 | if err != nil { | ||
| 101 | - | return err | 103 | + | return fmt.Errorf("%s\n%s", renderer.RenderError(err), renderer.RenderHintList()) |
| 102 | } | 104 | } | ||
| 103 | 105 | ||||
| 104 | body, err := os.ReadFile(report.MarkdownPath) | 106 | body, err := os.ReadFile(report.MarkdownPath) | ||
| 105 | if err != nil { | 107 | if err != nil { | ||
| 106 | - | return fmt.Errorf("read crash report markdown: %w", err) | 108 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err))) |
| 107 | } | 109 | } | ||
| 108 | fmt.Print(string(body)) | 110 | fmt.Print(string(body)) | ||
| 109 | return nil | 111 | return nil | ||
| @@ -114,15 +116,16 @@ func runCrashesIssue(_ *cobra.Command, args []string) error { | |||||
| 114 | if app == nil { | 116 | if app == nil { | ||
| 115 | return fmt.Errorf("app not initialized") | 117 | return fmt.Errorf("app not initialized") | ||
| 116 | } | 118 | } | ||
| 119 | + | renderer := styles.NewCrashesCLIRenderer(app.Theme) | |||
| 117 | 120 | ||||
| 118 | report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0]) | 121 | report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0]) | ||
| 119 | if err != nil { | 122 | if err != nil { | ||
| 120 | - | return err | 123 | + | return fmt.Errorf("%s\n%s", renderer.RenderError(err), renderer.RenderHintList()) |
| 121 | } | 124 | } | ||
| 122 | 125 | ||||
| 123 | body, err := os.ReadFile(report.MarkdownPath) | 126 | body, err := os.ReadFile(report.MarkdownPath) | ||
| 124 | if err != nil { | 127 | if err != nil { | ||
| 125 | - | return fmt.Errorf("read crash report markdown: %w", err) | 128 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("read crash report markdown: %w", err))) |
| 126 | } | 129 | } | ||
| 127 | all := string(body) | 130 | all := string(body) | ||
| 128 | start := strings.Index(all, "## GitHub Issue Template") | 131 | start := strings.Index(all, "## GitHub Issue Template") | ||
M internal/cli/cmd/sessions.go +16 -43
| @@ -5,13 +5,13 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | 7 | "strings" | |
| 8 | - | "text/tabwriter" | |
| 9 | 8 | ||
| 10 | 9 | tea "github.com/charmbracelet/bubbletea" | |
| 11 | 10 | "github.com/spf13/cobra" | |
| 12 | 11 | ||
| 13 | 12 | "github.com/bnema/dumber/internal/application/usecase" | |
| 14 | 13 | "github.com/bnema/dumber/internal/cli/model" | |
| 14 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 15 | 15 | "github.com/bnema/dumber/internal/domain/entity" | |
| 16 | 16 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 17 | 17 | ) | |
| @@ -93,9 +93,10 @@ func runSessionsList(_ *cobra.Command, _ []string) error { | |||
| 93 | 93 | if app == nil { | |
| 94 | 94 | return fmt.Errorf("app not initialized") | |
| 95 | 95 | } | |
| 96 | + | renderer := styles.NewSessionsCLIRenderer(app.Theme) | |
| 96 | 97 | ||
| 97 | 98 | if app.ListSessionsUC == nil { | |
| 98 | - | return fmt.Errorf("session management not available") | |
| 99 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("session management not available"))) | |
| 99 | 100 | } | |
| 100 | 101 | ||
| 101 | 102 | // Get current session ID (empty if not running as browser) | |
| @@ -113,7 +114,8 @@ func runSessionsList(_ *cobra.Command, _ []string) error { | |||
| 113 | 114 | return outputSessionsJSON(output.Sessions) | |
| 114 | 115 | } | |
| 115 | 116 | ||
| 116 | - | return outputSessionsTable(output.Sessions) | |
| 117 | + | fmt.Println(renderer.RenderList(output.Sessions, sessionsLimit)) | |
| 118 | + | return nil | |
| 117 | 119 | } | |
| 118 | 120 | ||
| 119 | 121 | func outputSessionsJSON(sessions []entity.SessionInfo) error { | |
| @@ -122,37 +124,6 @@ func outputSessionsJSON(sessions []entity.SessionInfo) error { | |||
| 122 | 124 | return enc.Encode(sessions) | |
| 123 | 125 | } | |
| 124 | 126 | ||
| 125 | - | func outputSessionsTable(sessions []entity.SessionInfo) error { | |
| 126 | - | if len(sessions) == 0 { | |
| 127 | - | fmt.Println("No saved sessions found.") | |
| 128 | - | return nil | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 132 | - | _, _ = fmt.Fprintln(w, "STATUS\tSESSION ID\tTABS\tPANES\tLAST UPDATED") | |
| 133 | - | ||
| 134 | - | for _, info := range sessions { | |
| 135 | - | status := " " | |
| 136 | - | switch { | |
| 137 | - | case info.IsCurrent: | |
| 138 | - | status = "●" | |
| 139 | - | case info.IsActive: | |
| 140 | - | status = "○" | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | relTime := usecase.GetRelativeTime(info.UpdatedAt) | |
| 144 | - | _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n", | |
| 145 | - | status, | |
| 146 | - | info.Session.ID, | |
| 147 | - | info.TabCount, | |
| 148 | - | info.PaneCount, | |
| 149 | - | relTime, | |
| 150 | - | ) | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | return w.Flush() | |
| 154 | - | } | |
| 155 | - | ||
| 156 | 127 | // sessions restore <id> | |
| 157 | 128 | var sessionsRestoreCmd = &cobra.Command{ | |
| 158 | 129 | Use: "restore <session-id>", | |
| @@ -180,15 +151,16 @@ func runSessionsRestore(_ *cobra.Command, args []string) error { | |||
| 180 | 151 | if app == nil { | |
| 181 | 152 | return fmt.Errorf("app not initialized") | |
| 182 | 153 | } | |
| 154 | + | renderer := styles.NewSessionsCLIRenderer(app.Theme) | |
| 183 | 155 | ||
| 184 | 156 | if app.RestoreUC == nil || app.ListSessionsUC == nil { | |
| 185 | - | return fmt.Errorf("session restoration not available") | |
| 157 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("session restoration not available"))) | |
| 186 | 158 | } | |
| 187 | 159 | ||
| 188 | 160 | // Find session by ID or suffix | |
| 189 | 161 | sessionInfo, err := findSessionByIDOrSuffix(args[0]) | |
| 190 | 162 | if err != nil { | |
| 191 | - | return err | |
| 163 | + | return fmt.Errorf("%s", renderer.RenderError(err)) | |
| 192 | 164 | } | |
| 193 | 165 | ||
| 194 | 166 | sessionID := sessionInfo.Session.ID | |
| @@ -196,16 +168,16 @@ func runSessionsRestore(_ *cobra.Command, args []string) error { | |||
| 196 | 168 | // Validate the session has restorable state | |
| 197 | 169 | _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID}) | |
| 198 | 170 | if err != nil { | |
| 199 | - | return fmt.Errorf("restore session: %w", err) | |
| 171 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("restore session: %w", err))) | |
| 200 | 172 | } | |
| 201 | 173 | ||
| 202 | 174 | // Spawn a new dumber instance with the session | |
| 203 | 175 | spawner := desktop.NewSessionSpawner(app.Ctx()) | |
| 204 | 176 | if err := spawner.SpawnWithSession(sessionID); err != nil { | |
| 205 | - | return fmt.Errorf("spawn browser: %w", err) | |
| 177 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("spawn browser: %w", err))) | |
| 206 | 178 | } | |
| 207 | 179 | ||
| 208 | - | fmt.Printf("Restoring session %s...\n", sessionID) | |
| 180 | + | fmt.Println(renderer.RenderRestoreStarted(sessionID)) | |
| 209 | 181 | return nil | |
| 210 | 182 | } | |
| 211 | 183 | ||
| @@ -236,15 +208,16 @@ func runSessionsDelete(_ *cobra.Command, args []string) error { | |||
| 236 | 208 | if app == nil { | |
| 237 | 209 | return fmt.Errorf("app not initialized") | |
| 238 | 210 | } | |
| 211 | + | renderer := styles.NewSessionsCLIRenderer(app.Theme) | |
| 239 | 212 | ||
| 240 | 213 | if app.DeleteSessionUC == nil || app.ListSessionsUC == nil { | |
| 241 | - | return fmt.Errorf("session management not available") | |
| 214 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("session management not available"))) | |
| 242 | 215 | } | |
| 243 | 216 | ||
| 244 | 217 | // Find session by ID or suffix | |
| 245 | 218 | sessionInfo, err := findSessionByIDOrSuffix(args[0]) | |
| 246 | 219 | if err != nil { | |
| 247 | - | return err | |
| 220 | + | return fmt.Errorf("%s", renderer.RenderError(err)) | |
| 248 | 221 | } | |
| 249 | 222 | ||
| 250 | 223 | // Get current session ID | |
| @@ -258,10 +231,10 @@ func runSessionsDelete(_ *cobra.Command, args []string) error { | |||
| 258 | 231 | SessionID: sessionInfo.Session.ID, | |
| 259 | 232 | CurrentSessionID: currentSessionID, | |
| 260 | 233 | }); err != nil { | |
| 261 | - | return fmt.Errorf("delete session: %w", err) | |
| 234 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("delete session: %w", err))) | |
| 262 | 235 | } | |
| 263 | 236 | ||
| 264 | - | fmt.Printf("Session %s deleted.\n", sessionInfo.Session.ID) | |
| 237 | + | fmt.Println(renderer.RenderDeleted(sessionInfo.Session.ID)) | |
| 265 | 238 | return nil | |
| 266 | 239 | } | |
| 267 | 240 | ||
| @@ -5,13 +5,13 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | - | "text/tabwriter" | |||
| 9 | 8 | ||||
| 10 | tea "github.com/charmbracelet/bubbletea" | 9 | tea "github.com/charmbracelet/bubbletea" | ||
| 11 | "github.com/spf13/cobra" | 10 | "github.com/spf13/cobra" | ||
| 12 | 11 | ||||
| 13 | "github.com/bnema/dumber/internal/application/usecase" | 12 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 14 | "github.com/bnema/dumber/internal/cli/model" | 13 | "github.com/bnema/dumber/internal/cli/model" | ||
| 14 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 15 | "github.com/bnema/dumber/internal/domain/entity" | 15 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 16 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 16 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| 17 | ) | 17 | ) | ||
| @@ -93,9 +93,10 @@ func runSessionsList(_ *cobra.Command, _ []string) error { | |||||
| 93 | if app == nil { | 93 | if app == nil { | ||
| 94 | return fmt.Errorf("app not initialized") | 94 | return fmt.Errorf("app not initialized") | ||
| 95 | } | 95 | } | ||
| 96 | + | renderer := styles.NewSessionsCLIRenderer(app.Theme) | |||
| 96 | 97 | ||||
| 97 | if app.ListSessionsUC == nil { | 98 | if app.ListSessionsUC == nil { | ||
| 98 | - | return fmt.Errorf("session management not available") | 99 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("session management not available"))) |
| 99 | } | 100 | } | ||
| 100 | 101 | ||||
| 101 | // Get current session ID (empty if not running as browser) | 102 | // Get current session ID (empty if not running as browser) | ||
| @@ -113,7 +114,8 @@ func runSessionsList(_ *cobra.Command, _ []string) error { | |||||
| 113 | return outputSessionsJSON(output.Sessions) | 114 | return outputSessionsJSON(output.Sessions) | ||
| 114 | } | 115 | } | ||
| 115 | 116 | ||||
| 116 | - | return outputSessionsTable(output.Sessions) | 117 | + | fmt.Println(renderer.RenderList(output.Sessions, sessionsLimit)) |
| 118 | + | return nil | |||
| 117 | } | 119 | } | ||
| 118 | 120 | ||||
| 119 | func outputSessionsJSON(sessions []entity.SessionInfo) error { | 121 | func outputSessionsJSON(sessions []entity.SessionInfo) error { | ||
| @@ -122,37 +124,6 @@ func outputSessionsJSON(sessions []entity.SessionInfo) error { | |||||
| 122 | return enc.Encode(sessions) | 124 | return enc.Encode(sessions) | ||
| 123 | } | 125 | } | ||
| 124 | 126 | ||||
| 125 | - | func outputSessionsTable(sessions []entity.SessionInfo) error { | |||
| 126 | - | if len(sessions) == 0 { | |||
| 127 | - | fmt.Println("No saved sessions found.") | |||
| 128 | - | return nil | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |||
| 132 | - | _, _ = fmt.Fprintln(w, "STATUS\tSESSION ID\tTABS\tPANES\tLAST UPDATED") | |||
| 133 | - | ||||
| 134 | - | for _, info := range sessions { | |||
| 135 | - | status := " " | |||
| 136 | - | switch { | |||
| 137 | - | case info.IsCurrent: | |||
| 138 | - | status = "●" | |||
| 139 | - | case info.IsActive: | |||
| 140 | - | status = "○" | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | relTime := usecase.GetRelativeTime(info.UpdatedAt) | |||
| 144 | - | _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n", | |||
| 145 | - | status, | |||
| 146 | - | info.Session.ID, | |||
| 147 | - | info.TabCount, | |||
| 148 | - | info.PaneCount, | |||
| 149 | - | relTime, | |||
| 150 | - | ) | |||
| 151 | - | } | |||
| 152 | - | ||||
| 153 | - | return w.Flush() | |||
| 154 | - | } | |||
| 155 | - | ||||
| 156 | // sessions restore <id> | 127 | // sessions restore <id> | ||
| 157 | var sessionsRestoreCmd = &cobra.Command{ | 128 | var sessionsRestoreCmd = &cobra.Command{ | ||
| 158 | Use: "restore <session-id>", | 129 | Use: "restore <session-id>", | ||
| @@ -180,15 +151,16 @@ func runSessionsRestore(_ *cobra.Command, args []string) error { | |||||
| 180 | if app == nil { | 151 | if app == nil { | ||
| 181 | return fmt.Errorf("app not initialized") | 152 | return fmt.Errorf("app not initialized") | ||
| 182 | } | 153 | } | ||
| 154 | + | renderer := styles.NewSessionsCLIRenderer(app.Theme) | |||
| 183 | 155 | ||||
| 184 | if app.RestoreUC == nil || app.ListSessionsUC == nil { | 156 | if app.RestoreUC == nil || app.ListSessionsUC == nil { | ||
| 185 | - | return fmt.Errorf("session restoration not available") | 157 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("session restoration not available"))) |
| 186 | } | 158 | } | ||
| 187 | 159 | ||||
| 188 | // Find session by ID or suffix | 160 | // Find session by ID or suffix | ||
| 189 | sessionInfo, err := findSessionByIDOrSuffix(args[0]) | 161 | sessionInfo, err := findSessionByIDOrSuffix(args[0]) | ||
| 190 | if err != nil { | 162 | if err != nil { | ||
| 191 | - | return err | 163 | + | return fmt.Errorf("%s", renderer.RenderError(err)) |
| 192 | } | 164 | } | ||
| 193 | 165 | ||||
| 194 | sessionID := sessionInfo.Session.ID | 166 | sessionID := sessionInfo.Session.ID | ||
| @@ -196,16 +168,16 @@ func runSessionsRestore(_ *cobra.Command, args []string) error { | |||||
| 196 | // Validate the session has restorable state | 168 | // Validate the session has restorable state | ||
| 197 | _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID}) | 169 | _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID}) | ||
| 198 | if err != nil { | 170 | if err != nil { | ||
| 199 | - | return fmt.Errorf("restore session: %w", err) | 171 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("restore session: %w", err))) |
| 200 | } | 172 | } | ||
| 201 | 173 | ||||
| 202 | // Spawn a new dumber instance with the session | 174 | // Spawn a new dumber instance with the session | ||
| 203 | spawner := desktop.NewSessionSpawner(app.Ctx()) | 175 | spawner := desktop.NewSessionSpawner(app.Ctx()) | ||
| 204 | if err := spawner.SpawnWithSession(sessionID); err != nil { | 176 | if err := spawner.SpawnWithSession(sessionID); err != nil { | ||
| 205 | - | return fmt.Errorf("spawn browser: %w", err) | 177 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("spawn browser: %w", err))) |
| 206 | } | 178 | } | ||
| 207 | 179 | ||||
| 208 | - | fmt.Printf("Restoring session %s...\n", sessionID) | 180 | + | fmt.Println(renderer.RenderRestoreStarted(sessionID)) |
| 209 | return nil | 181 | return nil | ||
| 210 | } | 182 | } | ||
| 211 | 183 | ||||
| @@ -236,15 +208,16 @@ func runSessionsDelete(_ *cobra.Command, args []string) error { | |||||
| 236 | if app == nil { | 208 | if app == nil { | ||
| 237 | return fmt.Errorf("app not initialized") | 209 | return fmt.Errorf("app not initialized") | ||
| 238 | } | 210 | } | ||
| 211 | + | renderer := styles.NewSessionsCLIRenderer(app.Theme) | |||
| 239 | 212 | ||||
| 240 | if app.DeleteSessionUC == nil || app.ListSessionsUC == nil { | 213 | if app.DeleteSessionUC == nil || app.ListSessionsUC == nil { | ||
| 241 | - | return fmt.Errorf("session management not available") | 214 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("session management not available"))) |
| 242 | } | 215 | } | ||
| 243 | 216 | ||||
| 244 | // Find session by ID or suffix | 217 | // Find session by ID or suffix | ||
| 245 | sessionInfo, err := findSessionByIDOrSuffix(args[0]) | 218 | sessionInfo, err := findSessionByIDOrSuffix(args[0]) | ||
| 246 | if err != nil { | 219 | if err != nil { | ||
| 247 | - | return err | 220 | + | return fmt.Errorf("%s", renderer.RenderError(err)) |
| 248 | } | 221 | } | ||
| 249 | 222 | ||||
| 250 | // Get current session ID | 223 | // Get current session ID | ||
| @@ -258,10 +231,10 @@ func runSessionsDelete(_ *cobra.Command, args []string) error { | |||||
| 258 | SessionID: sessionInfo.Session.ID, | 231 | SessionID: sessionInfo.Session.ID, | ||
| 259 | CurrentSessionID: currentSessionID, | 232 | CurrentSessionID: currentSessionID, | ||
| 260 | }); err != nil { | 233 | }); err != nil { | ||
| 261 | - | return fmt.Errorf("delete session: %w", err) | 234 | + | return fmt.Errorf("%s", renderer.RenderError(fmt.Errorf("delete session: %w", err))) |
| 262 | } | 235 | } | ||
| 263 | 236 | ||||
| 264 | - | fmt.Printf("Session %s deleted.\n", sessionInfo.Session.ID) | 237 | + | fmt.Println(renderer.RenderDeleted(sessionInfo.Session.ID)) |
| 265 | return nil | 238 | return nil | ||
| 266 | } | 239 | } | ||
| 267 | 240 | ||||
M internal/cli/styles/config.go +14 -0
| @@ -246,3 +246,17 @@ func (r *ConfigRenderer) RenderNoConfigFile(path string) string { | |||
| 246 | 246 | hintStyle.Render("Config file will be created on first run with all defaults."), | |
| 247 | 247 | ) | |
| 248 | 248 | } | |
| 249 | + | ||
| 250 | + | // RenderOpening renders a brief "opening config" message for `dumber config open`. | |
| 251 | + | func (r *ConfigRenderer) RenderOpening(path, editor string) string { | |
| 252 | + | iconStyle := r.theme.Subtle | |
| 253 | + | pathStyle := r.theme.Highlight | |
| 254 | + | editorStyle := r.theme.Subtle | |
| 255 | + | ||
| 256 | + | return fmt.Sprintf( | |
| 257 | + | "%s Opening %s with %s", | |
| 258 | + | iconStyle.Render(IconCursor), | |
| 259 | + | pathStyle.Render(path), | |
| 260 | + | editorStyle.Render(editor), | |
| 261 | + | ) | |
| 262 | + | } | |
| @@ -246,3 +246,17 @@ func (r *ConfigRenderer) RenderNoConfigFile(path string) string { | |||||
| 246 | hintStyle.Render("Config file will be created on first run with all defaults."), | 246 | hintStyle.Render("Config file will be created on first run with all defaults."), | ||
| 247 | ) | 247 | ) | ||
| 248 | } | 248 | } | ||
| 249 | + | ||||
| 250 | + | // RenderOpening renders a brief "opening config" message for `dumber config open`. | |||
| 251 | + | func (r *ConfigRenderer) RenderOpening(path, editor string) string { | |||
| 252 | + | iconStyle := r.theme.Subtle | |||
| 253 | + | pathStyle := r.theme.Highlight | |||
| 254 | + | editorStyle := r.theme.Subtle | |||
| 255 | + | ||||
| 256 | + | return fmt.Sprintf( | |||
| 257 | + | "%s Opening %s with %s", | |||
| 258 | + | iconStyle.Render(IconCursor), | |||
| 259 | + | pathStyle.Render(path), | |||
| 260 | + | editorStyle.Render(editor), | |||
| 261 | + | ) | |||
| 262 | + | } | |||
A internal/cli/styles/config_test.go +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | package styles_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestConfigRenderer_RenderOpening(t *testing.T) { | |
| 13 | + | theme := styles.NewTheme(config.DefaultConfig()) | |
| 14 | + | r := styles.NewConfigRenderer(theme) | |
| 15 | + | ||
| 16 | + | out := r.RenderOpening("/tmp/dumber/config.toml", "vim") | |
| 17 | + | require.Contains(t, out, "Opening") | |
| 18 | + | require.Contains(t, out, "config.toml") | |
| 19 | + | require.Contains(t, out, "vim") | |
| 20 | + | } | |
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | package styles_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestConfigRenderer_RenderOpening(t *testing.T) { | |||
| 13 | + | theme := styles.NewTheme(config.DefaultConfig()) | |||
| 14 | + | r := styles.NewConfigRenderer(theme) | |||
| 15 | + | ||||
| 16 | + | out := r.RenderOpening("/tmp/dumber/config.toml", "vim") | |||
| 17 | + | require.Contains(t, out, "Opening") | |||
| 18 | + | require.Contains(t, out, "config.toml") | |||
| 19 | + | require.Contains(t, out, "vim") | |||
| 20 | + | } | |||
A internal/cli/styles/crashes_cli.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | // CrashesCLIRenderer renders non-interactive CLI output for crashes subcommands | |
| 8 | + | // that otherwise print raw crash markdown to stdout. | |
| 9 | + | type CrashesCLIRenderer struct { | |
| 10 | + | theme *Theme | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | func NewCrashesCLIRenderer(theme *Theme) *CrashesCLIRenderer { | |
| 14 | + | return &CrashesCLIRenderer{theme: theme} | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func (r *CrashesCLIRenderer) RenderError(err error) string { | |
| 18 | + | return fmt.Sprintf("%s %v", r.theme.ErrorStyle.Render(IconX), err) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (r *CrashesCLIRenderer) RenderHintList() string { | |
| 22 | + | return r.theme.Subtle.Render("Hint: run `dumber crashes` to list available reports.") | |
| 23 | + | } | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ) | |||
| 6 | + | ||||
| 7 | + | // CrashesCLIRenderer renders non-interactive CLI output for crashes subcommands | |||
| 8 | + | // that otherwise print raw crash markdown to stdout. | |||
| 9 | + | type CrashesCLIRenderer struct { | |||
| 10 | + | theme *Theme | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | func NewCrashesCLIRenderer(theme *Theme) *CrashesCLIRenderer { | |||
| 14 | + | return &CrashesCLIRenderer{theme: theme} | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func (r *CrashesCLIRenderer) RenderError(err error) string { | |||
| 18 | + | return fmt.Sprintf("%s %v", r.theme.ErrorStyle.Render(IconX), err) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (r *CrashesCLIRenderer) RenderHintList() string { | |||
| 22 | + | return r.theme.Subtle.Render("Hint: run `dumber crashes` to list available reports.") | |||
| 23 | + | } | |||
A internal/cli/styles/crashes_cli_test.go +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | package styles_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestCrashesCLIRenderer(t *testing.T) { | |
| 14 | + | theme := styles.NewTheme(config.DefaultConfig()) | |
| 15 | + | r := styles.NewCrashesCLIRenderer(theme) | |
| 16 | + | ||
| 17 | + | out := r.RenderError(errors.New("nope")) | |
| 18 | + | require.Contains(t, out, "nope") | |
| 19 | + | ||
| 20 | + | h := r.RenderHintList() | |
| 21 | + | require.Contains(t, h, "dumber crashes") | |
| 22 | + | } | |
| @@ -0,0 +1,22 @@ | |||||
| 1 | + | package styles_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestCrashesCLIRenderer(t *testing.T) { | |||
| 14 | + | theme := styles.NewTheme(config.DefaultConfig()) | |||
| 15 | + | r := styles.NewCrashesCLIRenderer(theme) | |||
| 16 | + | ||||
| 17 | + | out := r.RenderError(errors.New("nope")) | |||
| 18 | + | require.Contains(t, out, "nope") | |||
| 19 | + | ||||
| 20 | + | h := r.RenderHintList() | |||
| 21 | + | require.Contains(t, h, "dumber crashes") | |||
| 22 | + | } | |||
A internal/cli/styles/sessions_cli.go +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // SessionsCLIRenderer renders non-interactive CLI output for sessions subcommands | |
| 12 | + | // (e.g. `dumber sessions list`, `restore`, `delete`). | |
| 13 | + | type SessionsCLIRenderer struct { | |
| 14 | + | theme *Theme | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func NewSessionsCLIRenderer(theme *Theme) *SessionsCLIRenderer { | |
| 18 | + | return &SessionsCLIRenderer{theme: theme} | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (r *SessionsCLIRenderer) RenderEmptyList() string { | |
| 22 | + | return r.theme.Subtle.Render("No saved sessions found.") | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (r *SessionsCLIRenderer) RenderList(items []entity.SessionInfo, limit int) string { | |
| 26 | + | if len(items) == 0 { | |
| 27 | + | return r.RenderEmptyList() | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | var b strings.Builder | |
| 31 | + | title := fmt.Sprintf("%s %s", r.theme.Highlight.Render(IconSessionStack), r.theme.Title.Render("Sessions")) | |
| 32 | + | b.WriteString(title) | |
| 33 | + | if limit > 0 { | |
| 34 | + | b.WriteString(r.theme.Subtle.Render(fmt.Sprintf(" (showing up to %d)", limit))) | |
| 35 | + | } | |
| 36 | + | b.WriteString("\n\n") | |
| 37 | + | ||
| 38 | + | for _, s := range items { | |
| 39 | + | b.WriteString(r.renderOne(s)) | |
| 40 | + | b.WriteString("\n") | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | b.WriteString("\n") | |
| 44 | + | b.WriteString(r.theme.Subtle.Render("Tip: use `dumber sessions` for the interactive browser.")) | |
| 45 | + | return b.String() | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (r *SessionsCLIRenderer) renderOne(info entity.SessionInfo) string { | |
| 49 | + | status := " " | |
| 50 | + | statusStyle := r.theme.Subtle | |
| 51 | + | switch { | |
| 52 | + | case info.IsCurrent: | |
| 53 | + | status = "●" | |
| 54 | + | statusStyle = r.theme.Highlight | |
| 55 | + | case info.IsActive: | |
| 56 | + | status = "○" | |
| 57 | + | statusStyle = r.theme.Subtle | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | id := r.theme.Highlight.Render(string(info.Session.ID)) | |
| 61 | + | tabs := r.theme.BadgeMuted.Render(fmt.Sprintf("%d tabs", info.TabCount)) | |
| 62 | + | panes := r.theme.BadgeMuted.Render(fmt.Sprintf("%d panes", info.PaneCount)) | |
| 63 | + | updated := r.theme.Subtle.Render(usecase.GetRelativeTime(info.UpdatedAt)) | |
| 64 | + | ||
| 65 | + | return fmt.Sprintf("%s %s %s %s %s", | |
| 66 | + | statusStyle.Render(status), | |
| 67 | + | id, | |
| 68 | + | tabs, | |
| 69 | + | panes, | |
| 70 | + | updated, | |
| 71 | + | ) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (r *SessionsCLIRenderer) RenderRestoreStarted(sessionID entity.SessionID) string { | |
| 75 | + | return fmt.Sprintf("%s Restoring session %s...", | |
| 76 | + | r.theme.SuccessStyle.Render(IconRestore), | |
| 77 | + | r.theme.Highlight.Render(string(sessionID)), | |
| 78 | + | ) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (r *SessionsCLIRenderer) RenderDeleted(sessionID entity.SessionID) string { | |
| 82 | + | return fmt.Sprintf("%s Session %s deleted.", | |
| 83 | + | r.theme.SuccessStyle.Render(IconCheck), | |
| 84 | + | r.theme.Highlight.Render(string(sessionID)), | |
| 85 | + | ) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (r *SessionsCLIRenderer) RenderError(err error) string { | |
| 89 | + | return fmt.Sprintf("%s %v", r.theme.ErrorStyle.Render(IconX), err) | |
| 90 | + | } | |
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // SessionsCLIRenderer renders non-interactive CLI output for sessions subcommands | |||
| 12 | + | // (e.g. `dumber sessions list`, `restore`, `delete`). | |||
| 13 | + | type SessionsCLIRenderer struct { | |||
| 14 | + | theme *Theme | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func NewSessionsCLIRenderer(theme *Theme) *SessionsCLIRenderer { | |||
| 18 | + | return &SessionsCLIRenderer{theme: theme} | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (r *SessionsCLIRenderer) RenderEmptyList() string { | |||
| 22 | + | return r.theme.Subtle.Render("No saved sessions found.") | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (r *SessionsCLIRenderer) RenderList(items []entity.SessionInfo, limit int) string { | |||
| 26 | + | if len(items) == 0 { | |||
| 27 | + | return r.RenderEmptyList() | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | var b strings.Builder | |||
| 31 | + | title := fmt.Sprintf("%s %s", r.theme.Highlight.Render(IconSessionStack), r.theme.Title.Render("Sessions")) | |||
| 32 | + | b.WriteString(title) | |||
| 33 | + | if limit > 0 { | |||
| 34 | + | b.WriteString(r.theme.Subtle.Render(fmt.Sprintf(" (showing up to %d)", limit))) | |||
| 35 | + | } | |||
| 36 | + | b.WriteString("\n\n") | |||
| 37 | + | ||||
| 38 | + | for _, s := range items { | |||
| 39 | + | b.WriteString(r.renderOne(s)) | |||
| 40 | + | b.WriteString("\n") | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | b.WriteString("\n") | |||
| 44 | + | b.WriteString(r.theme.Subtle.Render("Tip: use `dumber sessions` for the interactive browser.")) | |||
| 45 | + | return b.String() | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (r *SessionsCLIRenderer) renderOne(info entity.SessionInfo) string { | |||
| 49 | + | status := " " | |||
| 50 | + | statusStyle := r.theme.Subtle | |||
| 51 | + | switch { | |||
| 52 | + | case info.IsCurrent: | |||
| 53 | + | status = "●" | |||
| 54 | + | statusStyle = r.theme.Highlight | |||
| 55 | + | case info.IsActive: | |||
| 56 | + | status = "○" | |||
| 57 | + | statusStyle = r.theme.Subtle | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | id := r.theme.Highlight.Render(string(info.Session.ID)) | |||
| 61 | + | tabs := r.theme.BadgeMuted.Render(fmt.Sprintf("%d tabs", info.TabCount)) | |||
| 62 | + | panes := r.theme.BadgeMuted.Render(fmt.Sprintf("%d panes", info.PaneCount)) | |||
| 63 | + | updated := r.theme.Subtle.Render(usecase.GetRelativeTime(info.UpdatedAt)) | |||
| 64 | + | ||||
| 65 | + | return fmt.Sprintf("%s %s %s %s %s", | |||
| 66 | + | statusStyle.Render(status), | |||
| 67 | + | id, | |||
| 68 | + | tabs, | |||
| 69 | + | panes, | |||
| 70 | + | updated, | |||
| 71 | + | ) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func (r *SessionsCLIRenderer) RenderRestoreStarted(sessionID entity.SessionID) string { | |||
| 75 | + | return fmt.Sprintf("%s Restoring session %s...", | |||
| 76 | + | r.theme.SuccessStyle.Render(IconRestore), | |||
| 77 | + | r.theme.Highlight.Render(string(sessionID)), | |||
| 78 | + | ) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (r *SessionsCLIRenderer) RenderDeleted(sessionID entity.SessionID) string { | |||
| 82 | + | return fmt.Sprintf("%s Session %s deleted.", | |||
| 83 | + | r.theme.SuccessStyle.Render(IconCheck), | |||
| 84 | + | r.theme.Highlight.Render(string(sessionID)), | |||
| 85 | + | ) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (r *SessionsCLIRenderer) RenderError(err error) string { | |||
| 89 | + | return fmt.Sprintf("%s %v", r.theme.ErrorStyle.Render(IconX), err) | |||
| 90 | + | } | |||
A internal/cli/styles/sessions_cli_test.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package styles_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestSessionsCLIRenderer(t *testing.T) { | |
| 16 | + | theme := styles.NewTheme(config.DefaultConfig()) | |
| 17 | + | r := styles.NewSessionsCLIRenderer(theme) | |
| 18 | + | ||
| 19 | + | out := r.RenderEmptyList() | |
| 20 | + | require.Contains(t, out, "No saved sessions found.") | |
| 21 | + | ||
| 22 | + | now := time.Now().UTC() | |
| 23 | + | items := []entity.SessionInfo{ | |
| 24 | + | { | |
| 25 | + | Session: &entity.Session{ID: entity.SessionID("20260210_120000_abcd")}, | |
| 26 | + | TabCount: 3, | |
| 27 | + | PaneCount: 5, | |
| 28 | + | IsCurrent: true, | |
| 29 | + | UpdatedAt: now, | |
| 30 | + | }, | |
| 31 | + | } | |
| 32 | + | out = r.RenderList(items, 20) | |
| 33 | + | require.Contains(t, out, "Sessions") | |
| 34 | + | require.Contains(t, out, "20260210_120000_abcd") | |
| 35 | + | require.Contains(t, out, "3 tabs") | |
| 36 | + | require.Contains(t, out, "5 panes") | |
| 37 | + | ||
| 38 | + | errOut := r.RenderError(errors.New("boom")) | |
| 39 | + | require.Contains(t, errOut, "boom") | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package styles_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestSessionsCLIRenderer(t *testing.T) { | |||
| 16 | + | theme := styles.NewTheme(config.DefaultConfig()) | |||
| 17 | + | r := styles.NewSessionsCLIRenderer(theme) | |||
| 18 | + | ||||
| 19 | + | out := r.RenderEmptyList() | |||
| 20 | + | require.Contains(t, out, "No saved sessions found.") | |||
| 21 | + | ||||
| 22 | + | now := time.Now().UTC() | |||
| 23 | + | items := []entity.SessionInfo{ | |||
| 24 | + | { | |||
| 25 | + | Session: &entity.Session{ID: entity.SessionID("20260210_120000_abcd")}, | |||
| 26 | + | TabCount: 3, | |||
| 27 | + | PaneCount: 5, | |||
| 28 | + | IsCurrent: true, | |||
| 29 | + | UpdatedAt: now, | |||
| 30 | + | }, | |||
| 31 | + | } | |||
| 32 | + | out = r.RenderList(items, 20) | |||
| 33 | + | require.Contains(t, out, "Sessions") | |||
| 34 | + | require.Contains(t, out, "20260210_120000_abcd") | |||
| 35 | + | require.Contains(t, out, "3 tabs") | |||
| 36 | + | require.Contains(t, out, "5 panes") | |||
| 37 | + | ||||
| 38 | + | errOut := r.RenderError(errors.New("boom")) | |||
| 39 | + | require.Contains(t, errOut, "boom") | |||
| 40 | + | } | |||